diff --git a/CLAUDE.md b/CLAUDE.md index 7d4a201..7f79d73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,14 @@ 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. + +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. + +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 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 a819c7d..b1f4a8b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -49,12 +49,15 @@ 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 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. ### Session Window Behaviour While the assistant is running - or while stealth mode is on - the window is pinned above other windows (`screen-saver` level, and visible over a fullscreen call on macOS) and drops its taskbar button and Dock icon. The two conditions are independent: switching stealth off mid-session leaves both in place until the session actually stops. macOS traffic lights stay visible outside stealth, since the window is still interactive. Service: [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts). diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index 08bd14f..58d607d 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 7859e24..1988e42 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 58efcb2..af84fd0 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, refreshWindowSurfaces } 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 69f56e5..a6ef097 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 9b873ee..bfebade 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -1,15 +1,15 @@ import { LLMApi } from '../api/llm.js'; import { - LIVE_SUGGESTION_NO_SUGGESTION, LIVE_SUGGESTION_TTFB_MS, SUGGESTION_STALL_MS, TRANSCRIPT_UPLOAD_LIMIT, } 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 { 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); @@ -59,12 +56,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 +86,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 +194,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 45bacde..9b9a27a 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 27ca173..7e17ecd 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 ba6a16c..baa741b 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/main/utils/suggestion-sentinel.ts b/src/main/utils/suggestion-sentinel.ts new file mode 100644 index 0000000..2d62643 --- /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); +} diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 929bf10..9f3efc9 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 0000000..a05a582 --- /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 459f055..d0e45f0 100644 --- a/src/renderer/components/custom/panels/live-suggestions-panel.tsx +++ b/src/renderer/components/custom/panels/live-suggestions-panel.tsx @@ -13,13 +13,64 @@ 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'; 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. + * + * 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 - 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, + trailing, +}: { + 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 + // 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. +
+ +
+ ); + } + + return ( + // 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. +
+ +
+ +
+
+ ); +} + interface LiveSuggestionsPanelProps { suggestions?: LiveSuggestion[]; style?: React.CSSProperties; @@ -223,16 +274,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 0000000..0326017 --- /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 77da781..6a285d9 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/lib/suggestions.ts b/src/renderer/lib/suggestions.ts index 5171fb2..1eeb63b 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'); +} diff --git a/src/renderer/pages/main/index.tsx b/src/renderer/pages/main/index.tsx index a926abd..a54960b 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 8d8e006..3f70f79 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 41bf5d7..15c2827 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 7612120..4caf8af 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 ad65305..114de8d 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 976e0af..61d1e9e 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,34 @@ 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, 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.getStoredRuntime()?.professionalMode === true + ); + + store.configStore.updateConfig({ sessionToken: 'tok2' }); + check( + 'professionalMode survives an unrelated write', + store.configStore.getConfig().professionalMode === true + ); + return failures; } diff --git a/test/run.mjs b/test/run.mjs index 818c754..9ded71d 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 0000000..c0a7fb2 --- /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; +}