Skip to content

Commit 04d2db1

Browse files
committed
feat(keybindings): add task switch shortcut preserving panel focus
Cmd+Opt+Left/Right (macOS) and Ctrl+PageUp/PageDown (Linux) jump to the prev/next task while keeping the focused panel (Chat / Notes / Changed Files) the same. Falls back to the default panel when the target task lacks the current panel. Replaces the dead, mis-semantic `navigateTask` stub in navigation.ts with a grid-aware version in focus.ts that can validate panel identity against the target task's layout.
1 parent af685eb commit 04d2db1

6 files changed

Lines changed: 173 additions & 11 deletions

File tree

src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
stopTaskStatusPolling,
3232
navigateRow,
3333
navigateColumn,
34+
navigateTask,
3435
setPendingAction,
3536
toggleHelpDialog,
3637
toggleSettingsDialog,
@@ -456,6 +457,8 @@ function App() {
456457
'navigateRow:down': () => navigateRow('down'),
457458
'navigateColumn:left': () => navigateColumn('left'),
458459
'navigateColumn:right': () => navigateColumn('right'),
460+
'navigateTask:left': () => navigateTask('left'),
461+
'navigateTask:right': () => navigateTask('right'),
459462
'moveActiveTask:left': () => moveActiveTask('left'),
460463
'moveActiveTask:right': () => moveActiveTask('right'),
461464
...Object.fromEntries(

src/lib/keybindings/defaults.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,53 @@ export const DEFAULT_BINDINGS: KeyBinding[] = [
4848
action: 'navigateColumn:right',
4949
global: true,
5050
},
51+
// Cmd+Opt+Arrow on macOS (Chrome-style tab switching). On Linux, Ctrl+Alt+Arrow
52+
// is captured by GNOME/KDE for workspace switching, so use Ctrl+PageUp/PageDown
53+
// instead — same shortcut Chrome and Firefox use for tabs on Linux.
54+
{
55+
id: 'app.nav.task-left',
56+
layer: 'app',
57+
category: 'Navigation',
58+
description: 'Switch to previous task',
59+
platform: 'mac',
60+
key: 'ArrowLeft',
61+
modifiers: { cmdOrCtrl: true, alt: true },
62+
action: 'navigateTask:left',
63+
global: true,
64+
},
65+
{
66+
id: 'app.nav.task-right',
67+
layer: 'app',
68+
category: 'Navigation',
69+
description: 'Switch to next task',
70+
platform: 'mac',
71+
key: 'ArrowRight',
72+
modifiers: { cmdOrCtrl: true, alt: true },
73+
action: 'navigateTask:right',
74+
global: true,
75+
},
76+
{
77+
id: 'app.nav.task-left-linux',
78+
layer: 'app',
79+
category: 'Navigation',
80+
description: 'Switch to previous task',
81+
platform: 'linux',
82+
key: 'PageUp',
83+
modifiers: { cmdOrCtrl: true },
84+
action: 'navigateTask:left',
85+
global: true,
86+
},
87+
{
88+
id: 'app.nav.task-right-linux',
89+
layer: 'app',
90+
category: 'Navigation',
91+
description: 'Switch to next task',
92+
platform: 'linux',
93+
key: 'PageDown',
94+
modifiers: { cmdOrCtrl: true },
95+
action: 'navigateTask:right',
96+
global: true,
97+
},
5198

5299
// -------------------------------------------------------------------------
53100
// App layer — Task reordering

src/store/focus.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ vi.mock('./tasks', () => ({
8282
}),
8383
}));
8484

85-
import { navigateColumn, navigateRow } from './focus';
85+
import { navigateColumn, navigateRow, navigateTask } from './focus';
8686

