From d5f3874503bd7a42981c3fc5404b6db5e02cd28c Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 14:48:07 -0400 Subject: [PATCH 1/8] feat(suggestions): optional professional mode for at-a-glance hints Live suggestions arrived as full sentences and rendered as one whitespace-pre-wrap block. During a real interview the candidate has a couple of seconds to glance at the panel while the interviewer is watching, and a paragraph does not fit in that window. Professional mode asks the backend for hints instead: a bold one-line core answer plus 3-5 keyword bullets. Off by default, so nothing changes for anyone who does not turn it on - the migration IIFE backfills it as false, and test/config-store.test.mjs pins that an upgrading install reads back off. Toggle lives in the control panel next to the LLM button and is deliberately not disabled while running: it is a mid-interview control and only affects the next suggestion. Ctrl+Shift+F7 does the same, which keeps it reachable in stealth mode where the panel is hidden. F7 rather than P because globalShortcut claims accelerators system-wide, and Ctrl+Shift+P would take the command palette away from every editor on the machine for as long as the app runs. Each LiveSuggestion carries the mode it was generated under, and the panel picks its renderer from that rather than from the live setting. Otherwise toggling mid-interview would reformat cards already on screen, parsing prose as Markdown. Professional answers render through SafeMarkdown, the component the action panel already uses. Also adds a rejection handler to the fire-and-forget generateSuggestion call, matching the action service: the config read now sits above the try that guards it, so a throw there would otherwise leak an abort-map entry. Backend support: PowerInterviewAI/backend#48. The mode field defaults to normal server-side, so this is safe against an older deployment. Closes #96 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 ++ SPEC.md | 4 ++ src/main/hotkeys.ts | 10 ++++ src/main/preload.cts | 6 +++ src/main/services/app-state.service.ts | 2 + .../services/suggestion-action.service.ts | 3 +- src/main/services/suggestion-live.service.ts | 20 ++++++-- src/main/store/config.store.ts | 9 ++++ src/main/types/app-state.ts | 9 ++++ src/main/types/llm.ts | 12 +++++ .../components/custom/control-panel/index.tsx | 2 + .../control-panel/professional-mode-group.tsx | 46 +++++++++++++++++++ .../custom/panels/live-suggestions-panel.tsx | 42 +++++++++++++---- src/renderer/hooks/use-professional-mode.ts | 28 +++++++++++ src/renderer/lib/hotkeys.ts | 15 +++++- src/renderer/pages/main/index.tsx | 10 ++++ src/renderer/types/config.ts | 3 ++ src/renderer/types/electron-api.d.ts | 1 + src/renderer/types/llm.ts | 8 ++++ src/renderer/types/suggestion.ts | 4 ++ test/config-store.test.mjs | 28 +++++++++-- 21 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 src/renderer/components/custom/control-panel/professional-mode-group.tsx create mode 100644 src/renderer/hooks/use-professional-mode.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf70ed..8436dba9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ Handler registration lives in [src/main/ipc/](src/main/ipc/) - one file per doma Action suggestions are independent of transcripts - triggered by screenshot captures (up to `ACTION_SUGGESTION_MAX_CAPTURES` = 4 images per request). +**Professional mode** (`professionalMode` in ConfigStore, off by default) asks the backend for hints - a headline plus keyword bullets - instead of full sentences. Both suggestion services read the flag once at the top of `generateSuggestion` and send it as `mode` on the request; the backend defaults it to `normal`, so the field is safe to omit against an older deployment. + +Each `LiveSuggestion` carries the `mode` it was *generated* under, and the panel picks its renderer from that, not from the current setting. Reading the live setting instead would reformat every card on screen the moment the user toggles mid-interview, parsing prose answers as Markdown. Professional answers render through `SafeMarkdown`, the same component the action panel already uses; normal answers keep the plain 🪄-prefixed text. + ### Routing Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login` or `/auth/signup` -> `/main` (interview UI) -> `/payment`. diff --git a/SPEC.md b/SPEC.md index ff51cd44..530454cf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -55,6 +55,10 @@ Streaming AI responses generated from the user's CV and job description, trigger Screenshot-based problem solving. Accepts up to 4 images, sends them to the LLM backend, returns syntax-highlighted code output. Service: [src/main/services/suggestion-action.service.ts](src/main/services/suggestion-action.service.ts). +### Professional Mode + +Optional, off by default. Switches both live and triggered suggestions from full sentences to hints - a bold one-line core answer plus 3-5 keyword bullets - so the panel can be read at a glance mid-interview. Toggled from the control panel or with `Ctrl+Shift+F7`, which keeps it reachable in stealth mode. Persisted locally as `professionalMode`; sent to the backend as `mode` on the suggestion request. + ### Interview Config Sync Full name, profile/CV, and context are stored on the user's backend account and pulled on login or a remembered session, so the setup follows the user across devices. Service: [src/main/services/account.service.ts](src/main/services/account.service.ts). The full values are kept in the main process and fetched on demand over `account:get`; the app-state broadcast carries only a `{ fullName, hasProfileData }` summary, since the profile and context can each run to 128,000 characters. diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index 08bd14f2..58d607d7 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -60,6 +60,15 @@ export function registerGlobalHotkeys(): void { if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-transcript'); }); + // Toggle professional mode. A function key for the same reason as F8: it stays reachable in + // stealth mode, where the control panel carrying the button is hidden. Deliberately not P - + // globalShortcut claims accelerators system-wide, and Ctrl+Shift+P would take the command + // palette away from every editor on the machine for as long as this app runs. + registerShortcut(`${BASE}+F7`, () => { + const w = BrowserWindow.getAllWindows()[0]; + if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-professional-mode'); + }); + // Zoom hotkeys registerShortcut(`${BASE}+=`, () => { try { @@ -186,6 +195,7 @@ export function registerGlobalHotkeys(): void { console.log(` ${mod}+Q : Stop assistant`); console.log(` ${mod}+M : Toggle stealth mode`); console.log(` ${mod}+N : Toggle opacity (stealth only)`); + console.log(` ${mod}+F7 : Toggle professional mode`); console.log(` ${mod}+F8 : Toggle transcription dock`); console.log(` ${mod}+1-9 : Place window (numpad layout)`); console.log(' Ctrl+Alt+Shift+Arrow : Move window'); diff --git a/src/main/preload.cts b/src/main/preload.cts index 7859e244..1988e423 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -44,6 +44,12 @@ const electronApi = { return () => ipcRenderer.removeListener('hotkey:toggle-transcript', handler); }, + onHotkeyToggleProfessionalMode: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on('hotkey:toggle-professional-mode', handler); + return () => ipcRenderer.removeListener('hotkey:toggle-professional-mode', handler); + }, + config: { get: () => ipcRenderer.invoke('config:get'), update: (updates: Record) => ipcRenderer.invoke('config:update', updates), diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index ca1af6ec..fc908f02 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -12,6 +12,7 @@ import { Speaker, SuggestionState, } from '../types/app-state.js'; +import { SuggestionMode } from '../types/llm.js'; import { getWindowReference } from './window-control.service.js'; const DEFAULT_STATE: AppState = { @@ -65,6 +66,7 @@ export class AppStateService { answer: 'Suggested answers will be here in real-time', state: SuggestionState.Success, error: '', + mode: SuggestionMode.Normal, }, ], actionSuggestions: [ diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 69f56e59..a6ef0977 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -19,7 +19,7 @@ import { SuggestionState, Transcript, } from '../types/app-state.js'; -import { GenerateActionSuggestionRequest } from '../types/llm.js'; +import { GenerateActionSuggestionRequest, SuggestionMode } from '../types/llm.js'; import { DateTimeUtil } from '../utils/datetime.js'; import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; import { UuidUtil } from '../utils/uuid.js'; @@ -217,6 +217,7 @@ export class ActionSuggestionService { context: interviewConfig.context, transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), image_names: [...this.uploadedImageNames], + mode: conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal, }; const lastQuestion = this.getLastInterviewerQuestion(transcripts); diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index 9b873ee1..c229a269 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -7,7 +7,7 @@ import { } from '../consts.js'; import { configStore } from '../store/config.store.js'; import { LiveSuggestion, Speaker, SuggestionState, Transcript } from '../types/app-state.js'; -import { GenerateLiveSuggestionRequest } from '../types/llm.js'; +import { GenerateLiveSuggestionRequest, SuggestionMode } from '../types/llm.js'; import { DateTimeUtil } from '../utils/datetime.js'; import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; import { UuidUtil } from '../utils/uuid.js'; @@ -59,12 +59,19 @@ class LiveSuggestionService { // clears the abort map entry, leaking it. const epoch = this.epoch; const timestamp = DateTimeUtil.now(); + + // Read once, up front. The card and the request must agree on the mode even if the user + // toggles while this stream is in flight, or the panel would render prose as Markdown. + const conf = configStore.getConfig(); + const mode = conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal; + const suggestion: LiveSuggestion = { timestamp, last_question: transcripts[transcripts.length - 1].text, answer: '', state: SuggestionState.Pending, error: '', + mode, }; // Append initial suggestion @@ -82,13 +89,13 @@ class LiveSuggestionService { }; try { - const conf = configStore.getConfig(); const interviewConfig = appStateService.getState().interviewConfig; const requestBody: GenerateLiveSuggestionRequest = { config: conf.llmConf, profile_data: interviewConfig.profileData, context: interviewConfig.context, transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), + mode, }; armStallTimer(LIVE_SUGGESTION_TTFB_MS); @@ -190,7 +197,14 @@ class LiveSuggestionService { const taskId = UuidUtil.generate(); const controller = new AbortController(); this.abortMap.set(taskId, controller); - void this.generateSuggestion(taskId, controller, filteredTranscripts); + + // generateSuggestion owns the abort-map cleanup in its own finally, but it can throw before + // reaching the try that guards it - the config and state reads sit above it. Deleting the + // entry here on a synchronous rejection keeps a dead controller from being aborted forever. + this.generateSuggestion(taskId, controller, filteredTranscripts).catch((error) => { + console.error('[LiveSuggestionService] generateSuggestion rejected:', error); + this.abortMap.delete(taskId); + }); } stopRunningTasks(): void { diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 45bacde7..9b9a27a1 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -26,6 +26,9 @@ export interface RuntimeConfig { // transcription bottom dock visibility showTranscriptPanel: boolean; + + // suggestions come back as headline + keyword bullets instead of full sentences + professionalMode: boolean; } // Default runtime configuration @@ -45,6 +48,9 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { autoScrollTranscript: true, showTranscriptPanel: true, + + // opt-in: prose is what every existing user already expects from the panel + professionalMode: false, }; // interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now @@ -229,6 +235,9 @@ export const configStore = new ConfigStore(); if (raw?.showTranscriptPanel === undefined) { migration.showTranscriptPanel = true; } + if (raw?.professionalMode === undefined) { + migration.professionalMode = false; + } // perform migration only if there are values to set if (Object.keys(migration).length > 0) { configStore.updateConfig(migration); diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 27ca1737..7e17ecd2 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -3,6 +3,7 @@ */ import { UserRole } from './health-check.js'; +import { SuggestionMode } from './llm.js'; export enum Speaker { Self = 'self', @@ -40,6 +41,14 @@ export interface LiveSuggestion { answer: string; state: SuggestionState; error: string; + /** + * The mode this answer was generated under, not the mode currently configured. + * + * The panel picks its renderer from this. Reading the live setting instead would re-render + * every card on screen the moment the user toggles mid-interview, so a prose answer would + * suddenly be parsed as Markdown. + */ + mode: SuggestionMode; } export interface ActionSuggestion { diff --git a/src/main/types/llm.ts b/src/main/types/llm.ts index ba6a16c2..baa741b5 100644 --- a/src/main/types/llm.ts +++ b/src/main/types/llm.ts @@ -50,10 +50,22 @@ export interface LLMRequest { config: LLMConfig | null; } +/** + * How much prose a suggestion should carry. + * + * Normal is full spoken sentences. Professional is a headline plus keyword bullets, for reading + * at a glance mid-interview. Mirrors `SuggestionMode` in the backend's `app/schemas/suggestion.py`. + */ +export enum SuggestionMode { + Normal = 'normal', + Professional = 'professional', +} + export interface GenerateLiveSuggestionRequest extends LLMRequest { profile_data: string; context: string; transcripts: Transcript[]; + mode: SuggestionMode; } // action request reuses live fields but adds image names diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 929bf108..9f3efc95 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -17,6 +17,7 @@ import ZoomControl from '../zoom-control'; import { AudioGroup } from './audio-group'; import { LLMGroup } from './llm-group'; import { MainGroup } from './main-group'; +import { ProfessionalModeGroup } from './professional-mode-group'; import { ToolsGroup } from './tools-group'; type StateConfig = { @@ -155,6 +156,7 @@ export default function ControlPanel() { getDisabled={getDisabled} /> + diff --git a/src/renderer/components/custom/control-panel/professional-mode-group.tsx b/src/renderer/components/custom/control-panel/professional-mode-group.tsx new file mode 100644 index 00000000..a05a5829 --- /dev/null +++ b/src/renderer/components/custom/control-panel/professional-mode-group.tsx @@ -0,0 +1,46 @@ +import { Sparkles } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useProfessionalMode } from '@/hooks/use-professional-mode'; +import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; +import { cn } from '@/lib/utils'; + +/** + * Professional mode toggle. + * + * Deliberately takes no `getDisabled`: this is a mid-interview control, like the transcript + * toggle. It only affects the next suggestion, so leaving it live while the assistant runs + * cannot corrupt an in-flight stream. + */ +export function ProfessionalModeGroup() { + const { enabled, toggle } = useProfessionalMode(); + + return ( +
+ + + + + +

