Skip to content
Merged
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 4 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions src/main/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ipcRenderer.invoke('config:update', updates),
Expand Down
2 changes: 2 additions & 0 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -65,6 +66,7 @@ export class AppStateService {
answer: 'Suggested answers will be here in real-time',
state: SuggestionState.Success,
error: '',
mode: SuggestionMode.Normal,
},
],
actionSuggestions: [
Expand Down
3 changes: 2 additions & 1 deletion src/main/services/suggestion-action.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 19 additions & 8 deletions src/main/services/suggestion-live.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/main/store/config.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/main/types/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { UserRole } from './health-check.js';
import { SuggestionMode } from './llm.js';

export enum Speaker {
Self = 'self',
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src/main/types/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/main/utils/suggestion-sentinel.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions src/renderer/components/custom/control-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -155,6 +156,7 @@ export default function ControlPanel() {
getDisabled={getDisabled}
/>
<LLMGroup getDisabled={getDisabled} />
<ProfessionalModeGroup />
</div>

<MainGroup stateConfig={{ onClick, className, icon, label }} getDisabled={getDisabled} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn('h-8 w-8 border-none rounded-xl', enabled && 'text-accent')}
aria-pressed={enabled}
aria-label="Toggle professional mode"
onClick={toggle}
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Professional Mode: {enabled ? 'On' : 'Off'} (
{HOTKEYS[Hotkey.ToggleProfessionalMode].combo})
</p>
<p className="text-xs text-muted-foreground">
{enabled ? 'Short hints: headline + keyword bullets' : 'Full sentences'}
</p>
</TooltipContent>
</Tooltip>
</div>
);
}
Loading