Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 39 additions & 26 deletions src/main/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -26,43 +39,43 @@ 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');
}
});

// 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) {
Expand Down Expand Up @@ -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();
Expand Down
56 changes: 31 additions & 25 deletions src/renderer/components/custom/panels/transcript-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,32 +80,38 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp
) : (
<>
<div className="divide-y divide-border/50 px-2 py-1">
{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.
<div
key={idx}
className="flex items-baseline gap-2 py-1 [content-visibility:auto] [contain-intrinsic-size:auto_2rem]"
>
{/* The dock is wide and short, so the speaker rides inline with the words
rather than spending a line of its own on them. */}
<p className="flex-1 text-sm text-foreground/90 leading-snug text-wrap">
<span className="text-xs font-semibold text-primary mr-1.5">
{item.speaker === Speaker.Self ? username : 'Interviewer'}
</span>
{item.text}
</p>
<time
dateTime={new Date(item.timestamp).toISOString()}
className="text-xs text-muted-foreground tabular-nums shrink-0"
{transcripts.map((item, idx) => {
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. `auto` in
// contain-intrinsic-size remembers each row's last rendered size, so scroll
// position and scrollIntoView stay accurate without measurement plumbing.
<div
key={idx}
className="flex items-baseline gap-2 py-1 max-w-3xl mx-auto [content-visibility:auto] [contain-intrinsic-size:auto_2rem]"
>
{timeFormat.format(item.timestamp)}
</time>
</div>
))}
{/* Fixed-width column so wrapped/adjacent rows keep a stable left edge
regardless of speaker name length; long names truncate rather than
pushing the text column around. */}
<span
className="w-20 shrink-0 truncate text-xs font-semibold text-primary"
title={speaker}
>
{speaker}
</span>
<p className="flex-1 min-w-0 text-sm text-foreground/90 leading-snug text-wrap">
{item.text}
</p>
<time
dateTime={new Date(item.timestamp).toISOString()}
className="text-xs text-muted-foreground tabular-nums shrink-0"
>
{timeFormat.format(item.timestamp)}
</time>
</div>
);
})}
</div>
{/* Kept out of the divided list, or it would take a divider of its own */}
<div ref={endRef} />
Expand Down