+ Professional Mode: {enabled ? 'On' : 'Off'} ( + {HOTKEYS[Hotkey.ToggleProfessionalMode].combo}) +

+

+ {enabled ? 'Short hints: headline + keyword bullets' : 'Full sentences'} +

+
+
+
+ ); +} diff --git a/src/renderer/components/custom/panels/live-suggestions-panel.tsx b/src/renderer/components/custom/panels/live-suggestions-panel.tsx index 459f0559..6f565a4e 100644 --- a/src/renderer/components/custom/panels/live-suggestions-panel.tsx +++ b/src/renderer/components/custom/panels/live-suggestions-panel.tsx @@ -14,12 +14,44 @@ function truncateMiddle(text: string, maxLen: number): string { import { Card } from '@/components/ui/card'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; import { newestTimestamp } from '@/lib/suggestions'; +import { SuggestionMode } from '@/types/llm'; import { type LiveSuggestion, SuggestionState } from '@/types/suggestion'; import { Button } from '../../ui/button'; import { Checkbox } from '../../ui/checkbox'; +import { SafeMarkdown } from '../safe-markdown'; import SuggestionReveal from './suggestion-reveal'; +/** + * Render one answer in the format it was generated in. + * + * Keyed off the suggestion's own mode rather than the current setting, so toggling mid-interview + * leaves cards already on screen alone. `trailing` marks a stopped stream as unfinished. + */ +function SuggestionAnswer({ + suggestion, + trailing, +}: { + suggestion: LiveSuggestion; + trailing?: boolean; +}) { + if (suggestion.mode === SuggestionMode.Professional) { + return ( +
+ + {trailing && ...} +
+ ); + } + + return ( +
+ 🪄 {suggestion.answer} + {trailing && ' ...'} +
+ ); +} + interface LiveSuggestionsPanelProps { suggestions?: LiveSuggestion[]; style?: React.CSSProperties; @@ -223,16 +255,10 @@ function LiveSuggestionsPanel({ {(s.state === SuggestionState.Loading || - s.state === SuggestionState.Success) && ( -
- 🪄 {s.answer} -
- )} + s.state === SuggestionState.Success) && } {s.state === SuggestionState.Stopped && ( -
- 🪄 {s.answer} ... -
+ )} {s.state === SuggestionState.Error && ( diff --git a/src/renderer/hooks/use-professional-mode.ts b/src/renderer/hooks/use-professional-mode.ts new file mode 100644 index 00000000..03260170 --- /dev/null +++ b/src/renderer/hooks/use-professional-mode.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner'; + +import { useConfigStore } from './use-config-store'; + +/** + * Whether suggestions are generated as hints (headline + keyword bullets) instead of prose, + * plus a toggle that persists the change. + * + * Shared by the control panel button and the global hotkey. The toggle reads the store + * imperatively so it stays referentially stable, which lets the hotkey listener subscribe once + * instead of resubscribing on every config change. + * + * Absent means off, unlike the transcript dock: prose is what every existing user already has. + */ +export function useProfessionalMode() { + const { config } = useConfigStore(); + + const toggle = useCallback(() => { + const { config: current, updateConfig } = useConfigStore.getState(); + updateConfig({ professionalMode: current?.professionalMode !== true }).catch((e) => { + console.error('Failed to save professional mode setting', e); + toast.error('Failed to save professional mode setting'); + }); + }, []); + + return { enabled: config?.professionalMode === true, toggle }; +} diff --git a/src/renderer/lib/hotkeys.ts b/src/renderer/lib/hotkeys.ts index 77da7816..6a285d94 100644 --- a/src/renderer/lib/hotkeys.ts +++ b/src/renderer/lib/hotkeys.ts @@ -5,6 +5,7 @@ export enum Hotkey { ToggleStealth = 'ToggleStealth', Opacity = 'Opacity', ToggleTranscript = 'ToggleTranscript', + ToggleProfessionalMode = 'ToggleProfessionalMode', PlaceWin = 'PlaceWin', MoveWin = 'MoveWin', ResizeWin = 'ResizeWin', @@ -30,7 +31,13 @@ export type HotkeyGroup = { export const HOTKEY_GROUPS: HotkeyGroup[] = [ { label: 'General', - keys: [Hotkey.StopAll, Hotkey.ToggleStealth, Hotkey.Opacity, Hotkey.ToggleTranscript], + keys: [ + Hotkey.StopAll, + Hotkey.ToggleStealth, + Hotkey.Opacity, + Hotkey.ToggleTranscript, + Hotkey.ToggleProfessionalMode, + ], }, { label: 'Window Management', @@ -88,6 +95,12 @@ export const HOTKEYS: Record = { title: 'Toggle Transcription', description: 'Show or hide the transcription dock - works in stealth mode too', }, + [Hotkey.ToggleProfessionalMode]: { + combo: `${BASE}F7`, + title: 'Toggle Professional Mode', + description: + 'Switch suggestions between full sentences and short hints - a headline plus keyword bullets you can read at a glance. Works in stealth mode too.', + }, [Hotkey.PlaceWin]: { combo: `${BASE}1-9`, title: 'Place Window', diff --git a/src/renderer/pages/main/index.tsx b/src/renderer/pages/main/index.tsx index a926abd6..a54960b3 100644 --- a/src/renderer/pages/main/index.tsx +++ b/src/renderer/pages/main/index.tsx @@ -15,6 +15,7 @@ import { useAppState } from '@/hooks/use-app-state'; import { useAssistantService } from '@/hooks/use-assistant-service'; import { useConfigStore } from '@/hooks/use-config-store'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; +import { useProfessionalMode } from '@/hooks/use-professional-mode'; import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; import { isMac, @@ -44,6 +45,7 @@ export default function MainPage() { const { appState } = useAppState(); const { visible: transcriptDockEnabled, toggle: toggleTranscriptDock } = useTranscriptPanel(); + const { toggle: toggleProfessionalMode } = useProfessionalMode(); // Listen for hotkey to stop assistant useEffect(() => { @@ -66,6 +68,14 @@ export default function MainPage() { return window.electronAPI.onHotkeyToggleTranscript(toggleTranscriptDock); }, [toggleTranscriptDock]); + // Same reasoning for professional mode: the control panel button is gone in stealth mode, and + // this is a control the candidate may want to flip mid-question. + useEffect(() => { + if (!window?.electronAPI?.onHotkeyToggleProfessionalMode) return; + + return window.electronAPI.onHotkeyToggleProfessionalMode(toggleProfessionalMode); + }, [toggleProfessionalMode]); + // Load config on mount useEffect(() => { loadConfig(); diff --git a/src/renderer/types/config.ts b/src/renderer/types/config.ts index 8d8e0060..3f70f79e 100644 --- a/src/renderer/types/config.ts +++ b/src/renderer/types/config.ts @@ -25,4 +25,7 @@ export interface Config { // Transcription bottom dock visibility (persisted between sessions) showTranscriptPanel: boolean; + + // Suggestions come back as headline + keyword bullets instead of full sentences + professionalMode: boolean; } diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 41bf5d78..15c28277 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -27,6 +27,7 @@ declare global { // Hotkey stop assistant event onHotkeyStopAssistant: (callback: () => void) => () => void; onHotkeyToggleTranscript: (callback: () => void) => () => void; + onHotkeyToggleProfessionalMode: (callback: () => void) => () => void; // Configuration management config: { diff --git a/src/renderer/types/llm.ts b/src/renderer/types/llm.ts index 76121208..4caf8af8 100644 --- a/src/renderer/types/llm.ts +++ b/src/renderer/types/llm.ts @@ -11,6 +11,14 @@ export interface LLMConfig { model: string; } +/** + * How much prose a suggestion carries. Mirrors `SuggestionMode` in src/main/types/llm.ts. + */ +export enum SuggestionMode { + Normal = 'normal', + Professional = 'professional', +} + export interface LLMModelInfo { id: string; provider: LLMProvider; diff --git a/src/renderer/types/suggestion.ts b/src/renderer/types/suggestion.ts index ad653053..114de8da 100644 --- a/src/renderer/types/suggestion.ts +++ b/src/renderer/types/suggestion.ts @@ -1,3 +1,5 @@ +import type { SuggestionMode } from './llm'; + export enum SuggestionState { Idle = 'idle', Uploading = 'uploading', @@ -14,6 +16,8 @@ export interface LiveSuggestion { answer: string; state: SuggestionState; error: string; + /** The mode this answer was generated under, which is what selects the renderer. */ + mode: SuggestionMode; } export interface ActionSuggestion { diff --git a/test/config-store.test.mjs b/test/config-store.test.mjs index 976e0af7..2ed48c53 100644 --- a/test/config-store.test.mjs +++ b/test/config-store.test.mjs @@ -28,11 +28,17 @@ export async function run(userDataDir) { const store = await loadMain('store/config.store.js'); - check('legacy conf is readable for migration', store.getLegacyInterviewConf()?.profileData === 'MY CV'); + check( + 'legacy conf is readable for migration', + store.getLegacyInterviewConf()?.profileData === 'MY CV' + ); const cfg = store.configStore.getConfig(); check('getConfig omits interviewConf', !('interviewConf' in cfg)); - check('getConfig keeps real settings', cfg.email === 'a@b.c' && cfg.autoScrollTranscript === false); + check( + 'getConfig keeps real settings', + cfg.email === 'a@b.c' && cfg.autoScrollTranscript === false + ); // The data-loss trap: an unrelated write must not drop the not-yet-migrated copy. store.configStore.updateConfig({ sessionToken: 'tok' }); @@ -52,9 +58,25 @@ export async function run(userDataDir) { store.clearLegacyInterviewConf(); check('clear drops the in-memory copy', store.getLegacyInterviewConf() === null); - check('clear drops the disk copy', !('interviewConf' in (store.configStore.getStoredRuntime() ?? {}))); + check( + 'clear drops the disk copy', + !('interviewConf' in (store.configStore.getStoredRuntime() ?? {})) + ); check('clear drops the owner claim', store.getLegacyInterviewConfOwner() === null); check('clear leaves other settings intact', store.configStore.getConfig().email === 'a@b.c'); + // Professional mode is opt-in. The seeded runtime above predates the key, so an upgrading + // install must read back as off rather than silently switching every suggestion to hints. + check('professionalMode defaults off on upgrade', cfg.professionalMode === false); + + store.configStore.updateConfig({ professionalMode: true }); + check('professionalMode is persisted', store.configStore.getConfig().professionalMode === true); + + store.configStore.updateConfig({ sessionToken: 'tok2' }); + check( + 'professionalMode survives an unrelated write', + store.configStore.getConfig().professionalMode === true + ); + return failures; } From 99ebab9b89af21b784d179e3e97b7a763a41d572 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 15:00:04 -0400 Subject: [PATCH 2/8] test(config-store): state what the professional mode assertion guards Gitar flagged the assertion as tautological, which was right about the symptom. Its suggested fix - asserting on getStoredRuntime instead - turns out to pass just as unconditionally, because updateConfig re-spreads DEFAULT_RUNTIME_CONFIG on every write, so the key reaches disk whether or not the migration branch runs. Verified both directions by breaking each mechanism in turn: with the migration branch deleted the assertion still passes on the default spread, and with the default flipped to true it still passes because the migration pins false independently. So the two mechanisms are genuinely redundant, and no single assertion can isolate one while both hold. That redundancy is worth keeping - if professional mode ever becomes the default for new installs, the migration is what keeps existing users on prose. Left the code alone and rewrote the comment to say what the assertion actually guards rather than claiming to pin the backfill. Co-Authored-By: Claude Opus 5 --- test/config-store.test.mjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/config-store.test.mjs b/test/config-store.test.mjs index 2ed48c53..61d1e9e4 100644 --- a/test/config-store.test.mjs +++ b/test/config-store.test.mjs @@ -65,12 +65,21 @@ export async function run(userDataDir) { check('clear drops the owner claim', store.getLegacyInterviewConfOwner() === null); check('clear leaves other settings intact', store.configStore.getConfig().email === 'a@b.c'); - // Professional mode is opt-in. The seeded runtime above predates the key, so an upgrading - // install must read back as off rather than silently switching every suggestion to hints. - check('professionalMode defaults off on upgrade', cfg.professionalMode === false); + // Professional mode is opt-in: the seeded runtime above predates the key, and an upgrading + // install must not silently start emitting hints instead of prose. + // + // Two independent mechanisms deliver this - the DEFAULT_RUNTIME_CONFIG spread in getConfig, + // and the migration IIFE, which pins false rather than the default. That redundancy is the + // point: should professional mode ever become the default for new installs, the migration is + // what keeps existing users on prose. No single assertion can isolate one mechanism while + // both hold, so this asserts the invariant itself. + check('professionalMode reads off on upgrade', cfg.professionalMode === false); store.configStore.updateConfig({ professionalMode: true }); - check('professionalMode is persisted', store.configStore.getConfig().professionalMode === true); + check( + 'professionalMode is persisted', + store.configStore.getStoredRuntime()?.professionalMode === true + ); store.configStore.updateConfig({ sessionToken: 'tok2' }); check( From 376bfb1098df094a289de371dfb1ca14c84d3d7f Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 18:12:34 -0400 Subject: [PATCH 3/8] fix(suggestions): match a markdown-wrapped NO_SUGGESTION_NEEDED Professional mode asks the model for a bold headline on line 1, so a model that carries the format over to the sentinel emits **NO_SUGGESTION_NEEDED**. The literal prefix match missed that and left the sentinel itself sitting in the panel as a card, mid-interview. Co-Authored-By: Claude Opus 5 --- src/main/services/suggestion-live.service.ts | 7 ++----- src/main/utils/suggestion-sentinel.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 src/main/utils/suggestion-sentinel.ts diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index c229a269..bfebaded 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -1,6 +1,5 @@ import { LLMApi } from '../api/llm.js'; import { - LIVE_SUGGESTION_NO_SUGGESTION, LIVE_SUGGESTION_TTFB_MS, SUGGESTION_STALL_MS, TRANSCRIPT_UPLOAD_LIMIT, @@ -10,6 +9,7 @@ import { LiveSuggestion, Speaker, SuggestionState, Transcript } from '../types/a import { GenerateLiveSuggestionRequest, SuggestionMode } from '../types/llm.js'; import { DateTimeUtil } from '../utils/datetime.js'; import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; +import { isNoSuggestionSentinel } from '../utils/suggestion-sentinel.js'; import { UuidUtil } from '../utils/uuid.js'; import { appStateService } from './app-state.service.js'; @@ -36,10 +36,7 @@ class LiveSuggestionService { return; } - if ( - suggestion.answer.length > 0 && - LIVE_SUGGESTION_NO_SUGGESTION.startsWith(suggestion.answer) - ) { + if (isNoSuggestionSentinel(suggestion.answer)) { this.suggestions.delete(timestamp); } else { this.suggestions.set(timestamp, suggestion); diff --git a/src/main/utils/suggestion-sentinel.ts b/src/main/utils/suggestion-sentinel.ts new file mode 100644 index 00000000..2d626432 --- /dev/null +++ b/src/main/utils/suggestion-sentinel.ts @@ -0,0 +1,20 @@ +import { LIVE_SUGGESTION_NO_SUGGESTION } from '../consts.js'; + +/** + * Whether a streamed live answer is the "no suggestion needed" sentinel, or is still a prefix of + * one. + * + * Prefix-matched because it runs on every chunk: a sentinel that only matched once complete would + * flash a half-written NO_SUGGESTION_NEEDED card into the panel first. + * + * Markdown emphasis is stripped before the comparison. Professional mode asks the model for a bold + * headline on line 1, so a model that carries that format over to the sentinel emits + * `**NO_SUGGESTION_NEEDED**`; a bare-string match would leave that sitting in the panel as a card. + * The prompt asks for it bare, but the fallback costs one regex and the failure is visible + * mid-interview. + */ +export function isNoSuggestionSentinel(answer: string): boolean { + const bare = answer.replace(/^[\s*`#>-]+/, '').replace(/[\s*`]+$/, ''); + + return bare.length > 0 && LIVE_SUGGESTION_NO_SUGGESTION.startsWith(bare); +} From 68d7f84e0c9c60461b4ed658e9ee63d3d5a643a4 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 18:12:35 -0400 Subject: [PATCH 4/8] test(suggestions): pin sentinel matching in both directions The wrapped forms have to be suppressed and real answers have to survive; loosening the match too far would swallow an answer with no trace. Co-Authored-By: Claude Opus 5 --- test/run.mjs | 1 + test/suggestion-sentinel.test.mjs | 39 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 test/suggestion-sentinel.test.mjs diff --git a/test/run.mjs b/test/run.mjs index 818c7547..9ded71d4 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -24,6 +24,7 @@ for (const module of [ // reads the same running state through its own copy of window-control. './running-surface.test.mjs', './tools-export.test.mjs', + './suggestion-sentinel.test.mjs', './mac-update-util.test.mjs', ]) { const { run } = await import(module); diff --git a/test/suggestion-sentinel.test.mjs b/test/suggestion-sentinel.test.mjs new file mode 100644 index 00000000..c0a7fb2c --- /dev/null +++ b/test/suggestion-sentinel.test.mjs @@ -0,0 +1,39 @@ +/** + * The backend answers a pure-backchannel question with NO_SUGGESTION_NEEDED, and the live service + * matches that string to drop the card instead of showing it. The match is the whole mechanism: + * miss it and the sentinel itself is what the candidate reads mid-interview. + * + * Professional mode is what put pressure on it. That prompt asks for a bold headline on line 1, so + * a model carrying the format over to the sentinel emits it wrapped in **, and a bare-string match + * fails. Prefix matching also has to keep working, since this runs on every streamed chunk. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('suggestion-sentinel'); + + const { isNoSuggestionSentinel } = await loadMain('utils/suggestion-sentinel.js'); + + check('matches the bare sentinel', isNoSuggestionSentinel('NO_SUGGESTION_NEEDED')); + check('matches a partial sentinel mid-stream', isNoSuggestionSentinel('NO_SUGGESTION')); + check('matches the first chunk of one', isNoSuggestionSentinel('NO')); + + check('matches a bolded sentinel', isNoSuggestionSentinel('**NO_SUGGESTION_NEEDED**')); + check('matches a bolded partial', isNoSuggestionSentinel('**NO_SUGG')); + check('matches a bulleted sentinel', isNoSuggestionSentinel('- NO_SUGGESTION_NEEDED')); + check('matches a trailing newline', isNoSuggestionSentinel('NO_SUGGESTION_NEEDED\n')); + + check('an empty answer is not the sentinel', !isNoSuggestionSentinel('')); + check('bare emphasis alone is not the sentinel', !isNoSuggestionSentinel('**')); + + // The other half: a real answer must never be swallowed, in either mode. + check( + 'a professional headline is kept', + !isNoSuggestionSentinel('**Cut p99 from 1.8s to 210ms on the orders API**') + ); + check('a partial professional headline is kept', !isNoSuggestionSentinel('**Cut')); + check('a prose answer is kept', !isNoSuggestionSentinel('No, I owned the migration end to end.')); + check('a prose answer starting mid-word is kept', !isNoSuggestionSentinel('Nothing')); + + return failures; +} From 5d84a470857fc13360709b4171d9c2bce642e33b Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 18:12:43 -0400 Subject: [PATCH 5/8] feat(suggestions): promote the professional headline in the live panel SafeMarkdown renders paragraphs semibold, so the bold headline sat one weight step from the bullets under it - the line the whole mode exists to make readable in a glance was the hardest one to pick out. Co-Authored-By: Claude Opus 5 --- .../components/custom/panels/live-suggestions-panel.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/custom/panels/live-suggestions-panel.tsx b/src/renderer/components/custom/panels/live-suggestions-panel.tsx index 6f565a4e..02134938 100644 --- a/src/renderer/components/custom/panels/live-suggestions-panel.tsx +++ b/src/renderer/components/custom/panels/live-suggestions-panel.tsx @@ -37,7 +37,13 @@ function SuggestionAnswer({ }) { if (suggestion.mode === SuggestionMode.Professional) { return ( -
+ // The headline is the line that has to land in a glance, and SafeMarkdown renders it as bold + // text in a paragraph that is already semibold - next to no contrast against the bullets + // under it. Size and full-strength foreground rather than text-accent: the accent is an + // oklch 0.77 orange, which reads as a highlight on the dark card and as low-contrast body + // text on the light one. Applied here, not in SafeMarkdown, because the action panel renders + // whole documents through the same component and has no headline line to promote. +
{trailing && ...}
From 765de3f45484716518ad5afd8f034934cdd3e308 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 18:12:44 -0400 Subject: [PATCH 6/8] docs: record the sentinel guard Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 477088d3..471cea8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,8 @@ Action suggestions are independent of transcripts - triggered by screenshot capt **Professional mode** (`professionalMode` in ConfigStore, off by default) asks the backend for hints - a headline plus keyword bullets - instead of full sentences. Both suggestion services read the flag once at the top of `generateSuggestion` and send it as `mode` on the request; the backend defaults it to `normal`, so the field is safe to omit against an older deployment. +The `NO_SUGGESTION_NEEDED` sentinel goes through `isNoSuggestionSentinel()` ([src/main/utils/suggestion-sentinel.ts](src/main/utils/suggestion-sentinel.ts)) rather than a direct comparison. It is prefix-matched because it runs on every streamed chunk, and it strips leading markdown first: the professional prompt asks for a bold headline on line 1, so a model that carries that format over emits `**NO_SUGGESTION_NEEDED**` and a bare match would leave the sentinel on screen as a card. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not. + Each `LiveSuggestion` carries the `mode` it was *generated* under, and the panel picks its renderer from that, not from the current setting. Reading the live setting instead would reformat every card on screen the moment the user toggles mid-interview, parsing prose answers as Markdown. Professional answers render through `SafeMarkdown`, the same component the action panel already uses; normal answers keep the plain 🪄-prefixed text. ### Routing From e444c737638a51d8fd6630bbcb26295d09421e82 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 18:17:09 -0400 Subject: [PATCH 7/8] feat(suggestions): render live answers as Markdown in both modes The normal-mode prompt asks for plain text with light formatting, so bold or a bullet reached the panel as literal asterisks under whitespace-pre-wrap. Both modes now go through SafeMarkdown, the component the action panel already uses. Prose is passed through withHardBreaks() first, since Markdown folds a single newline into a space and the previous rendering showed every one of them. The wand keeps a column of its own rather than being prepended to the content, where it would swallow whatever structure the answer opens with. The stopped marker moves into the content so it stays inline at the end of the last line. Co-Authored-By: Claude Opus 5 --- .../custom/panels/live-suggestions-panel.tsx | 27 ++++++++++++++----- src/renderer/lib/suggestions.ts | 14 ++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/custom/panels/live-suggestions-panel.tsx b/src/renderer/components/custom/panels/live-suggestions-panel.tsx index 02134938..d0e45f01 100644 --- a/src/renderer/components/custom/panels/live-suggestions-panel.tsx +++ b/src/renderer/components/custom/panels/live-suggestions-panel.tsx @@ -13,7 +13,7 @@ function truncateMiddle(text: string, maxLen: number): string { import { Card } from '@/components/ui/card'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; -import { newestTimestamp } from '@/lib/suggestions'; +import { newestTimestamp, withHardBreaks } from '@/lib/suggestions'; import { SuggestionMode } from '@/types/llm'; import { type LiveSuggestion, SuggestionState } from '@/types/suggestion'; @@ -25,8 +25,14 @@ import SuggestionReveal from './suggestion-reveal'; /** * Render one answer in the format it was generated in. * + * Both modes go through `SafeMarkdown`: the normal-mode prompt asks for light formatting, and a + * model that reaches for bold or a bullet there used to put the asterisks on screen literally. * Keyed off the suggestion's own mode rather than the current setting, so toggling mid-interview - * leaves cards already on screen alone. `trailing` marks a stopped stream as unfinished. + * leaves cards already on screen alone - what the mode still decides is the presentation around + * the Markdown, not whether it is parsed. + * + * `trailing` marks a stopped stream as unfinished. Appended to the content rather than rendered + * beside it, so it lands inline at the end of the last line instead of on a block of its own. */ function SuggestionAnswer({ suggestion, @@ -35,6 +41,8 @@ function SuggestionAnswer({ suggestion: LiveSuggestion; trailing?: boolean; }) { + const suffix = trailing ? ' ...' : ''; + if (suggestion.mode === SuggestionMode.Professional) { return ( // The headline is the line that has to land in a glance, and SafeMarkdown renders it as bold @@ -44,16 +52,21 @@ function SuggestionAnswer({ // text on the light one. Applied here, not in SafeMarkdown, because the action panel renders // whole documents through the same component and has no headline line to promote.
- - {trailing && ...} +
); } return ( -
- 🪄 {suggestion.answer} - {trailing && ' ...'} + // The wand stays a marker in its own column rather than a character prepended to the content: + // inside the Markdown it would swallow whatever structure the answer opens with. +
+ +
+ +
); } diff --git a/src/renderer/lib/suggestions.ts b/src/renderer/lib/suggestions.ts index 5171fb26..1eeb63bc 100644 --- a/src/renderer/lib/suggestions.ts +++ b/src/renderer/lib/suggestions.ts @@ -2,3 +2,17 @@ export function newestTimestamp(items: { timestamp: number }[]): number { return items.reduce((max, item) => Math.max(max, item.timestamp), 0); } + +/** + * Turn single newlines into Markdown hard breaks. + * + * Markdown folds a single newline into a space, and prose answers used to render under + * `whitespace-pre-wrap`, where every newline the model emitted was a line the candidate saw. Blank + * lines are left alone - they already separate paragraphs. + * + * For prose only. Structural Markdown (lists, fenced code) carries its own line semantics and does + * not want two spaces welded onto the end of every line. + */ +export function withHardBreaks(text: string): string { + return text.replace(/([^\n])\n(?!\n)/g, '$1 \n'); +} From e540f8df4c7665d78a2ae49d79793992fd7f22d3 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 18:17:09 -0400 Subject: [PATCH 8/8] docs: both live suggestion modes render Markdown Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 +++- SPEC.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 471cea8e..7f79d73b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,9 @@ Action suggestions are independent of transcripts - triggered by screenshot capt The `NO_SUGGESTION_NEEDED` sentinel goes through `isNoSuggestionSentinel()` ([src/main/utils/suggestion-sentinel.ts](src/main/utils/suggestion-sentinel.ts)) rather than a direct comparison. It is prefix-matched because it runs on every streamed chunk, and it strips leading markdown first: the professional prompt asks for a bold headline on line 1, so a model that carries that format over emits `**NO_SUGGESTION_NEEDED**` and a bare match would leave the sentinel on screen as a card. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not. -Each `LiveSuggestion` carries the `mode` it was *generated* under, and the panel picks its renderer from that, not from the current setting. Reading the live setting instead would reformat every card on screen the moment the user toggles mid-interview, parsing prose answers as Markdown. Professional answers render through `SafeMarkdown`, the same component the action panel already uses; normal answers keep the plain 🪄-prefixed text. +Both live modes render through `SafeMarkdown`, the same component the action panel uses. The normal-mode prompt asks for plain text *with light formatting*, so any bold or bullet the model reached for used to land on screen as literal asterisks. Prose is passed through `withHardBreaks()` ([src/renderer/lib/suggestions.ts](src/renderer/lib/suggestions.ts)) first: Markdown folds a single newline into a space, and the `whitespace-pre-wrap` rendering it replaced showed every newline the model emitted. + +Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the panel keys off that rather than the current setting, so toggling mid-interview leaves cards already on screen alone. What the mode selects is the presentation around the Markdown: professional promotes the headline line, normal keeps the 🪄 marker in a column of its own - prepending it to the content instead would swallow whatever structure the answer opens with. ### Routing diff --git a/SPEC.md b/SPEC.md index 732c660e..b1f4a8bc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -49,7 +49,7 @@ Real-time ASR via WebSocket streaming on two separate channels - the interviewer ### Live Suggestions -Streaming AI responses generated from the user's CV and job description, triggered by live transcript context. Service: [src/main/services/suggestion-live.service.ts](src/main/services/suggestion-live.service.ts). +Streaming AI responses generated from the user's CV and job description, triggered by live transcript context. Answers render as Markdown in both suggestion modes, so bold, bullets and inline code arrive formatted rather than as raw characters. Service: [src/main/services/suggestion-live.service.ts](src/main/services/suggestion-live.service.ts). ### Action Suggestions