Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions app/e2e-mock-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ const SEED_MEETING = {
discussion_areas: [],
};

// A generated template report attached to SEED_MEETING when
// STENOAI_E2E_SEED_REPORT=1 (copy-notes-report T1). Gated separately so the
// transcript-export T1 keeps seeing the report-less meeting it asserts on.
const SEED_REPORT = {
id: 'rep_e2e_1',
template_id: 'tpl_e2e_status',
template_name: 'Status Report',
model: 'mock-model',
// Leading <think> block: the copy path must strip reasoning like the
// rendered view does, so the spec can assert it never reaches the clipboard.
content:
'<think>secret chain of thought</think>\n## Status Report\n\n- Pipeline healthy\n- Next: open the reqs',
created_at: '2026-06-19T13:00:00Z',
};

// Mutable so the stateful set-active-report mock below persists the pill
// switch across the invalidate → get-meeting refetch, like the real sidecar.
let seedActiveReport = null;

const seededMeeting = () =>
process.env.STENOAI_E2E_SEED_REPORT === '1'
? { ...SEED_MEETING, reports: [SEED_REPORT], active_report: seedActiveReport }
: SEED_MEETING;

function install({ ipcMain }) {
// In-memory stand-in for the org session + provider config that the real
// handlers persist to disk. Mutated by the org-login / org-logout / set-ai
Expand Down Expand Up @@ -95,17 +119,25 @@ function install({ ipcMain }) {
// channel.
'list-meetings': async () => {
if (process.env.STENOAI_E2E_SEED_MEETING === '1') {
return { success: true, meetings: [SEED_MEETING] };
return { success: true, meetings: [seededMeeting()] };
}
return { success: true, meetings: SEEDED_MEETINGS };
},

// Mirror the real handler just enough for the copy-notes-report T1: persist
// the switch so the refetch that follows the mutation doesn't reset the
// pill. 'standard' clears it, like src/reports.py set_active.
'set-active-report': async (_event, _summaryFile, reportId) => {
seedActiveReport = !reportId || reportId === 'standard' ? null : reportId;
return { success: true };
},

// The detail route loads via get-meeting (the lazy per-meeting fetch), not
// by filtering list-meetings — answer it with the same seeded meeting so the
// transcript-export detail route resolves and renders the transcript actions.
'get-meeting': async (_event, summaryFile) => {
if (process.env.STENOAI_E2E_SEED_MEETING === '1') {
return { success: true, meeting: SEED_MEETING };
return { success: true, meeting: seededMeeting() };
}
const m = SEEDED_MEETINGS.find(
(x) => x.session_info && x.session_info.summary_file === summaryFile,
Expand Down
88 changes: 88 additions & 0 deletions app/renderer/src/lib/notesCopy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, test, expect } from 'vitest';
import { buildNotesCopyText, type NotesCopySections } from '@/lib/notesCopy';

/**
* Unit coverage for buildNotesCopyText — the builder behind the "Copy notes"
* header action. The load-bearing case: when a generated template report is
* open, the copy must contain THAT report, not the Standard structured note
* (the on-screen content and the clipboard must never disagree).
*/

const sections: NotesCopySections = {
name: 'Weekly sync',
meta: 'Mon, Jun 23, 2026, 10:00 AM · 45m',
summary: 'We discussed the roadmap.',
discussionAreas: [
{ title: 'Roadmap', analysis: 'Q3 priorities agreed.' },
{ title: 'Hiring' },
],
keyPoints: ['Ship v2 in July'],
actionItems: ['Ben: draft the announcement'],
participants: ['Ben', 'Ruzin'],
};

describe('buildNotesCopyText', () => {
test('no active report → the Standard structured note, all sections in order', () => {
const text = buildNotesCopyText(sections, null);
expect(text).toBe(
[
'Weekly sync',
'Mon, Jun 23, 2026, 10:00 AM · 45m',
'',
'SUMMARY',
'We discussed the roadmap.',
'',
'KEY TOPICS',
'- Roadmap: Q3 priorities agreed.',
'- Hiring',
'',
'KEY POINTS',
'- Ship v2 in July',
'',
'ACTION ITEMS',
'- Ben: draft the announcement',
'',
'PARTICIPANTS',
'Ben, Ruzin',
].join('\n'),
);
});

test('active report → title + meta + the report markdown, no Standard sections', () => {
const text = buildNotesCopyText(sections, {
content: '## 1:1 Notes\n\n- Roadmap locked\n',
});
expect(text).toBe(
[
'Weekly sync',
'Mon, Jun 23, 2026, 10:00 AM · 45m',
'',
'## 1:1 Notes',
'',
'- Roadmap locked',
].join('\n'),
);
expect(text).not.toContain('SUMMARY');
});

test('empty sections are omitted entirely (no dangling headers)', () => {
const text = buildNotesCopyText(
{
name: 'Quick call',
meta: undefined,
summary: ' ',
discussionAreas: [],
keyPoints: [],
actionItems: [],
participants: [],
},
null,
);
expect(text).toBe('Quick call');
});

test('active report with only-whitespace content still copies just title + meta', () => {
const text = buildNotesCopyText(sections, { content: ' \n ' });
expect(text).toBe('Weekly sync\nMon, Jun 23, 2026, 10:00 AM · 45m');
});
});
54 changes: 54 additions & 0 deletions app/renderer/src/lib/notesCopy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Builds the plain-text payload for the "Copy notes" header action in
* MeetingDetail. Pure so the report-vs-Standard selection is unit-testable:
* when a generated template report is open, the clipboard must carry that
* report's markdown, not the Standard structured note — the copy always
* matches what's on screen.
*/

export interface NotesCopySections {
name: string;
meta?: string;
summary?: string;
discussionAreas: { title: string; analysis?: string }[];
keyPoints: string[];
actionItems: string[];
participants: string[];
}

export function buildNotesCopyText(
sections: NotesCopySections,
activeReport: { content: string } | null,
): string {
const lines: string[] = [sections.name];
if (sections.meta) lines.push(sections.meta);

if (activeReport) {
const content = activeReport.content.trim();
if (content) lines.push('', content);
return lines.join('\n');
}

const summary = sections.summary?.trim();
if (summary) {
lines.push('', 'SUMMARY', summary);
}
if (sections.discussionAreas.length) {
lines.push('', 'KEY TOPICS');
sections.discussionAreas.forEach((a) =>
lines.push(`- ${a.title}${a.analysis ? `: ${a.analysis}` : ''}`),
);
}
if (sections.keyPoints.length) {
lines.push('', 'KEY POINTS');
sections.keyPoints.forEach((p) => lines.push(`- ${p}`));
}
if (sections.actionItems.length) {
lines.push('', 'ACTION ITEMS');
sections.actionItems.forEach((a) => lines.push(`- ${a}`));
}
if (sections.participants.length) {
lines.push('', 'PARTICIPANTS', sections.participants.join(', '));
}
return lines.join('\n');
}
48 changes: 22 additions & 26 deletions app/renderer/src/routes/MeetingDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
import { useActiveMeeting } from '@/lib/askBarContext';
import { ipc, type Meeting, type Report, type Template } from '@/lib/ipc';
import { buildTranscriptBundle, defaultExportFilename } from '@/lib/transcriptBundle';
import { buildNotesCopyText } from '@/lib/notesCopy';
import { unwrap } from '@/lib/result';
import { cn } from '@/lib/utils';
import { navigate } from '@/lib/router';
Expand Down Expand Up @@ -478,36 +479,27 @@ function DetailContent({ meeting }: { meeting: Meeting }) {
}
};

// Copies whichever note is on screen: the open template report when one is
// selected, otherwise the Standard structured note. Reasoning blocks are
// stripped like the rendered views do, so the clipboard never carries
// <think> content the screen hides.
const copyNotes = () => {
const lines: string[] = [info.name];
const meta = [formatDetailDate(info), formatDuration(info.duration_seconds)]
.filter(Boolean)
.join(' · ');
if (meta) lines.push(meta);
const summary = meeting.summary ? stripReasoning(meeting.summary).trim() : undefined;
if (summary) {
lines.push('', 'SUMMARY', summary);
}
const dAreas = asDiscussionAreas(meeting.discussion_areas);
if (dAreas.length) {
lines.push('', 'KEY TOPICS');
dAreas.forEach((a) => lines.push(`- ${a.title}${a.analysis ? `: ${a.analysis}` : ''}`));
}
const kp = meeting.key_points ?? [];
if (kp.length) {
lines.push('', 'KEY POINTS');
kp.forEach((p) => lines.push(`- ${p}`));
}
const ai = asStringArray(meeting.action_items);
if (ai.length) {
lines.push('', 'ACTION ITEMS');
ai.forEach((a) => lines.push(`- ${a}`));
}
const parts = asStringArray(meeting.participants);
if (parts.length) {
lines.push('', 'PARTICIPANTS', parts.join(', '));
}
void navigator.clipboard.writeText(lines.join('\n'));
const text = buildNotesCopyText(
{
name: info.name,
meta: meta || undefined,
summary: meeting.summary ? stripReasoning(meeting.summary) : undefined,
discussionAreas: asDiscussionAreas(meeting.discussion_areas),
keyPoints: meeting.key_points ?? [],
actionItems: asStringArray(meeting.action_items),
participants: asStringArray(meeting.participants),
},
activeReport ? { content: stripReasoning(activeReport.content) } : null,
);
void navigator.clipboard.writeText(text);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Copy notes can show a false success state when clipboard write fails, because the handler marks copied immediately instead of after a successful write. Matching the transcript copy pattern (or chaining .then) would keep feedback aligned with actual clipboard outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/renderer/src/routes/MeetingDetail.tsx, line 496:

<comment>Copy notes can show a false success state when clipboard write fails, because the handler marks `copied` immediately instead of after a successful write. Matching the transcript copy pattern (or chaining `.then`) would keep feedback aligned with actual clipboard outcome.</comment>

<file context>
@@ -474,36 +475,25 @@ function DetailContent({ meeting }: { meeting: Meeting }) {
+      },
+      activeReport,
+    );
+    void navigator.clipboard.writeText(text);
     setCopied(true);
     setTimeout(() => setCopied(false), 2000);
</file context>

setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
Expand Down Expand Up @@ -545,9 +537,13 @@ function DetailContent({ meeting }: { meeting: Meeting }) {
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
{/* Disabled while a summary/report stream is on screen — the
clipboard would otherwise get the old note while the body
shows the in-flux streamed text. */}
<ActionIconButton
label={copied ? 'Copied' : 'Copy notes'}
onClick={copyNotes}
disabled={streamPhase !== 'idle'}
>
{copied ? <Check className="size-[13px]" /> : <Copy className="size-[13px]" />}
</ActionIconButton>
Expand Down
87 changes: 87 additions & 0 deletions e2e/specs/copy-notes-report.t1.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test, expect } from '../fixtures/electron';
import type { Page } from '@playwright/test';

/**
* T1 — renderer-only, mock IPC, no backend. "Copy notes" must copy whichever
* note is on screen: with a generated template report open it copies THAT
* report's markdown, not the Standard structured note (the regression: the
* clipboard silently disagreed with the displayed report).
*
* Seams (mirrors of the real ones, see app/e2e-mock-ipc.js):
* - STENOAI_E2E_SEED_MEETING=1 + STENOAI_E2E_SEED_REPORT=1 seed one known
* meeting carrying one generated report (rep_e2e_1, "Status Report").
* - the clipboard is captured by replacing navigator.clipboard in-page (no OS
* clipboard dependency in CI), same recorder as transcript-export.t1.
*/

const SUMMARY_FILE = 'epsilon_summary.json';

async function installClipboardRecorder(page: Page) {
await page.evaluate(() => {
const w = window as unknown as { __clipboardWrites: string[] };
w.__clipboardWrites = [];
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: (text: string) => {
w.__clipboardWrites.push(text);
return Promise.resolve();
},
},
});
});
}

