diff --git a/app/e2e-mock-ipc.js b/app/e2e-mock-ipc.js index 1ed12fb5..7b0558ff 100644 --- a/app/e2e-mock-ipc.js +++ b/app/e2e-mock-ipc.js @@ -525,6 +525,48 @@ function install({ ipcMain }) { return { success: true, path: seamPath }; }, + // Share capability. Injectable precisely because the real answer is a + // platform fact: gating the renderer on this instead of navigator.platform + // is what lets a T1 spec exercise BOTH branches on any OS, including the + // absent branch that keeps Windows away from a ShareMenu it does not have. + 'share-capability': async () => process.env.STENOAI_E2E_SHARE_CAPABLE === '1', + + // Mirror the real share-note-file handler far enough to observe the call. + // Appends one JSON line per call to STENOAI_E2E_SHARE_LOG so a spec can + // count calls (the pending guard) and read back what was passed. No file is + // materialised and no sheet is popped: that is the real handler's job and + // the T2 spec's assertion. STENOAI_E2E_SHARE_DELAY_MS holds the call open so + // the "Preparing…" state is observable without racing the clock. + // + // STENOAI_E2E_SHARE_PAYLOAD_PATH writes the payload VERBATIM, the same seam + // STENOAI_E2E_EXPORT_PATH gives the save path. The log's 200-char head is + // enough to tell a PDF from markdown, but a branded PDF's first 200 chars + // are doctype and font CSS — the note itself is thousands of characters in, + // so asserting WHICH note was shared needs the whole payload. + 'share-note-file': async (_event, kind, defaultFilename, payload, anchor) => { + const logPath = process.env.STENOAI_E2E_SHARE_LOG; + if (logPath) { + fs.appendFileSync( + logPath, + JSON.stringify({ + kind, + defaultFilename, + anchor, + payloadLength: typeof payload === 'string' ? payload.length : null, + payloadHead: typeof payload === 'string' ? payload.slice(0, 200) : null, + }) + '\n', + 'utf-8', + ); + } + const payloadPath = process.env.STENOAI_E2E_SHARE_PAYLOAD_PATH; + if (payloadPath && typeof payload === 'string') { + fs.writeFileSync(payloadPath, payload, 'utf-8'); + } + const delay = Number(process.env.STENOAI_E2E_SHARE_DELAY_MS || 0); + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + return { success: true }; + }, + 'org-status': async () => { if (!state.orgSession) { return { signedIn: false, everSignedIn: state.everSignedIn }; diff --git a/app/main.js b/app/main.js index 413cb0df..e4fc6e17 100644 --- a/app/main.js +++ b/app/main.js @@ -62,6 +62,7 @@ const { isOSUpdateEligible, MIN_MACOS_FOR_AUTOUPDATE } = require('./update-os-ga const processingLog = require('./processing-log'); const { isMeetingApp, allowsDeviceLevelFallback, isMacos14Plus } = require('./meeting-detect'); const { sweepOrphanedLiveSnapshots } = require('./live-snapshot-sweep'); +const { shareTempDir, sweepShareTemp, SHARE_TEMP_MAX_AGE_MS } = require('./share-temp'); const { userNotesFilePath } = require('./notes-file'); const { makeLineReader } = require('./backend-stream'); // Pure deep-link (stenoai://) parsing/sanitizing lives in ./shortcut-url @@ -113,6 +114,24 @@ if (process.env.STENOAI_USER_DATA_DIR) { app.setPath('userData', process.env.STENOAI_USER_DATA_DIR); } const IS_E2E = process.env.STENOAI_E2E === '1'; +// The subset of the harness that redirects WHERE BYTES GO: the STENOAI_E2E_*_PATH +// seams in export-transcript / export-note-pdf / save-diagnostics, and the +// path-returning seam in share-note-file. Those need a second condition beyond +// IS_E2E, because IS_E2E is nothing but an environment variable: a signed build +// started with STENOAI_E2E=1 plus one of those paths would silently write a +// user's export to an attacker-chosen location instead of the file they picked +// in the save dialog, and never show the dialog at all. +// +// `!app.isPackaged` is the right second condition rather than a stricter one: +// every e2e lane launches the DEV binary from source (`electron.launch({ args: +// ['.'] })` in e2e/fixtures/electron.ts, and the release gate's T1 smoke runs +// the same way), so no legitimate test run is packaged. A packaged build now +// ignores these seams entirely, whatever it is started with. +// +// Deliberately NOT folded into IS_E2E itself: its other ~30 uses gate the tray, +// the dock, the scheduler and telemetry, and re-gating those on packaging would +// be a far wider behavioural change than closing this hole. +const ALLOW_E2E_PATH_SEAMS = IS_E2E && !app.isPackaged; const IS_E2E_MOCK_IPC = process.env.STENOAI_E2E_MOCK_IPC === '1'; // Global (system-wide) accelerator to toggle recording. CommandOrControl @@ -2182,6 +2201,25 @@ if (!gotSingleInstanceLock) { console.warn('Live-snapshot sweep failed (non-fatal):', e?.message); } + // Sweep share-sheet temp files older than a day. This is the ONLY cleanup + // for that directory: a file handed to ShareMenu must survive as long as the + // destination holds it (an open Mail draft, an AirDrop transfer waiting to be + // accepted), and popup() never reports when that ends - so nothing is deleted + // during a session. See share-temp.js for the full reasoning. Best-effort: an + // undeletable temp directory must not interrupt a launch. + try { + const { deleted } = sweepShareTemp( + shareTempDir(app.getPath('temp')), + Date.now(), + SHARE_TEMP_MAX_AGE_MS, + ); + if (deleted.length > 0) { + sendDebugLog(`Swept ${deleted.length} stale share temp file(s)`); + } + } catch (e) { + console.warn('Share temp sweep failed (non-fatal):', e?.message); + } + // Load custom storage path for file validation. Skipped under E2E (spawns // the backend; the test tiers keep startup backend-free). if (!IS_E2E) { @@ -3759,9 +3797,10 @@ ipcMain.handle('export-transcript', async (event, defaultFilename, content) => { return { success: false, error: 'No transcript content to export.' }; } - // Test-only seam: only honor it under e2e, so a stray env var in a real - // launch can't silently redirect a user's export to an arbitrary path. - const seamPath = IS_E2E ? process.env.STENOAI_E2E_EXPORT_PATH : undefined; + // Test-only seam: honored only in an UNPACKAGED e2e run, so a stray env var + // in a real launch can't silently redirect a user's export to an arbitrary + // path. See ALLOW_E2E_PATH_SEAMS - IS_E2E alone is just an env var. + const seamPath = ALLOW_E2E_PATH_SEAMS ? process.env.STENOAI_E2E_EXPORT_PATH : undefined; let targetPath = seamPath; if (!targetPath) { @@ -3883,9 +3922,10 @@ ipcMain.handle('export-note-pdf', async (event, defaultFilename, html) => { return { success: false, error: 'No notes content to export.' }; } - // Test-only seam: only honor it under e2e, so a stray env var in a real - // launch can't silently redirect a user's export to an arbitrary path. - const seamPath = IS_E2E ? process.env.STENOAI_E2E_EXPORT_PATH : undefined; + // Test-only seam: honored only in an UNPACKAGED e2e run (see + // ALLOW_E2E_PATH_SEAMS), so a stray env var in a real launch can't silently + // redirect a user's export to an arbitrary path. + const seamPath = ALLOW_E2E_PATH_SEAMS ? process.env.STENOAI_E2E_EXPORT_PATH : undefined; let targetPath = seamPath; if (!targetPath) { @@ -3937,9 +3977,10 @@ ipcMain.handle('save-diagnostics', async (event, defaultFilename, content) => { return { success: false, error: 'No diagnostics content to save.' }; } - // Test-only seam: only honor it under e2e, so a stray env var in a real - // launch can't silently redirect a user's save to an arbitrary path. - const seamPath = IS_E2E ? process.env.STENOAI_E2E_DIAGNOSTICS_PATH : undefined; + // Test-only seam: honored only in an UNPACKAGED e2e run (see + // ALLOW_E2E_PATH_SEAMS), so a stray env var in a real launch can't silently + // redirect a user's save to an arbitrary path. + const seamPath = ALLOW_E2E_PATH_SEAMS ? process.env.STENOAI_E2E_DIAGNOSTICS_PATH : undefined; let targetPath = seamPath; if (!targetPath) { @@ -3978,6 +4019,129 @@ ipcMain.handle('save-diagnostics', async (event, defaultFilename, content) => { } }); +// Whether a native share sheet actually exists on this machine. macOS-only: +// Electron exposes ShareMenu on darwin and nothing anywhere else. The second +// half of the check is not paranoia - if a future Electron drops or renames the +// export, this degrades to a hidden menu group instead of a main-process crash +// on `new undefined(...)`. +function shareSheetAvailable() { + if (process.platform !== 'darwin') return false; + try { + return typeof require('electron').ShareMenu === 'function'; + } catch (_) { + return false; + } +} + +// The renderer asks once and hides the three share entries when this is false. +// That flag is only a hint for what to draw; the handler below re-checks, which +// is what actually keeps a stray call from crashing the process. +ipcMain.handle('share-capability', () => shareSheetAvailable()); + +// Hand a note to the native macOS share sheet (AirDrop, Mail, Messages, Notes). +// Follows the same split as the three export handlers above: the renderer builds +// the content, we own the bytes. Differences from them, all deliberate: +// - No save dialog. The destination is our own managed temp directory, because +// the sheet needs a real file and the user never picks a location. +// - The filename is user-visible: it becomes the attachment name the recipient +// reads, so defaultExportFilename()'s dated slug is used as-is. +// - Files are never deleted during the session (see share-temp.js). +// Returns { success } only; success means the sheet opened, since popup() has no +// completion callback and the app never learns the destination. +ipcMain.handle('share-note-file', async (event, kind, defaultFilename, payload, anchor) => { + try { + if (kind !== 'pdf' && kind !== 'text') { + return { success: false, error: 'Unsupported share type.' }; + } + if (typeof payload !== 'string' || payload.length === 0) { + return { success: false, error: 'No content to share.' }; + } + + // Fail before rendering: a stray call off darwin must not pay for a 15 + // second PDF render just to be refused. In an unpackaged e2e run we + // deliberately continue on every platform, because the file write is what + // those specs assert and T2 runs on Windows too. + if (!ALLOW_E2E_PATH_SEAMS && !shareSheetAvailable()) { + return { success: false, error: 'Sharing is not available on this platform.' }; + } + + // The renderer supplies a suggested name only. Unlike the export handlers, + // where the reduced name merely seeds a dialog the user confirms, here it is + // joined onto a directory we write into - so `.` and `..` have to be rejected + // as well, not just directory components. + const fallback = kind === 'pdf' ? 'notes.pdf' : 'notes.md'; + let base = fallback; + if (typeof defaultFilename === 'string' && defaultFilename.trim()) { + const reduced = path.basename(defaultFilename).slice(0, 200); + if (reduced && reduced !== '.' && reduced !== '..') base = reduced; + } + + const dir = shareTempDir(app.getPath('temp')); + const targetPath = path.join(dir, base); + + // Rasterise BEFORE touching the destination, so a render failure leaves no + // file behind. Mirrors export-note-pdf. + const bytes = kind === 'pdf' ? await renderHtmlToPdf(payload) : payload; + + // Atomic write: tmp file in the SAME directory, then rename into place. + // Sharing the same note twice overwrites the first file, which is fine: the + // content is identical and Mail copies an attachment into the draft. The + // exception, since the name is deliberately not randomised: another process + // running as the same user could have planted a file of that name in this + // directory first, and the rename would replace it. Accepted - the filename + // has to stay the readable one the recipient sees, and anyone who can write + // here can already read the notes we put here. + const tmpPath = path.join(dir, `.${base}.${require('crypto').randomBytes(6).toString('hex')}.tmp`); + try { + await fs.promises.writeFile(tmpPath, bytes, kind === 'pdf' ? undefined : 'utf-8'); + await fs.promises.rename(tmpPath, targetPath); + } catch (writeErr) { + try { await fs.promises.unlink(tmpPath); } catch (_) {} + throw writeErr; + } + + // Test-only seam, mirroring STENOAI_E2E_EXPORT_PATH: a native sheet cannot be + // automated (Playwright can neither see nor dismiss it, and an open sheet + // blocks the run), so under e2e we stop after the write and return where the + // file went. The path is returned ONLY here - outside the seam the renderer + // has no business knowing, and a leaked absolute path is exactly what the + // basename reduction exists to prevent. + if (ALLOW_E2E_PATH_SEAMS) return { success: true, path: targetPath }; + + // Re-check rather than trust the entry gate above: this is the line that + // stops `new undefined(...)` from taking the main process down. + if (!shareSheetAvailable()) { + return { success: false, error: 'Sharing is not available on this platform.' }; + } + if (!mainWindow || mainWindow.isDestroyed()) { + return { success: false, error: 'No window to share from.' }; + } + const { ShareMenu } = require('electron'); + const menu = new ShareMenu({ filePaths: [targetPath] }); + // Anchor on the clicked entry so the sheet pops from the button rather than + // the window corner. The rectangle was read up to 15 seconds ago, before the + // PDF render, so a scroll, a window move or a resize can have moved it, and + // after a shrink it can sit outside the window entirely. An out-of-range + // anchor is worse than none: dropping it lets Electron place the sheet with + // coordinates that are at least current. A malformed anchor is dropped for + // the same reason rather than defaulted to 0,0. + const popupOptions = { window: mainWindow }; + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const { width, height } = mainWindow.getContentBounds(); + const x = Math.round(anchor.x); + const y = Math.round(anchor.y); + if (x >= 0 && y >= 0 && x <= width && y <= height) { + popupOptions.x = x; + popupOptions.y = y; + } + } + menu.popup(popupOptions); + return { success: true }; + } catch (err) { + return { success: false, error: String(err && err.message ? err.message : err) }; + } +}); + // Replace (or remove, when empty) the "## User Notes" section at the tail of a // meeting markdown body. Kept a pure string transform so it's unit-testable and // never touches the summary/transcript sections above it. `body` is everything diff --git a/app/package.json b/app/package.json index 904fc7bc..c16269ff 100644 --- a/app/package.json +++ b/app/package.json @@ -19,7 +19,7 @@ "typecheck:renderer": "tsc -p renderer/tsconfig.json --noEmit", "lint:renderer": "eslint --config renderer/eslint.config.mjs renderer/src", "format:renderer": "prettier --write renderer/src", - "test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js update-error-copy.test.js && vitest run", + "test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js update-error-copy.test.js share-temp.test.js && vitest run", "build": "npm run build:renderer && electron-builder", "pack:unsigned": "npm run build:renderer && electron-builder --dir --config electron-builder.ci.yml", "build-mac": "npm run build:renderer && electron-builder --mac", diff --git a/app/preload.js b/app/preload.js index 6b7a50a5..1f97bae1 100644 --- a/app/preload.js +++ b/app/preload.js @@ -346,6 +346,16 @@ const stenoai = { chatStream: (streamId, payload) => send('org-chat-stream', streamId, payload), }, + // Native macOS share sheet. canShare answers "does a share sheet exist here", + // NOT "does the user agent look like a Mac" - the renderer's isMac is a + // module-level navigator.platform constant frozen at import, so it can neither + // be flipped at runtime nor exercised in both directions by a test. + share: { + canShare: () => invoke('share-capability'), + shareFile: (kind, defaultFilename, payload, anchor) => + invoke('share-note-file', kind, defaultFilename, payload, anchor), + }, + dialog: { respondQuit: (confirmed) => send('quit-dialog-response', { confirmed }), }, diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index 04f35f3b..6323ecdb 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -1193,6 +1193,27 @@ export interface StenoaiBridge { chatStream: SendFn<[streamId: string, payload: OrgChatPayload]>; }; + share: { + /** Whether a native share sheet exists on this machine (macOS only). Treat + * an unresolved query as false so the entries never flash in before the + * capability is known. */ + canShare: RequestFn<[], boolean>; + /** Write the payload into the managed share temp directory and pop the + * native sheet on it. `kind: 'pdf'` takes HTML and rasterises it first; + * `'text'` is written as UTF-8. `anchor` is a point in window coordinates + * so the sheet pops from the clicked entry. `success` means only that the + * sheet opened - the destination is never reported back. */ + shareFile: RequestFn< + [ + kind: 'pdf' | 'text', + defaultFilename: string, + payload: string, + anchor: { x: number; y: number }, + ], + Result> + >; + }; + dialog: { respondQuit: SendFn<[confirmed: boolean]>; }; diff --git a/app/renderer/src/lib/notesMarkdown.test.ts b/app/renderer/src/lib/notesMarkdown.test.ts new file mode 100644 index 00000000..44dcf921 --- /dev/null +++ b/app/renderer/src/lib/notesMarkdown.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'vitest'; +import type { StructuredNoteSections } from './notesCopy'; +import { buildNotesMarkdown } from './notesMarkdown'; + +const fullSections: StructuredNoteSections = { + name: 'Weekly *sync*', + meta: 'Mon, Jun 23, 2026, 10:00 AM · 45m', + summary: 'We discussed **the roadmap**.', + discussionAreas: [ + { + title: 'Roadmap & scope', + analysis: 'Q3 priorities [agreed](https://example.com/roadmap).', + }, + { title: 'Hiring' }, + ], + keyPoints: ['Ship `v2` in July'], + actionItems: ['Ben: prepare '], + participants: ['Ben', 'Ruzin & Alex'], +}; + +describe('buildNotesMarkdown', () => { + test('renders every structured field in copy-export order without escaping body text', () => { + expect(buildNotesMarkdown(fullSections, null)).toBe( + [ + '# Weekly *sync*', + 'Mon, Jun 23, 2026, 10:00 AM · 45m', + '', + '## Summary', + '', + 'We discussed **the roadmap**.', + '', + '## Key Topics', + '', + '### Roadmap & scope', + '', + 'Q3 priorities [agreed](https://example.com/roadmap).', + '', + '### Hiring', + '', + '## Key Points', + '', + '- Ship `v2` in July', + '', + '## Action Items', + '', + '- Ben: prepare ', + '', + '## Participants', + '', + 'Ben, Ruzin & Alex', + ].join('\n'), + ); + }); + + test('removing any handled structured field changes the output', () => { + const complete = buildNotesMarkdown(fullSections, null); + const removals: Array<[string, StructuredNoteSections]> = [ + ['title', { ...fullSections, name: '' }], + ['meta', { ...fullSections, meta: undefined }], + ['summary', { ...fullSections, summary: undefined }], + [ + 'discussion title', + { + ...fullSections, + discussionAreas: [ + { analysis: fullSections.discussionAreas[0].analysis, title: '' }, + fullSections.discussionAreas[1], + ], + }, + ], + [ + 'discussion analysis', + { + ...fullSections, + discussionAreas: [ + { title: fullSections.discussionAreas[0].title }, + fullSections.discussionAreas[1], + ], + }, + ], + ['discussion areas', { ...fullSections, discussionAreas: [] }], + ['key points', { ...fullSections, keyPoints: [] }], + ['action items', { ...fullSections, actionItems: [] }], + ['participants', { ...fullSections, participants: [] }], + ]; + + removals.forEach(([field, sections]) => { + expect(buildNotesMarkdown(sections, null), field).not.toBe(complete); + }); + }); + + test('omits every empty section without leaving a heading', () => { + const markdown = buildNotesMarkdown( + { + name: 'Empty note', + summary: ' ', + discussionAreas: [], + keyPoints: [], + actionItems: [], + participants: [], + }, + null, + ); + + expect(markdown).toBe('# Empty note'); + expect(markdown).not.toContain('## Summary'); + expect(markdown).not.toContain('## Key Topics'); + expect(markdown).not.toContain('## Key Points'); + expect(markdown).not.toContain('## Action Items'); + expect(markdown).not.toContain('## Participants'); + }); + + test('an open active report preserves title and meta while replacing the structured body', () => { + const content = '## 1:1 Notes\n\n- Roadmap \n'; + const markdown = buildNotesMarkdown(fullSections, { content }); + + expect(markdown).toBe( + [ + '# Weekly *sync*', + 'Mon, Jun 23, 2026, 10:00 AM · 45m', + '', + content, + ].join('\n'), + ); + expect(markdown).not.toContain('## Summary'); + expect(markdown).not.toContain('## Key Topics'); + expect(buildNotesMarkdown(fullSections, { content: '' })).not.toBe(markdown); + }); +}); diff --git a/app/renderer/src/lib/notesMarkdown.ts b/app/renderer/src/lib/notesMarkdown.ts new file mode 100644 index 00000000..f16379c0 --- /dev/null +++ b/app/renderer/src/lib/notesMarkdown.ts @@ -0,0 +1,49 @@ +import type { StructuredNoteSections } from './notesCopy'; + +function hasText(value: string | undefined): value is string { + return Boolean(value?.trim()); +} + +export function buildNotesMarkdown( + sections: StructuredNoteSections, + activeReport: { content: string } | null, +): string { + const titleBlock = [`# ${sections.name}`]; + if (sections.meta) titleBlock.push(sections.meta); + + const blocks: string[] = [titleBlock.join('\n')]; + + if (activeReport) { + if (hasText(activeReport.content)) blocks.push(activeReport.content); + return blocks.join('\n\n'); + } + + if (hasText(sections.summary)) { + blocks.push(`## Summary\n\n${sections.summary}`); + } + + if (sections.discussionAreas.length) { + const topics = sections.discussionAreas + .map((area) => { + const topic = [`### ${area.title}`]; + if (hasText(area.analysis)) topic.push('', area.analysis); + return topic.join('\n'); + }) + .join('\n\n'); + blocks.push(`## Key Topics\n\n${topics}`); + } + + if (sections.keyPoints.length) { + blocks.push(`## Key Points\n\n${sections.keyPoints.map((point) => `- ${point}`).join('\n')}`); + } + + if (sections.actionItems.length) { + blocks.push(`## Action Items\n\n${sections.actionItems.map((item) => `- ${item}`).join('\n')}`); + } + + if (sections.participants.length) { + blocks.push(`## Participants\n\n${sections.participants.join(', ')}`); + } + + return blocks.join('\n\n'); +} diff --git a/app/renderer/src/routes/MeetingDetail.tsx b/app/renderer/src/routes/MeetingDetail.tsx index b8ff8a1d..26ce4e6c 100644 --- a/app/renderer/src/routes/MeetingDetail.tsx +++ b/app/renderer/src/routes/MeetingDetail.tsx @@ -20,11 +20,12 @@ import { Mic, PencilLine, RefreshCw, + Share, Trash2, Users, } from 'lucide-react'; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import * as SelectPrimitive from '@radix-ui/react-select'; import { MeetingsShell } from '@/components/MeetingsShell'; import { Select, SelectContent, SelectItem, SelectSeparator } from '@/components/ui/select'; @@ -69,6 +70,7 @@ import { useActiveMeeting } from '@/lib/askBarContext'; import { ipc, type Meeting, type Report, type Template } from '@/lib/ipc'; import { buildTranscriptBundle, defaultExportFilename } from '@/lib/transcriptBundle'; import { buildNotesCopyText, type StructuredNoteSections } from '@/lib/notesCopy'; +import { buildNotesMarkdown } from '@/lib/notesMarkdown'; import { buildNotesHtml, hasNotesContent } from '@/lib/notesPdf'; import { unwrap } from '@/lib/result'; import { cn } from '@/lib/utils'; @@ -81,6 +83,28 @@ import { useAutoSummarizeSetting } from '@/hooks/useSettings'; const LAST_OPENED_KEY = 'steno-last-opened-meeting'; +// The `…` popover's entry markup, lifted to a constant so the Share menu is +// visually the same control rather than a near-copy that drifts from it. Both +// popovers are plain `Popover` + buttons (no roving focus); giving them +// keyboard navigation is an improvement to that shared pattern, not part of the +// Share menu. +const MENU_ENTRY_CLASS = + 'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-50'; + +// Group separator inside the Share menu. aria-hidden with role="none": it is +// pure visual grouping, and a `separator` role in a popover that is not a real +// menu would announce structure the keyboard cannot navigate. +function MenuDivider() { + return ( +