8787
function setTask(id: string, overrides: Record<string, unknown> = {}): void {
8888
mockStore.tasks[id] = {
@@ -223,3 +223,92 @@ describe('focus navigation neighbor map', () => {
223223
expect(mockStore.focusedPanel['task-1']).toBe('shell:0');
224224
});
225225
});
226+
227+
describe('navigateTask', () => {
228+
it('preserves the focused panel name when switching to the next task', () => {
229+
setTask('task-1');
230+
setTask('task-2');
231+
mockStore.taskOrder = ['task-1', 'task-2'];
232+
mockStore.focusedPanel['task-1'] = 'changed-files';
233+
234+
navigateTask('right');
235+
236+
expect(mockStore.activeTaskId).toBe('task-2');
237+
expect(mockStore.focusedPanel['task-2']).toBe('changed-files');
238+
});
239+
240+
it('preserves the focused panel name when switching to the previous task', () => {
241+
setTask('task-1');
242+
setTask('task-2');
243+
mockStore.taskOrder = ['task-1', 'task-2'];
244+
mockStore.activeTaskId = 'task-2';
245+
mockStore.focusedPanel['task-2'] = 'notes';
246+
247+
navigateTask('left');
248+
249+
expect(mockStore.activeTaskId).toBe('task-1');
250+
expect(mockStore.focusedPanel['task-1']).toBe('notes');
251+
});
252+
253+
it('falls back to the default panel when the current panel does not exist in the target', () => {
254+
setTask('task-1', { stepsEnabled: true, stepsContent: [{ id: 'step-1' }] });
255+
setTask('task-2');
256+
mockStore.taskOrder = ['task-1', 'task-2'];
257+
mockStore.focusedPanel['task-1'] = 'steps';
258+
259+
navigateTask('right');
260+
261+
expect(mockStore.activeTaskId).toBe('task-2');
262+
expect(mockStore.focusedPanel['task-2']).toBe('ai-terminal');
263+
});
264+
265+
it('is a no-op at the leftmost task', () => {
266+
setTask('task-1');
267+
setTask('task-2');
268+
mockStore.taskOrder = ['task-1', 'task-2'];
269+
mockStore.activeTaskId = 'task-1';
270+
mockStore.focusedPanel['task-1'] = 'changed-files';
271+
272+
navigateTask('left');
273+
274+
expect(mockStore.activeTaskId).toBe('task-1');
275+
expect(mockStore.focusedPanel['task-1']).toBe('changed-files');
276+
expect(mockStore.sidebarFocused).toBe(false);
277+
});
278+
279+
it('is a no-op at the rightmost task', () => {
280+
setTask('task-1');
281+
setTask('task-2');
282+
mockStore.taskOrder = ['task-1', 'task-2'];
283+
mockStore.activeTaskId = 'task-2';
284+
mockStore.focusedPanel['task-2'] = 'notes';
285+
286+
navigateTask('right');
287+
288+
expect(mockStore.activeTaskId).toBe('task-2');
289+
expect(mockStore.focusedPanel['task-2']).toBe('notes');
290+
expect(mockStore.placeholderFocused).toBe(false);
291+
});
292+
293+
it('is a no-op when the active id is not in taskOrder (e.g. terminal)', () => {
294+
setTask('task-1');
295+
mockStore.taskOrder = ['task-1'];
296+
mockStore.activeTaskId = 'terminal-1';
297+
298+
navigateTask('right');
299+
300+
expect(mockStore.activeTaskId).toBe('terminal-1');
301+
});
302+
303+
it('is a no-op while a dialog is open', () => {
304+
setTask('task-1');
305+
setTask('task-2');
306+
mockStore.taskOrder = ['task-1', 'task-2'];
307+
mockStore.focusedPanel['task-1'] = 'changed-files';
308+
mockStore.showHelpDialog = true;
309+
310+
navigateTask('right');
311+
312+
expect(mockStore.activeTaskId).toBe('task-1');
313+
});
314+
});

src/store/focus.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,38 @@ export function navigateColumn(direction: 'left' | 'right'): void {
401401
}
402402
}
403403

404+
/**
405+
* Switch directly to the prev/next task in `taskOrder`, preserving the focused
406+
* panel name when it exists in the target's grid. Clamps at edges (no sidebar
407+
* or placeholder fall-through). Collapsed tasks are not in `taskOrder`, so
408+
* they are skipped — same semantics as `navigateColumn`'s cross-task path.
409+
*/
410+
export function navigateTask(direction: 'left' | 'right'): void {
411+
if (store.showNewTaskDialog || store.showHelpDialog || store.showSettingsDialog) return;
412+
413+
const { taskOrder, activeTaskId } = store;
414+
if (!activeTaskId) return;
415+
416+
const currentIdx = taskOrder.indexOf(activeTaskId);
417+
if (currentIdx === -1) return;
418+
419+
const targetIdx = direction === 'left' ? currentIdx - 1 : currentIdx + 1;
420+
if (targetIdx < 0 || targetIdx >= taskOrder.length) return;
421+
422+
const targetId = taskOrder[targetIdx];
423+
if (!store.tasks[targetId]) return;
424+
425+
const currentPanel = getTaskFocusedPanel(activeTaskId);
426+
const targetGrid = buildGrid(targetId);
427+
const targetPanel =
428+
findInGrid(targetGrid, currentPanel) !== null ? currentPanel : defaultPanelFor(targetId);
429+
430+
batch(() => {
431+
setActiveTask(targetId);
432+
setTaskFocusedPanel(targetId, targetPanel);
433+
});
434+
}
435+
404436
export function setPendingAction(
405437
action: { type: 'close' | 'merge' | 'push'; taskId: string } | null,
406438
): void {

src/store/navigation.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,6 @@ export function setActiveAgent(agentId: string): void {
1616
setStore('activeAgentId', agentId);
1717
}
1818

19-
export function navigateTask(direction: 'left' | 'right'): void {
20-
const { taskOrder, activeTaskId } = store;
21-
if (taskOrder.length === 0) return;
22-
const idx = activeTaskId ? taskOrder.indexOf(activeTaskId) : -1;
23-
const next =
24-
direction === 'left' ? Math.max(0, idx - 1) : Math.min(taskOrder.length - 1, idx + 1);
25-
setActiveTask(taskOrder[next]);
26-
}
27-
2819
export function navigateAgent(direction: 'up' | 'down'): void {
2920
const { activeTaskId, activeAgentId } = store;
3021
if (!activeTaskId) return;

src/store/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export {
5858
export {
5959
setActiveTask,
6060
setActiveAgent,
61-
navigateTask,
6261
navigateAgent,
6362
moveActiveTask,
6463
jumpToTask,
@@ -80,6 +79,7 @@ export {
8079
unfocusPlaceholder,
8180
navigateRow,
8281
navigateColumn,
82+
navigateTask,
8383
setPendingAction,
8484
clearPendingAction,
8585
toggleHelpDialog,

0 commit comments

Comments
 (0)