const clipboardWrites = (page: Page) =>
page.evaluate(() => (window as unknown as { __clipboardWrites: string[] }).__clipboardWrites);

test('Copy notes copies the open report, and the Standard note when none is open', async ({
launchApp,
}) => {
const { page } = await launchApp({
mockIpc: true,
env: { STENOAI_E2E_SEED_MEETING: '1', STENOAI_E2E_SEED_REPORT: '1' },
});
await installClipboardRecorder(page);

await page.evaluate((f) => {
window.location.hash = `#/meetings/${encodeURIComponent(f)}`;
}, SUMMARY_FILE);
await expect(page.getByRole('button', { name: 'Copy notes' })).toBeVisible();
// The seeded report shows up as a pill next to Standard.
const reportPill = page.getByTestId('report-switch').getByRole('button', {
name: /Status Report/,
});
await expect(reportPill).toBeVisible();

// Standard note open (active_report is null) → the structured-note copy.
await page.getByRole('button', { name: 'Copy notes' }).click();
let writes = await clipboardWrites(page);
expect(writes).toHaveLength(1);
expect(writes[0]).toContain('Epsilon Planning');
expect(writes[0]).toContain('PARTICIPANTS');
expect(writes[0]).toContain('Alice, Bob');
expect(writes[0]).not.toContain('## Status Report');

// Open the generated report, then copy again → the report's markdown.
await reportPill.click();
await expect(page.getByText('Pipeline healthy')).toBeVisible();
await page.getByRole('button', { name: 'Copy notes' }).click();
writes = await clipboardWrites(page);
expect(writes).toHaveLength(2);
expect(writes[1]).toContain('Epsilon Planning');
expect(writes[1]).toContain('- Pipeline healthy');
expect(writes[1]).toContain('- Next: open the reqs');
expect(writes[1]).not.toContain('PARTICIPANTS');
// The seeded report starts with a <think> block; the copy must strip
// reasoning like the rendered view does.
expect(writes[1]).not.toContain('secret chain of thought');

// Switching back to Standard restores the structured-note copy.
await page.getByTestId('report-switch').getByRole('button', { name: 'Standard' }).click();
await page.getByRole('button', { name: 'Copy notes' }).click();
writes = await clipboardWrites(page);
expect(writes).toHaveLength(3);
expect(writes[2]).toContain('PARTICIPANTS');
expect(writes[2]).not.toContain('Pipeline healthy');
});
Loading