From 3c77049049bee86648ae3d8cae703e27da857d7a Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 7 Aug 2026 14:34:08 -0500 Subject: [PATCH 1/3] fix(transcript,hotkeys): bound transcript line width, surface silent hotkey registration failures Transcript dock rows inlined the speaker label into the text paragraph with no max width, so a maximized/ultra-wide window let a single utterance stretch edge-to-edge, and variable-length speaker names shifted where the text started on every row. Give the speaker its own fixed-width truncated column, cap the row at a readable max width, and truncate long text on one line (full text on hover) instead of wrapping, keeping the wide-short-dock density goal from 6d03a00 intact. Separately, none of the 25 globalShortcut.register() calls in hotkeys.ts checked their return value. Electron returns false rather than throwing when another application has already claimed an accelerator system-wide, so a binding can silently go dead. Reproduced as Ctrl+Shift+L (scroll live suggestions to end) not working during manual testing while its J/K neighbors did - L is a far more commonly claimed global combo. All registrations now go through a helper that logs a warning on failure. Fixes #85, fixes #86 Co-Authored-By: Claude Sonnet 5 --- src/main/hotkeys.ts | 65 +++++++++++-------- .../custom/panels/transcript-panel.tsx | 62 +++++++++++------- 2 files changed, 76 insertions(+), 51 deletions(-) diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index 8159298..08bd14f 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -18,6 +18,19 @@ const isMac = process.platform === 'darwin'; // Windows/Linux use Control+Shift const BASE = isMac ? 'Control+Alt' : 'Control+Shift'; +// globalShortcut.register() returns false rather than throwing when another application has +// already claimed the accelerator system-wide - a common combo like Ctrl+Shift+L is far more +// likely to lose that race than e.g. Ctrl+Shift+J, so without this check one binding can go +// dead with no trace while its neighbors keep working. +function registerShortcut(accelerator: string, callback: () => void): void { + const ok = globalShortcut.register(accelerator, callback); + if (!ok) { + console.warn( + `[Hotkeys] failed to register ${accelerator} - likely already claimed by another application` + ); + } +} + /** * Register global hotkeys for window management and navigation */ @@ -26,7 +39,7 @@ export function registerGlobalHotkeys(): void { globalShortcut.unregisterAll(); // Stop assistant - globalShortcut.register(`${BASE}+Q`, () => { + registerShortcut(`${BASE}+Q`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) { w.webContents.send('hotkey:stop-assistant'); @@ -34,35 +47,35 @@ export function registerGlobalHotkeys(): void { }); // Stealth mode toggle - globalShortcut.register(`${BASE}+M`, () => toggleStealth()); + registerShortcut(`${BASE}+M`, () => toggleStealth()); // Opacity toggle: cycle opacity when in stealth mode - globalShortcut.register(`${BASE}+N`, () => toggleOpacity()); + registerShortcut(`${BASE}+N`, () => toggleOpacity()); // Toggle the transcription dock. F8 rather than T because these shortcuts are system-wide, and // it keeps the dock reachable in stealth mode, where the control panel carrying the button is // hidden. - globalShortcut.register(`${BASE}+F8`, () => { + registerShortcut(`${BASE}+F8`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-transcript'); }); // Zoom hotkeys - globalShortcut.register(`${BASE}+=`, () => { + registerShortcut(`${BASE}+=`, () => { try { zoomService.adjustZoom(ZOOM_STEP); } catch (e) { console.warn('hotkey zoom in failed', e); } }); - globalShortcut.register(`${BASE}+-`, () => { + registerShortcut(`${BASE}+-`, () => { try { zoomService.adjustZoom(-ZOOM_STEP); } catch (e) { console.warn('hotkey zoom out failed', e); } }); - globalShortcut.register(`${BASE}+0`, () => { + registerShortcut(`${BASE}+0`, () => { try { zoomService.resetZoom(); } catch (e) { @@ -100,63 +113,63 @@ export function registerGlobalHotkeys(): void { }; for (let i = 1; i <= 9; i++) { - globalShortcut.register(`${BASE}+${i}`, () => { + registerShortcut(`${BASE}+${i}`, () => { moveWindowToCorner(numToCorner(i)); }); } // Window movement: Ctrl+Alt+Shift+Arrow (Alt = Option on macOS) - globalShortcut.register('Control+Alt+Shift+Up', () => moveWindowByArrow('up')); - globalShortcut.register('Control+Alt+Shift+Down', () => moveWindowByArrow('down')); - globalShortcut.register('Control+Alt+Shift+Left', () => moveWindowByArrow('left')); - globalShortcut.register('Control+Alt+Shift+Right', () => moveWindowByArrow('right')); + registerShortcut('Control+Alt+Shift+Up', () => moveWindowByArrow('up')); + registerShortcut('Control+Alt+Shift+Down', () => moveWindowByArrow('down')); + registerShortcut('Control+Alt+Shift+Left', () => moveWindowByArrow('left')); + registerShortcut('Control+Alt+Shift+Right', () => moveWindowByArrow('right')); // Window resize: macOS = Ctrl+Option+Command+Arrow, Windows = Ctrl+Win+Shift+Arrow const resizeMod = isMac ? 'Control+Alt+Super' : 'Control+Super+Shift'; - globalShortcut.register(`${resizeMod}+Up`, () => resizeWindowByArrow('up')); - globalShortcut.register(`${resizeMod}+Down`, () => resizeWindowByArrow('down')); - globalShortcut.register(`${resizeMod}+Right`, () => resizeWindowByArrow('right')); - globalShortcut.register(`${resizeMod}+Left`, () => resizeWindowByArrow('left')); + registerShortcut(`${resizeMod}+Up`, () => resizeWindowByArrow('up')); + registerShortcut(`${resizeMod}+Down`, () => resizeWindowByArrow('down')); + registerShortcut(`${resizeMod}+Right`, () => resizeWindowByArrow('right')); + registerShortcut(`${resizeMod}+Left`, () => resizeWindowByArrow('left')); // Scroll live suggestions: J (down) / K (up) / L (end) - globalShortcut.register(`${BASE}+K`, () => { + registerShortcut(`${BASE}+K`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:scroll', '0', 'up'); }); - globalShortcut.register(`${BASE}+J`, () => { + registerShortcut(`${BASE}+J`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:scroll', '0', 'down'); }); - globalShortcut.register(`${BASE}+L`, () => { + registerShortcut(`${BASE}+L`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:scroll', '0', 'end'); }); // Scroll action suggestions: I (up) / U (down) / O (end) - globalShortcut.register(`${BASE}+I`, () => { + registerShortcut(`${BASE}+I`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:scroll', '1', 'up'); }); - globalShortcut.register(`${BASE}+U`, () => { + registerShortcut(`${BASE}+U`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:scroll', '1', 'down'); }); - globalShortcut.register(`${BASE}+O`, () => { + registerShortcut(`${BASE}+O`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:scroll', '1', 'end'); }); // Action suggestion operations - globalShortcut.register(`${BASE}+F9`, async () => { + registerShortcut(`${BASE}+F9`, async () => { await actionSuggestionService.captureScreenshot(); }); - globalShortcut.register(`${BASE}+F10`, async () => { + registerShortcut(`${BASE}+F10`, async () => { await actionSuggestionService.clearImages(); }); - globalShortcut.register(`${BASE}+F11`, async () => { + registerShortcut(`${BASE}+F11`, async () => { await actionSuggestionService.startGenerateSuggestion(); }); - globalShortcut.register(`${BASE}+F12`, async () => { + registerShortcut(`${BASE}+F12`, async () => { try { if (!actionSuggestionService.hasUploadedImages()) { await actionSuggestionService.captureScreenshot(); diff --git a/src/renderer/components/custom/panels/transcript-panel.tsx b/src/renderer/components/custom/panels/transcript-panel.tsx index 9a7d037..8b07b59 100644 --- a/src/renderer/components/custom/panels/transcript-panel.tsx +++ b/src/renderer/components/custom/panels/transcript-panel.tsx @@ -80,32 +80,44 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp ) : ( <>
- {transcripts.map((item, idx) => ( - // content-visibility skips style, layout and paint for rows scrolled out of - // view, which is the cost that grew with transcript length. Chosen over a - // windowing library because rows wrap to variable heights: `auto` in - // contain-intrinsic-size remembers each row's last rendered size, so scroll - // position and scrollIntoView stay accurate without measurement plumbing. -
- {/* The dock is wide and short, so the speaker rides inline with the words - rather than spending a line of its own on them. */} -

- - {item.speaker === Speaker.Self ? username : 'Interviewer'} - - {item.text} -

-
+ ); + })}
{/* Kept out of the divided list, or it would take a divider of its own */}
From f6b12c64650cdd06a1d64a36066019229c38c123 Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 7 Aug 2026 16:14:46 -0500 Subject: [PATCH 2/3] style(transcript): center capped-width rows in the wide dock max-w-3xl bounded row width but left it hugging the dock's left edge; mx-auto centers it so the readable-width column sits in the middle of the full-width dock instead. Co-Authored-By: Claude Sonnet 5 --- src/renderer/components/custom/panels/transcript-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/custom/panels/transcript-panel.tsx b/src/renderer/components/custom/panels/transcript-panel.tsx index 8b07b59..26d096b 100644 --- a/src/renderer/components/custom/panels/transcript-panel.tsx +++ b/src/renderer/components/custom/panels/transcript-panel.tsx @@ -89,7 +89,7 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp // measurement plumbing.
{/* Fixed-width column so wrapped/adjacent rows keep a stable left edge regardless of speaker name length; long names truncate rather than From bff8c7de94927e9b9d6ab6e4c7dfee64cded6d94 Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 7 Aug 2026 16:18:16 -0500 Subject: [PATCH 3/3] fix(transcript): truncate only the speaker column, let text wrap Single-line ellipsis on the transcript text itself was cutting off utterances the candidate needs to actually read back. Truncation belongs on the fixed-width speaker column only; the text wraps as before within the max-w-3xl bound. Co-Authored-By: Claude Sonnet 5 --- .../custom/panels/transcript-panel.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/custom/panels/transcript-panel.tsx b/src/renderer/components/custom/panels/transcript-panel.tsx index 26d096b..4f62dad 100644 --- a/src/renderer/components/custom/panels/transcript-panel.tsx +++ b/src/renderer/components/custom/panels/transcript-panel.tsx @@ -84,12 +84,12 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp const speaker = item.speaker === Speaker.Self ? username : 'Interviewer'; return ( // content-visibility skips style, layout and paint for rows scrolled out of - // view, which is the cost that grew with transcript length. Fixed row height - // (single-line, truncated) keeps contain-intrinsic-size accurate without - // measurement plumbing. + // view, which is the cost that grew with transcript length. `auto` in + // contain-intrinsic-size remembers each row's last rendered size, so scroll + // position and scrollIntoView stay accurate without measurement plumbing.
{/* Fixed-width column so wrapped/adjacent rows keep a stable left edge regardless of speaker name length; long names truncate rather than @@ -100,13 +100,7 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp > {speaker} - {/* Single-line with end truncation, not wrap: keeps every row the same - height (the density goal of the wide, short dock) and readable at any - window width. Full text on hover. */} -

+

{item.text}