Skip to content

Commit 9932aa4

Browse files
brookscclaudejohannesjo
authored
feat(shortcuts): add Cmd+1-9 to jump to task by sidebar position (#97)
* feat(shortcuts): add Cmd+1-9 to jump to task by sidebar position Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(shortcuts): use key='1' not 'Digit1', add AZERTY shift variants, integration tests - Bindings used e.code format ("Digit1") but matchers compare against e.key ("1"), so jump-to-task shortcuts never fired on any platform - Add shift-key variants for each digit so AZERTY/non-US layouts work (mirrors the existing Cmd+0 reset-zoom shift variant pattern) - Add integration tests in shortcuts.test.ts that verify the binding fires on key="1" and does not fire on key="Digit1" (regression guard) - Update defaults.test.ts expected IDs to include jump-to-task and shift variants Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(shortcuts): index Cmd+1-9 by taskOrder, move shift variants out of registry - jumpToTask now indexes store.taskOrder (left-to-right tile order in the main area), so Cmd+N matches what the user sees in the tile layout and what Cmd+Left/Right cycles through. Previously it indexed computeSidebarTaskOrder which regroups by project, causing Cmd+N to land on a different task than the visually Nth tile when projects are interleaved. This also incidentally fixes the collapsed-task surprise: taskOrder excludes collapsed tasks, so Cmd+N can no longer activate a task hidden inside a collapsed project group. - Move the AZERTY shift variants from defaults.ts to a new registerJumpToTaskShortcuts() in shortcuts.ts, mirroring the Cmd+0 reset-zoom precedent. This keeps the keybindings UI from showing 9 duplicate "Jump to task N" rows. - Add an integration test for the AZERTY shift path (key="1", shift=true) and a unit test pinning that jumpToTask ignores collapsedTaskOrder. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
1 parent ba6da2c commit 9932aa4

8 files changed

Lines changed: 287 additions & 2 deletions

File tree

src/App.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
toggleSidebar,
2525
toggleArena,
2626
moveActiveTask,
27+
jumpToTask,
2728
adjustGlobalScale,
2829
resetGlobalScale,
2930
startTaskStatusPolling,
@@ -49,7 +50,12 @@ import {
4950
} from './store/store';
5051
import { isGitHubUrl } from './lib/github-url';
5152
import type { PersistedWindowState } from './store/types';
52-
import { initShortcuts, registerFromRegistry, registerZoomShortcuts } from './lib/shortcuts';
53+
import {
54+
initShortcuts,
55+
registerFromRegistry,
56+
registerJumpToTaskShortcuts,
57+
registerZoomShortcuts,
58+
} from './lib/shortcuts';
5359
import { resolvedBindings, loadKeybindings, dismissMigrationBanner } from './store/keybindings';
5460
import { setupAutosave } from './store/autosave';
5561
import { isMac, mod } from './lib/platform';
@@ -452,6 +458,9 @@ function App() {
452458
'navigateColumn:right': () => navigateColumn('right'),
453459
'moveActiveTask:left': () => moveActiveTask('left'),
454460
'moveActiveTask:right': () => moveActiveTask('right'),
461+
...Object.fromEntries(
462+
Array.from({ length: 9 }, (_, i) => [`jumpToTask:${i + 1}`, () => jumpToTask(i)]),
463+
),
455464
closeShell: () => {
456465
const taskId = store.activeTaskId;
457466
if (!taskId) return;
@@ -516,6 +525,8 @@ function App() {
516525
resetZoom: () => resetGlobalScale(),
517526
});
518527

528+
const cleanupJumpToTaskShortcuts = registerJumpToTaskShortcuts((i) => jumpToTask(i));
529+
519530
createEffect(() => {
520531
const cleanup = registerFromRegistry(resolvedBindings(), actionHandlers);
521532
onCleanup(cleanup);
@@ -535,6 +546,7 @@ function App() {
535546
unlistenResized?.();
536547
unlistenMoved?.();
537548
cleanupZoomShortcuts();
549+
cleanupJumpToTaskShortcuts();
538550
});
539551
});
540552

src/lib/keybindings/__tests__/defaults.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const APP_LAYER_IDS = [
2424
'app.toggle-settings',
2525
'app.close-dialogs',
2626
'app.reset-zoom',
27+
...Array.from({ length: 9 }, (_, i) => `app.nav.jump-to-task-${i + 1}`),
2728
];
2829

2930
const TERMINAL_LAYER_IDS = [

src/lib/keybindings/defaults.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,25 @@ export const DEFAULT_BINDINGS: KeyBinding[] = [
7575
global: true,
7676
},
7777

78+
// -------------------------------------------------------------------------
79+
// App layer — Jump to task by position (Cmd+1 through Cmd+9)
80+
// -------------------------------------------------------------------------
81+
// Shift variants for keyboard layouts where the digit row requires Shift
82+
// (e.g. AZERTY) live in shortcuts.ts (registerJumpToTaskShortcuts), mirroring
83+
// the Cmd+0 reset-zoom pattern — keeping them out of the registry avoids
84+
// duplicating these rows in the keybindings UI.
85+
...Array.from({ length: 9 }, (_, i) => ({
86+
id: `app.nav.jump-to-task-${i + 1}`,
87+
layer: 'app' as const,
88+
category: 'Navigation',
89+
description: `Jump to task ${i + 1}`,
90+
platform: 'both' as const,
91+
key: `${i + 1}`,
92+
modifiers: { cmdOrCtrl: true },
93+
action: `jumpToTask:${i + 1}`,
94+
global: true,
95+
})),
96+
7897
// -------------------------------------------------------------------------
7998
// App layer — Task actions
8099
// -------------------------------------------------------------------------

src/lib/shortcuts.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
import { initShortcuts, registerZoomShortcuts } from './shortcuts';
3+
import { DEFAULT_BINDINGS } from './keybindings/defaults';
4+
import {
5+
initShortcuts,
6+
registerFromRegistry,
7+
registerJumpToTaskShortcuts,
8+
registerZoomShortcuts,
9+
} from './shortcuts';
410

511
type KeyboardEventStub = Pick<
612
KeyboardEvent,
@@ -14,6 +20,130 @@ type KeyboardEventStub = Pick<
1420
| 'target'
1521
>;
1622

23+
describe('registerFromRegistry — jump-to-task bindings', () => {
24+
let keydownHandler: ((event: KeyboardEvent) => void) | undefined;
25+
26+
beforeEach(() => {
27+
vi.stubGlobal('document', { querySelector: () => null });
28+
vi.stubGlobal('window', {
29+
addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => {
30+
if (type === 'keydown' && typeof handler === 'function') {
31+
keydownHandler = handler as (event: KeyboardEvent) => void;
32+
}
33+
},
34+
removeEventListener: vi.fn(),
35+
});
36+
});
37+
38+
afterEach(() => {
39+
keydownHandler = undefined;
40+
vi.unstubAllGlobals();
41+
});
42+
43+
it('fires jumpToTask:1 handler on Cmd+1 (key="1")', () => {
44+
const handler = vi.fn();
45+
const cleanupRegistry = registerFromRegistry(DEFAULT_BINDINGS, { 'jumpToTask:1': handler });
46+
const cleanupShortcuts = initShortcuts();
47+
48+
const event: Pick<
49+
KeyboardEvent,
50+
| 'key'
51+
| 'ctrlKey'
52+
| 'metaKey'
53+
| 'altKey'
54+
| 'shiftKey'
55+
| 'target'
56+
| 'preventDefault'
57+
| 'stopPropagation'
58+
> = {
59+
key: '1',
60+
ctrlKey: false,
61+
metaKey: true,
62+
altKey: false,
63+
shiftKey: false,
64+
target: null,
65+
preventDefault: vi.fn(),
66+
stopPropagation: vi.fn(),
67+
};
68+
69+
keydownHandler?.(event as KeyboardEvent);
70+
71+
expect(handler).toHaveBeenCalledTimes(1);
72+
73+
cleanupShortcuts();
74+
cleanupRegistry();
75+
});
76+
77+
it('fires jumpToTask handler on Cmd+Shift+1 via registerJumpToTaskShortcuts (AZERTY)', () => {
78+
const handler = vi.fn();
79+
const cleanupJump = registerJumpToTaskShortcuts(handler);
80+
const cleanupShortcuts = initShortcuts();
81+
82+
const event: Pick<
83+
KeyboardEvent,
84+
| 'key'
85+
| 'ctrlKey'
86+
| 'metaKey'
87+
| 'altKey'
88+
| 'shiftKey'
89+
| 'target'
90+
| 'preventDefault'
91+
| 'stopPropagation'
92+
> = {
93+
key: '1',
94+
ctrlKey: false,
95+
metaKey: true,
96+
altKey: false,
97+
shiftKey: true,
98+
target: null,
99+
preventDefault: vi.fn(),
100+
stopPropagation: vi.fn(),
101+
};
102+
103+
keydownHandler?.(event as KeyboardEvent);
104+
105+
expect(handler).toHaveBeenCalledTimes(1);
106+
expect(handler).toHaveBeenCalledWith(0);
107+
108+
cleanupShortcuts();
109+
cleanupJump();
110+
});
111+
112+
it('does NOT fire when key is "Digit1" (old broken binding format)', () => {
113+
const handler = vi.fn();
114+
const cleanupRegistry = registerFromRegistry(DEFAULT_BINDINGS, { 'jumpToTask:1': handler });
115+
const cleanupShortcuts = initShortcuts();
116+
117+
const event: Pick<
118+
KeyboardEvent,
119+
| 'key'
120+
| 'ctrlKey'
121+
| 'metaKey'
122+
| 'altKey'
123+
| 'shiftKey'
124+
| 'target'
125+
| 'preventDefault'
126+
| 'stopPropagation'
127+
> = {
128+
key: 'Digit1',
129+
ctrlKey: false,
130+
metaKey: true,
131+
altKey: false,
132+
shiftKey: false,
133+
target: null,
134+
preventDefault: vi.fn(),
135+
stopPropagation: vi.fn(),
136+
};
137+
138+
keydownHandler?.(event as KeyboardEvent);
139+
140+
expect(handler).not.toHaveBeenCalled();
141+
142+
cleanupShortcuts();
143+
cleanupRegistry();
144+
});
145+
});
146+
17147
describe('registerZoomShortcuts', () => {
18148
let keydownHandler: ((event: KeyboardEvent) => void) | undefined;
19149

src/lib/shortcuts.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ export function registerZoomShortcuts(handlers: ZoomShortcutHandlers): () => voi
9797
return () => cleanups.forEach((cleanup) => cleanup());
9898
}
9999

100+
/**
101+
* Register Shift variants of Cmd+1..9 jump-to-task shortcuts.
102+
*
103+
* The canonical bindings (Cmd+1..9 without Shift) live in the keybindings
104+
* registry so they appear in the Keyboard Shortcuts UI and are user-overridable.
105+
* The Shift variants exist only so layouts where the digit row requires Shift
106+
* (e.g. AZERTY) still work — keeping them out of the registry avoids 9 duplicate
107+
* rows in the UI, mirroring how the Cmd+0 reset-zoom shift variant is handled.
108+
*/
109+
export function registerJumpToTaskShortcuts(handler: (index: number) => void): () => void {
110+
const cleanups = Array.from({ length: 9 }, (_, i) =>
111+
registerShortcut({
112+
key: `${i + 1}`,
113+
cmdOrCtrl: true,
114+
shift: true,
115+
global: true,
116+
handler: () => handler(i),
117+
}),
118+
);
119+
return () => cleanups.forEach((cleanup) => cleanup());
120+
}
121+
100122
/** Whether a dialog overlay is currently mounted in the DOM. */
101123
function isDialogOpen(): boolean {
102124
return document.querySelector('.dialog-overlay') !== null;

src/store/navigation.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
type MockStore = {
4+
activeTaskId: string | null;
5+
activeAgentId: string | null;
6+
tasks: Record<string, { id: string; agentIds: string[] }>;
7+
terminals: Record<string, unknown>;
8+
taskOrder: string[];
9+
collapsedTaskOrder: string[];
10+
projects: Array<{ id: string }>;
11+
};
12+
13+
let mockStore: MockStore;
14+
15+
vi.mock('./core', () => ({
16+
store: new Proxy(
17+
{},
18+
{
19+
get(_target, prop) {
20+
return mockStore[prop as keyof MockStore];
21+
},
22+
},
23+
),
24+
setStore: vi.fn((...args: unknown[]) => {
25+
const key = args[0] as keyof MockStore;
26+
const value = args[1];
27+
(mockStore as Record<string, unknown>)[key] = value;
28+
}),
29+
}));
30+
31+
vi.mock('./focus', () => ({}));
32+
vi.mock('./notification', () => ({ showNotification: vi.fn() }));
33+
vi.mock('./projects', () => ({ pickAndAddProject: vi.fn() }));
34+
vi.mock('./tasks', () => ({ reorderTask: vi.fn() }));
35+
36+
import { jumpToTask } from './navigation';
37+
38+
beforeEach(() => {
39+
mockStore = {
40+
activeTaskId: null,
41+
activeAgentId: null,
42+
tasks: {
43+
'task-1': { id: 'task-1', agentIds: ['agent-a'] },
44+
'task-2': { id: 'task-2', agentIds: ['agent-b'] },
45+
'task-3': { id: 'task-3', agentIds: ['agent-c'] },
46+
},
47+
terminals: {},
48+
taskOrder: ['task-1', 'task-2', 'task-3'],
49+
collapsedTaskOrder: [],
50+
projects: [],
51+
};
52+
});
53+
54+
afterEach(() => {
55+
vi.clearAllMocks();
56+
});
57+
58+
describe('jumpToTask', () => {
59+
it('switches to the task at the given 0-based index', () => {
60+
jumpToTask(1);
61+
expect(mockStore.activeTaskId).toBe('task-2');
62+
});
63+
64+
it('switches to the first task with index 0', () => {
65+
jumpToTask(0);
66+
expect(mockStore.activeTaskId).toBe('task-1');
67+
});
68+
69+
it('switches to the last task with index matching last position', () => {
70+
jumpToTask(2);
71+
expect(mockStore.activeTaskId).toBe('task-3');
72+
});
73+
74+
it('does nothing when index is out of bounds', () => {
75+
mockStore.activeTaskId = 'task-1';
76+
jumpToTask(9);
77+
expect(mockStore.activeTaskId).toBe('task-1');
78+
});
79+
80+
it('sets activeAgentId to first agent of the target task', () => {
81+
jumpToTask(1);
82+
expect(mockStore.activeAgentId).toBe('agent-b');
83+
});
84+
85+
it('indexes taskOrder, not collapsed tasks', () => {
86+
// Collapsed tasks live in collapsedTaskOrder and must not be reachable
87+
// by index — the user can't see them, so jumping there would surprise.
88+
mockStore.taskOrder = ['task-1', 'task-2'];
89+
mockStore.collapsedTaskOrder = ['task-3'];
90+
jumpToTask(2);
91+
expect(mockStore.activeTaskId).toBe(null);
92+
});
93+
});

src/store/navigation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export function moveActiveTask(direction: 'left' | 'right'): void {
4848
setTaskFocusedPanel(activeTaskId, getTaskFocusedPanel(activeTaskId));
4949
}
5050

51+
export function jumpToTask(index: number): void {
52+
// Index against taskOrder so Cmd+N matches the left-to-right tile order
53+
// shown in the main area (and the order Cmd+Left/Right cycles through).
54+
const id = store.taskOrder[index];
55+
if (id) setActiveTask(id);
56+
}
57+
5158
export function toggleNewTaskDialog(show?: boolean): void {
5259
const shouldShow = show ?? !store.showNewTaskDialog;
5360
if (shouldShow && store.projects.length === 0) {

src/store/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export {
6161
navigateTask,
6262
navigateAgent,
6363
moveActiveTask,
64+
jumpToTask,
6465
toggleNewTaskDialog,
6566
} from './navigation';
6667
export {

0 commit comments

Comments
 (0)