From d385f4ef9de2a2ed67583e709331d74ce777e133 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 6 Aug 2026 21:44:11 -0500 Subject: [PATCH 1/3] feat(tools): add Markdown export alongside DOCX Export already assembled the entire report as a Markdown string and only then handed it to the DOCX converter, so exporting .md is that same string written straight to disk rather than a second render path. The Save button now opens a format menu instead of exporting on click. Both formats carry identical content from the same summarize call, so choosing Markdown costs no extra credits. Model output keeps its fenced code blocks and lists verbatim in .md, which the converter flattens. buildExportMarkdown and generateExportFilename move to an electron-free utils module. That is what makes them reachable from test/run.mjs: tools.service.ts imports `dialog`, which the test stub does not provide, so anything left in that module cannot be loaded outside Electron. generateExportFilename branches on `md` and lets everything else fall through to docx. `format` arrives over IPC from the renderer, where the type annotation is erased, so an unexpected value must not reach the file extension. The DOCX conversion now runs after the save dialog rather than before. Cancelling no longer pays for a conversion nobody uses, and a Markdown export never pays for it at all. Output is unchanged. Docs in marketing-website and backend still name DOCX as the only format and need matching updates in their own repos. Closes #79 Co-Authored-By: Claude Opus 5 --- src/main/consts.ts | 4 +- src/main/ipc/tools.ts | 5 +- src/main/preload.cts | 6 +- src/main/services/tools.service.ts | 79 ++++++------------- src/main/types/export.ts | 1 + src/main/utils/export-markdown.ts | 72 +++++++++++++++++ .../custom/control-panel/tools-group.tsx | 70 +++++++++++----- src/renderer/hooks/use-tools.tsx | 5 +- src/renderer/types/electron-api.d.ts | 8 +- src/renderer/types/export.ts | 1 + test/run.mjs | 1 + test/tools-export.test.mjs | 68 ++++++++++++++++ 12 files changed, 234 insertions(+), 86 deletions(-) create mode 100644 src/main/types/export.ts create mode 100644 src/main/utils/export-markdown.ts create mode 100644 src/renderer/types/export.ts create mode 100644 test/tools-export.test.mjs diff --git a/src/main/consts.ts b/src/main/consts.ts index 6bc78929..a33f2750 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -30,8 +30,8 @@ export const SELF_PARTIAL_STALE_MS = 15_000; // cost for no effect - and it grew for the whole interview. // // Deliberately larger than the backend's window, so a change there does not silently starve the -// prompt. This does NOT bound retained history: the end-of-interview summary and .docx export -// read the full transcript from app state. +// prompt. This does NOT bound retained history: the end-of-interview summary and the .docx/.md +// export read the full transcript from app state. export const TRANSCRIPT_UPLOAD_LIMIT = 60; // Suggestion constants diff --git a/src/main/ipc/tools.ts b/src/main/ipc/tools.ts index 5932a1a6..7ef267f7 100644 --- a/src/main/ipc/tools.ts +++ b/src/main/ipc/tools.ts @@ -2,10 +2,11 @@ import { dialog, ipcMain } from 'electron'; import fs from 'fs/promises'; import { toolsService } from '../services/tools.service.js'; +import { ExportFormat } from '../types/export.js'; export function registerToolsHandlers(): void { - ipcMain.handle('tools:export-transcript', async () => { - return toolsService.exportTranscript(); + ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => { + return toolsService.exportTranscript(format); }); ipcMain.handle('tools:clear-all', async () => { await toolsService.clearAll(); diff --git a/src/main/preload.cts b/src/main/preload.cts index e5275d47..f7132605 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -48,7 +48,8 @@ const electronApi = { }, auth: { - sendVerificationCode: (email: string) => ipcRenderer.invoke('auth:send-verification-code', email), + sendVerificationCode: (email: string) => + ipcRenderer.invoke('auth:send-verification-code', email), verifyEmailCode: (email: string, code: string) => ipcRenderer.invoke('auth:verify-email-code', email, code), signup: (username: string, email: string, password: string, verificationCode: string) => @@ -126,7 +127,8 @@ const electronApi = { }, tools: { - exportTranscript: () => ipcRenderer.invoke('tools:export-transcript'), + exportTranscript: (format: 'docx' | 'md') => + ipcRenderer.invoke('tools:export-transcript', format), clearAll: () => ipcRenderer.invoke('tools:clear-all'), setPlaceholderData: () => ipcRenderer.invoke('tools:set-placeholder-data'), saveImage: (opts: { filename: string; data: number[] }) => diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 59f87875..bf37643b 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -4,8 +4,9 @@ import fs from 'fs/promises'; import { LLMApi } from '../api/llm.js'; import { configStore } from '../store/config.store.js'; -import { Speaker } from '../types/app-state.js'; +import { ExportFormat } from '../types/export.js'; import { GenerateSummarizeRequest } from '../types/llm.js'; +import { buildExportMarkdown, generateExportFilename } from '../utils/export-markdown.js'; import { appStateService } from './app-state.service.js'; import { actionSuggestionService } from './suggestion-action.service.js'; import { liveSuggestionService } from './suggestion-live.service.js'; @@ -14,22 +15,7 @@ import { transcriptService } from './transcript.service.js'; class ToolsService { private llmApi: LLMApi = new LLMApi(); - private generateFilename(): string { - const d = new Date(); - - const pad = (n: number) => String(n).padStart(2, '0'); - - const yyyy = d.getFullYear(); - const mm = pad(d.getMonth() + 1); - const dd = pad(d.getDate()); - const hh = pad(d.getHours()); - const min = pad(d.getMinutes()); - const ss = pad(d.getSeconds()); - - return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.docx`; - } - - async exportTranscript(): Promise { + async exportTranscript(format: ExportFormat = 'docx'): Promise { // Prepare request data const username = appStateService.getState().interviewConfig.fullName; const transcripts = appStateService.getState().transcripts; @@ -45,43 +31,30 @@ class ToolsService { throw new Error(response.error.message); } - const summaryPartRaw = response.data ?? ''; + const fullMarkdown = buildExportMarkdown({ + username, + summary: response.data ?? '', + transcripts, + suggestions, + }); - // Add Date/Time to summary (insert after first line) - let summaryPart = summaryPartRaw; - if (summaryPart) { - const lines = summaryPart.split('\n'); - if (lines.length > 0) { - const datetimeNow = new Date().toLocaleString(); - lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`); - summaryPart = lines.join('\n'); - } - } + const isMarkdown = format === 'md'; - // Build Transcripts section - const transcriptLines: string[] = []; - for (const t of transcripts) { - const timeStr = new Date(t.timestamp).toLocaleString(); - const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer'; - transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`); - } - const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`; + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save Transcript', + defaultPath: generateExportFilename(format), + filters: isMarkdown + ? [{ name: 'Markdown', extensions: ['md'] }] + : [{ name: 'Word Document', extensions: ['docx'] }], + }); - // Build Suggestions section - const suggestionLines: string[] = []; - for (const s of suggestions) { - const timeStr = new Date(s.timestamp).toLocaleString(); - suggestionLines.push( - `#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n` - ); - } - const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`; + if (canceled || !filePath) return null; - // Combine all parts into final Markdown content - const fullMarkdown = - `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); + if (isMarkdown) { + await fs.writeFile(filePath, fullMarkdown, 'utf8'); + return filePath; + } - // Convert Markdown to DOCX const docxBlob = await convertMarkdownToDocx(fullMarkdown, { documentType: 'document', style: { @@ -90,14 +63,6 @@ class ToolsService { }, }); - const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save Transcript', - defaultPath: this.generateFilename(), - filters: [{ name: 'Word Document', extensions: ['docx'] }], - }); - - if (canceled || !filePath) return null; - await fs.writeFile(filePath, Buffer.from(await docxBlob.arrayBuffer())); return filePath; } diff --git a/src/main/types/export.ts b/src/main/types/export.ts new file mode 100644 index 00000000..8f86f74b --- /dev/null +++ b/src/main/types/export.ts @@ -0,0 +1 @@ +export type ExportFormat = 'docx' | 'md'; diff --git a/src/main/utils/export-markdown.ts b/src/main/utils/export-markdown.ts new file mode 100644 index 00000000..8bbc8a79 --- /dev/null +++ b/src/main/utils/export-markdown.ts @@ -0,0 +1,72 @@ +import { LiveSuggestion, Speaker, Transcript } from '../types/app-state.js'; +import { ExportFormat } from '../types/export.js'; + +interface ExportMarkdownInput { + username: string; + summary: string; + transcripts: Transcript[]; + suggestions: LiveSuggestion[]; +} + +/** + * Builds the report every export format is rendered from. Kept free of any `electron` import so + * it stays loadable outside an Electron process - see test/tools-export.test.mjs. + */ +export function buildExportMarkdown({ + username, + summary, + transcripts, + suggestions, +}: ExportMarkdownInput): string { + // Add Date/Time to summary (insert after first line) + let summaryPart = summary; + if (summaryPart) { + const lines = summaryPart.split('\n'); + if (lines.length > 0) { + const datetimeNow = new Date().toLocaleString(); + lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`); + summaryPart = lines.join('\n'); + } + } + + // Build Transcripts section + const transcriptLines: string[] = []; + for (const t of transcripts) { + const timeStr = new Date(t.timestamp).toLocaleString(); + const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer'; + transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`); + } + const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`; + + // Build Suggestions section + const suggestionLines: string[] = []; + for (const s of suggestions) { + const timeStr = new Date(s.timestamp).toLocaleString(); + suggestionLines.push( + `#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n` + ); + } + const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`; + + return `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); +} + +export function generateExportFilename(format: ExportFormat): string { + const d = new Date(); + + const pad = (n: number) => String(n).padStart(2, '0'); + + const yyyy = d.getFullYear(); + const mm = pad(d.getMonth() + 1); + const dd = pad(d.getDate()); + const hh = pad(d.getHours()); + const min = pad(d.getMinutes()); + const ss = pad(d.getSeconds()); + + // Anything that is not an explicit `md` falls back to docx: `format` arrives over IPC from the + // renderer, where the type annotation is erased, so an unexpected value must not reach the + // extension. + const ext = format === 'md' ? 'md' : 'docx'; + + return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`; +} diff --git a/src/renderer/components/custom/control-panel/tools-group.tsx b/src/renderer/components/custom/control-panel/tools-group.tsx index 2a25d0c5..6583cb65 100644 --- a/src/renderer/components/custom/control-panel/tools-group.tsx +++ b/src/renderer/components/custom/control-panel/tools-group.tsx @@ -3,7 +3,9 @@ import { CaptionsOff, CircleCheck, FileIcon, + FileText, FolderOpenIcon, + Hash, Loader, Save, Trash2, @@ -13,6 +15,12 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; import useTools from '@/hooks/use-tools'; @@ -20,6 +28,7 @@ import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; import { getElectron } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; +import type { ExportFormat } from '@/types/export'; interface ToolsGroupProps { getDisabled: (state: RunningState, disableOnRunning?: boolean) => boolean; @@ -46,9 +55,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { } }; - const onExportTranscript = async () => { + const onExportTranscript = async (format: ExportFormat) => { try { - const filePath = await exportTranscript(); + const filePath = await exportTranscript(format); if (!filePath) return; const electron = getElectron(); const toastId = `export-${Date.now()}`; @@ -63,7 +72,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { }} > - Interview exported + + Interview exported as {format === 'md' ? 'Markdown' : 'Word'} +
@@ -161,22 +172,43 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {

Clear

- - - - - -

Export Interview

-
-
+ {/* Non-modal for the same reason as the titlebar menu: a modal menu locks body pointer + events, and picking a format unmounts the menu before it releases the lock. */} + + + + + + + + +

Export Interview

+
+
+ {/* Opens upward: the control panel is the bottom-most element, so a downward menu would + land past the window edge. */} + + void onExportTranscript('docx')}> + + Word Document (.docx) + + void onExportTranscript('md')}> + + Markdown (.md) + + +
); } diff --git a/src/renderer/hooks/use-tools.tsx b/src/renderer/hooks/use-tools.tsx index 92cfd3ad..1d921fe3 100644 --- a/src/renderer/hooks/use-tools.tsx +++ b/src/renderer/hooks/use-tools.tsx @@ -1,18 +1,19 @@ import { useState } from 'react'; import { getElectron } from '@/lib/utils'; +import type { ExportFormat } from '@/types/export'; export default function useTools() { const [exporting, setExporting] = useState(false); - const exportTranscript = async (): Promise => { + const exportTranscript = async (format: ExportFormat): Promise => { setExporting(true); try { const electron = getElectron(); if (!electron) { throw new Error('Electron API not available'); } - return await electron.tools.exportTranscript(); + return await electron.tools.exportTranscript(format); } catch (error) { console.error('Failed to export transcript:', error); throw error; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 12792009..49247cbf 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -1,5 +1,6 @@ import type { AppState } from './app-state'; import type { Config } from './config'; +import type { ExportFormat } from './export'; import type { LLMConfig, LLMConfigValidationResult, LLMModelInfo } from './llm'; import type { AvailableCurrency, @@ -33,7 +34,10 @@ declare global { // Authentication management auth: { sendVerificationCode: (email: string) => Promise<{ success: boolean; error?: string }>; - verifyEmailCode: (email: string, code: string) => Promise<{ success: boolean; error?: string }>; + verifyEmailCode: ( + email: string, + code: string + ) => Promise<{ success: boolean; error?: string }>; signup: ( username: string, email: string, @@ -131,7 +135,7 @@ declare global { // Tools management tools: { - exportTranscript: () => Promise; + exportTranscript: (format: ExportFormat) => Promise; clearAll: () => Promise; setPlaceholderData: () => Promise; saveImage: (opts: { diff --git a/src/renderer/types/export.ts b/src/renderer/types/export.ts new file mode 100644 index 00000000..8f86f74b --- /dev/null +++ b/src/renderer/types/export.ts @@ -0,0 +1 @@ +export type ExportFormat = 'docx' | 'md'; diff --git a/test/run.mjs b/test/run.mjs index a511784b..f97a56c9 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -19,6 +19,7 @@ for (const module of [ './account.test.mjs', './stealth-surface.test.mjs', './stealth-toggle.test.mjs', + './tools-export.test.mjs', ]) { const { run } = await import(module); failures.push(...(await run(userDataDir))); diff --git a/test/tools-export.test.mjs b/test/tools-export.test.mjs new file mode 100644 index 00000000..06d79b09 --- /dev/null +++ b/test/tools-export.test.mjs @@ -0,0 +1,68 @@ +/** + * Export is assembled as Markdown and only then converted, so the same string is what a .md + * export writes verbatim and what the .docx converter is fed. These pin that assembly and the + * filename extension, which is what decides the format the save dialog offers. + * + * Only the pure helpers are covered - exportTranscript() itself needs `dialog` and the LLM API, + * neither of which the electron stub provides. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('tools-export'); + + const { buildExportMarkdown, generateExportFilename } = await loadMain( + 'utils/export-markdown.js' + ); + + const transcripts = [ + { timestamp: Date.now(), text: 'I led the migration.', speaker: 'self' }, + { timestamp: Date.now(), text: 'Tell me about a hard bug.', speaker: 'other' }, + ]; + const suggestions = [ + { timestamp: Date.now(), last_question: 'Why did you leave?', answer: 'Growth.' }, + ]; + + const md = buildExportMarkdown({ + username: 'Ada Lovelace', + summary: '# **Report**\nBody text.', + transcripts, + suggestions, + }); + + check('keeps the summary', md.includes('# **Report**')); + check('stamps the export time under the title', md.includes('##### Date/Time:')); + check('emits the transcripts section', md.includes('# **Transcripts**')); + check('emits the suggestions section', md.includes('# **Suggestions**')); + check('labels the candidate by name', md.includes('| Ada Lovelace***')); + check('labels everyone else as the interviewer', md.includes('| Interviewer***')); + check( + 'carries the question and the suggested answer', + md.includes('Why did you leave?') && md.includes('Growth.') + ); + + const empty = buildExportMarkdown({ + username: 'Ada Lovelace', + summary: '# **Report**', + transcripts: [], + suggestions: [], + }); + check('omits the transcripts heading when there are none', !empty.includes('# **Transcripts**')); + check('omits the suggestions heading when there are none', !empty.includes('# **Suggestions**')); + + const docxName = generateExportFilename('docx'); + const mdName = generateExportFilename('md'); + check('names the Word export .docx', docxName.endsWith('.docx')); + check('names the Markdown export .md', mdName.endsWith('.md')); + check( + 'keeps the report- stem', + /^report-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.(docx|md)$/.test(mdName) + ); + // `format` crosses IPC from the renderer, where the type annotation is erased. + check( + 'falls back to docx for an unknown format', + generateExportFilename('exe').endsWith('.docx') + ); + + return failures; +} From 7d8d72ed154d46f01cd713c2f6bd556132b02e36 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 6 Aug 2026 21:49:49 -0500 Subject: [PATCH 2/3] style: drop unrelated prettier churn from the export change Formatting the touched files also rewrapped two auth signatures that have nothing to do with export. Prettier is not enforced here and a number of files do not satisfy it, so reformatting pre-existing violations just to sit next to a change puts unrelated hunks in front of a reviewer. Co-Authored-By: Claude Opus 5 --- src/main/preload.cts | 3 +-- src/renderer/types/electron-api.d.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/preload.cts b/src/main/preload.cts index f7132605..0d6e58b3 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -48,8 +48,7 @@ const electronApi = { }, auth: { - sendVerificationCode: (email: string) => - ipcRenderer.invoke('auth:send-verification-code', email), + sendVerificationCode: (email: string) => ipcRenderer.invoke('auth:send-verification-code', email), verifyEmailCode: (email: string, code: string) => ipcRenderer.invoke('auth:verify-email-code', email, code), signup: (username: string, email: string, password: string, verificationCode: string) => diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 49247cbf..9bb25035 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -34,10 +34,7 @@ declare global { // Authentication management auth: { sendVerificationCode: (email: string) => Promise<{ success: boolean; error?: string }>; - verifyEmailCode: ( - email: string, - code: string - ) => Promise<{ success: boolean; error?: string }>; + verifyEmailCode: (email: string, code: string) => Promise<{ success: boolean; error?: string }>; signup: ( username: string, email: string, From 1e547a6848db58e54db36abd69569abf88ab1f4d Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 7 Aug 2026 08:01:44 -0500 Subject: [PATCH 3/3] docs(tools): record why the export menu opens upward DropdownMenuContent portals into the overflow-hidden
from main-frame, not document.body. The control panel is the bottom-most thing in it, so a downward menu opens past that edge and is clipped - not merely flipped by collision detection, which is what the previous wording implied and what would invite someone to drop the prop. Co-Authored-By: Claude Opus 5 --- src/renderer/components/custom/control-panel/tools-group.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/custom/control-panel/tools-group.tsx b/src/renderer/components/custom/control-panel/tools-group.tsx index 6583cb65..45cf21c8 100644 --- a/src/renderer/components/custom/control-panel/tools-group.tsx +++ b/src/renderer/components/custom/control-panel/tools-group.tsx @@ -196,8 +196,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {

Export Interview

- {/* Opens upward: the control panel is the bottom-most element, so a downward menu would - land past the window edge. */} + {/* Opens upward, and not just for looks: the menu is portalled into the overflow-hidden +
from main-frame, and the control panel is the bottom-most thing in it, so a + downward menu would open past that edge and get clipped rather than merely flipped. */} void onExportTranscript('docx')}>