Skip to content

Commit e9ee802

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(console/ai): keyboard shortcuts — ⌘⇧O new chat, ⌘⇧S toggle list (#1707)
Parity with ChatGPT/Claude: ⌘/Ctrl+Shift+O starts a new conversation, and ⌘/Ctrl+Shift+S toggles the conversations list. Both use the ⌘/Ctrl+Shift modifier so they're safe to fire while the composer is focused (ordinary typing can't produce them). Registered via a keydown listener on the AI chat page and surfaced in the keyboard-shortcuts cheatsheet (the "?" dialog) under a new "AI assistant" group. The match logic is a pure exported `matchAiChatShortcut` and unit-tested. Test: 487 pass (5 new — ⌘/Ctrl variants, requires Shift, rejects Alt/missing modifier, ignores unrelated keys). tsc clean; no new lint errors. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b3a87eb commit e9ee802

3 files changed

Lines changed: 84 additions & 0 deletions

File tree

packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export function KeyboardShortcutsDialog() {
5353
{ keys: ['⌘', 'E'], description: t('console.shortcuts.editRecord') },
5454
],
5555
},
56+
{
57+
title: t('console.shortcuts.groups.aiChat', { defaultValue: 'AI assistant' }),
58+
shortcuts: [
59+
{ keys: ['⌘', '⇧', 'O'], description: t('console.shortcuts.newChat', { defaultValue: 'New chat' }) },
60+
{ keys: ['⌘', '⇧', 'S'], description: t('console.shortcuts.toggleChatsList', { defaultValue: 'Toggle conversations list' }) },
61+
],
62+
},
5663
{
5764
title: t('console.shortcuts.groups.preferences'),
5865
shortcuts: [

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,35 @@ export function useResizableChatPane(active: boolean): ResizableChatPane {
363363
return { width, dragging, containerRef, onHandlePointerDown, onHandleKeyDown, reset };
364364
}
365365

366+
export type AiChatShortcut = 'toggle-list' | 'new-chat';
367+
368+
/**
369+
* Match a keydown to an AI-chat shortcut, mirroring ChatGPT/Claude:
370+
* - ⌘/Ctrl+Shift+O → new chat
371+
* - ⌘/Ctrl+Shift+S → toggle the conversations list
372+
*
373+
* Both use the ⌘/Ctrl+Shift modifier so they're safe to fire even while the
374+
* composer is focused (they can't be produced by ordinary typing). Pure +
375+
* exported for tests.
376+
*/
377+
export function matchAiChatShortcut(e: {
378+
key: string;
379+
metaKey: boolean;
380+
ctrlKey: boolean;
381+
shiftKey: boolean;
382+
altKey: boolean;
383+
}): AiChatShortcut | null {
384+
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return null;
385+
switch (e.key.toLowerCase()) {
386+
case 'o':
387+
return 'new-chat';
388+
case 's':
389+
return 'toggle-list';
390+
default:
391+
return null;
392+
}
393+
}
394+
366395
export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp }: AiChatPageProps = {}) {
367396
const { user } = useAuth();
368397
const { t } = useObjectTranslation();
@@ -430,6 +459,18 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
430459
toggle: toggleChatsCollapsed,
431460
handleCanvasOpenChange,
432461
} = useCollapsibleChatsList();
462+
// Keyboard shortcuts (ChatGPT/Claude parity): ⌘⇧O new chat, ⌘⇧S toggle list.
463+
useEffect(() => {
464+
const onKeyDown = (e: KeyboardEvent) => {
465+
const action = matchAiChatShortcut(e);
466+
if (!action) return;
467+
e.preventDefault();
468+
if (action === 'toggle-list') toggleChatsCollapsed();
469+
else navigate('/ai?new=1');
470+
};
471+
document.addEventListener('keydown', onKeyDown);
472+
return () => document.removeEventListener('keydown', onKeyDown);
473+
}, [toggleChatsCollapsed, navigate]);
433474
const restApiBase = useMemo(
434475
() => apiBase.replace(/\/v1\/ai$/, '').replace(/\/ai$/, '') || '/api',
435476
[apiBase],
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { matchAiChatShortcut } from '../AiChatPage';
3+
4+
const ev = (over: Partial<{ key: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }>) => ({
5+
key: 'o',
6+
metaKey: false,
7+
ctrlKey: false,
8+
shiftKey: false,
9+
altKey: false,
10+
...over,
11+
});
12+
13+
describe('matchAiChatShortcut', () => {
14+
it('maps ⌘⇧O and Ctrl⇧O to new-chat', () => {
15+
expect(matchAiChatShortcut(ev({ key: 'O', metaKey: true, shiftKey: true }))).toBe('new-chat');
16+
expect(matchAiChatShortcut(ev({ key: 'o', ctrlKey: true, shiftKey: true }))).toBe('new-chat');
17+
});
18+
19+
it('maps ⌘⇧S to toggle-list', () => {
20+
expect(matchAiChatShortcut(ev({ key: 'S', metaKey: true, shiftKey: true }))).toBe('toggle-list');
21+
});
22+
23+
it('requires the shift modifier', () => {
24+
expect(matchAiChatShortcut(ev({ key: 'o', metaKey: true }))).toBeNull();
25+
expect(matchAiChatShortcut(ev({ key: 's', ctrlKey: true }))).toBeNull();
26+
});
27+
28+
it('requires a ⌘/Ctrl modifier and rejects Alt', () => {
29+
expect(matchAiChatShortcut(ev({ key: 'o', shiftKey: true }))).toBeNull();
30+
expect(matchAiChatShortcut(ev({ key: 'o', metaKey: true, shiftKey: true, altKey: true }))).toBeNull();
31+
});
32+
33+
it('ignores unrelated keys', () => {
34+
expect(matchAiChatShortcut(ev({ key: 'k', metaKey: true, shiftKey: true }))).toBeNull();
35+
});
36+
});

0 commit comments

Comments
 (0)