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
14 changes: 13 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
toggleSidebar,
toggleArena,
moveActiveTask,
jumpToTask,
adjustGlobalScale,
resetGlobalScale,
startTaskStatusPolling,
Expand All @@ -49,7 +50,12 @@ import {
} from './store/store';
import { isGitHubUrl } from './lib/github-url';
import type { PersistedWindowState } from './store/types';
import { initShortcuts, registerFromRegistry, registerZoomShortcuts } from './lib/shortcuts';
import {
initShortcuts,
registerFromRegistry,
registerJumpToTaskShortcuts,
registerZoomShortcuts,
} from './lib/shortcuts';
import { resolvedBindings, loadKeybindings, dismissMigrationBanner } from './store/keybindings';
import { setupAutosave } from './store/autosave';
import { isMac, mod } from './lib/platform';
Expand Down Expand Up @@ -452,6 +458,9 @@ function App() {
'navigateColumn:right': () => navigateColumn('right'),
'moveActiveTask:left': () => moveActiveTask('left'),
'moveActiveTask:right': () => moveActiveTask('right'),
...Object.fromEntries(
Array.from({ length: 9 }, (_, i) => [`jumpToTask:${i + 1}`, () => jumpToTask(i)]),
),
closeShell: () => {
const taskId = store.activeTaskId;
if (!taskId) return;
Expand Down Expand Up @@ -516,6 +525,8 @@ function App() {
resetZoom: () => resetGlobalScale(),
});

const cleanupJumpToTaskShortcuts = registerJumpToTaskShortcuts((i) => jumpToTask(i));

createEffect(() => {
const cleanup = registerFromRegistry(resolvedBindings(), actionHandlers);
onCleanup(cleanup);
Expand All @@ -535,6 +546,7 @@ function App() {
unlistenResized?.();
unlistenMoved?.();
cleanupZoomShortcuts();
cleanupJumpToTaskShortcuts();
});
});

Expand Down
1 change: 1 addition & 0 deletions src/lib/keybindings/__tests__/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const APP_LAYER_IDS = [
'app.toggle-settings',
'app.close-dialogs',
'app.reset-zoom',
...Array.from({ length: 9 }, (_, i) => `app.nav.jump-to-task-${i + 1}`),
];

const TERMINAL_LAYER_IDS = [
Expand Down
19 changes: 19 additions & 0 deletions src/lib/keybindings/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ export const DEFAULT_BINDINGS: KeyBinding[] = [
global: true,
},

// -------------------------------------------------------------------------
// App layer — Jump to task by position (Cmd+1 through Cmd+9)
// -------------------------------------------------------------------------
// Shift variants for keyboard layouts where the digit row requires Shift
// (e.g. AZERTY) live in shortcuts.ts (registerJumpToTaskShortcuts), mirroring
// the Cmd+0 reset-zoom pattern — keeping them out of the registry avoids
// duplicating these rows in the keybindings UI.
...Array.from({ length: 9 }, (_, i) => ({
id: `app.nav.jump-to-task-${i + 1}`,
layer: 'app' as const,
category: 'Navigation',
description: `Jump to task ${i + 1}`,
platform: 'both' as const,
key: `${i + 1}`,
modifiers: { cmdOrCtrl: true },
action: `jumpToTask:${i + 1}`,
global: true,
})),

// -------------------------------------------------------------------------
// App layer — Task actions
// -------------------------------------------------------------------------
Expand Down
132 changes: 131 additions & 1 deletion src/lib/shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { initShortcuts, registerZoomShortcuts } from './shortcuts';
import { DEFAULT_BINDINGS } from './keybindings/defaults';
import {
initShortcuts,
registerFromRegistry,
registerJumpToTaskShortcuts,
registerZoomShortcuts,
} from './shortcuts';

type KeyboardEventStub = Pick<
KeyboardEvent,
Expand All @@ -14,6 +20,130 @@ type KeyboardEventStub = Pick<
| 'target'
>;

describe('registerFromRegistry — jump-to-task bindings', () => {
let keydownHandler: ((event: KeyboardEvent) => void) | undefined;

beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
vi.stubGlobal('window', {
addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => {
if (type === 'keydown' && typeof handler === 'function') {
keydownHandler = handler as (event: KeyboardEvent) => void;
}
},
removeEventListener: vi.fn(),
});
});

afterEach(() => {
keydownHandler = undefined;
vi.unstubAllGlobals();
});

it('fires jumpToTask:1 handler on Cmd+1 (key="1")', () => {
const handler = vi.fn();
const cleanupRegistry = registerFromRegistry(DEFAULT_BINDINGS, { 'jumpToTask:1': handler });
const cleanupShortcuts = initShortcuts();

const event: Pick<
KeyboardEvent,
| 'key'
| 'ctrlKey'
| 'metaKey'
| 'altKey'
| 'shiftKey'
| 'target'
| 'preventDefault'
| 'stopPropagation'
> = {
key: '1',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};

keydownHandler?.(event as KeyboardEvent);

expect(handler).toHaveBeenCalledTimes(1);

cleanupShortcuts();
cleanupRegistry();
});

it('fires jumpToTask handler on Cmd+Shift+1 via registerJumpToTaskShortcuts (AZERTY)', () => {
const handler = vi.fn();
const cleanupJump = registerJumpToTaskShortcuts(handler);
const cleanupShortcuts = initShortcuts();

const event: Pick<
KeyboardEvent,
| 'key'
| 'ctrlKey'
| 'metaKey'
| 'altKey'
| 'shiftKey'
| 'target'
| 'preventDefault'
| 'stopPropagation'
> = {
key: '1',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: true,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};

keydownHandler?.(event as KeyboardEvent);

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(0);

cleanupShortcuts();
cleanupJump();
});

it('does NOT fire when key is "Digit1" (old broken binding format)', () => {
const handler = vi.fn();
const cleanupRegistry = registerFromRegistry(DEFAULT_BINDINGS, { 'jumpToTask:1': handler });
const cleanupShortcuts = initShortcuts();

const event: Pick<
KeyboardEvent,
| 'key'
| 'ctrlKey'
| 'metaKey'
| 'altKey'
| 'shiftKey'
| 'target'
| 'preventDefault'
| 'stopPropagation'
> = {
key: 'Digit1',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};

keydownHandler?.(event as KeyboardEvent);

expect(handler).not.toHaveBeenCalled();

cleanupShortcuts();
cleanupRegistry();
});
});

describe('registerZoomShortcuts', () => {
let keydownHandler: ((event: KeyboardEvent) => void) | undefined;

Expand Down
22 changes: 22 additions & 0 deletions src/lib/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ export function registerZoomShortcuts(handlers: ZoomShortcutHandlers): () => voi
return () => cleanups.forEach((cleanup) => cleanup());
}

/**
* Register Shift variants of Cmd+1..9 jump-to-task shortcuts.
*
* The canonical bindings (Cmd+1..9 without Shift) live in the keybindings
* registry so they appear in the Keyboard Shortcuts UI and are user-overridable.
* The Shift variants exist only so layouts where the digit row requires Shift
* (e.g. AZERTY) still work — keeping them out of the registry avoids 9 duplicate
* rows in the UI, mirroring how the Cmd+0 reset-zoom shift variant is handled.
*/
export function registerJumpToTaskShortcuts(handler: (index: number) => void): () => void {
const cleanups = Array.from({ length: 9 }, (_, i) =>
registerShortcut({
key: `${i + 1}`,
cmdOrCtrl: true,
shift: true,
global: true,
handler: () => handler(i),
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}

/** Whether a dialog overlay is currently mounted in the DOM. */
function isDialogOpen(): boolean {
return document.querySelector('.dialog-overlay') !== null;
Expand Down
93 changes: 93 additions & 0 deletions src/store/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

type MockStore = {
activeTaskId: string | null;
activeAgentId: string | null;
tasks: Record<string, { id: string; agentIds: string[] }>;
terminals: Record<string, unknown>;
taskOrder: string[];
collapsedTaskOrder: string[];
projects: Array<{ id: string }>;
};

let mockStore: MockStore;

vi.mock('./core', () => ({
store: new Proxy(
{},
{
get(_target, prop) {
return mockStore[prop as keyof MockStore];
},
},
),
setStore: vi.fn((...args: unknown[]) => {
const key = args[0] as keyof MockStore;
const value = args[1];
(mockStore as Record<string, unknown>)[key] = value;
}),
}));

vi.mock('./focus', () => ({}));
vi.mock('./notification', () => ({ showNotification: vi.fn() }));
vi.mock('./projects', () => ({ pickAndAddProject: vi.fn() }));
vi.mock('./tasks', () => ({ reorderTask: vi.fn() }));

import { jumpToTask } from './navigation';

beforeEach(() => {
mockStore = {
activeTaskId: null,
activeAgentId: null,
tasks: {
'task-1': { id: 'task-1', agentIds: ['agent-a'] },
'task-2': { id: 'task-2', agentIds: ['agent-b'] },
'task-3': { id: 'task-3', agentIds: ['agent-c'] },
},
terminals: {},
taskOrder: ['task-1', 'task-2', 'task-3'],
collapsedTaskOrder: [],
projects: [],
};
});

afterEach(() => {
vi.clearAllMocks();
});

describe('jumpToTask', () => {
it('switches to the task at the given 0-based index', () => {
jumpToTask(1);
expect(mockStore.activeTaskId).toBe('task-2');
});

it('switches to the first task with index 0', () => {
jumpToTask(0);
expect(mockStore.activeTaskId).toBe('task-1');
});

it('switches to the last task with index matching last position', () => {
jumpToTask(2);
expect(mockStore.activeTaskId).toBe('task-3');
});

it('does nothing when index is out of bounds', () => {
mockStore.activeTaskId = 'task-1';
jumpToTask(9);
expect(mockStore.activeTaskId).toBe('task-1');
});

it('sets activeAgentId to first agent of the target task', () => {
jumpToTask(1);
expect(mockStore.activeAgentId).toBe('agent-b');
});

it('indexes taskOrder, not collapsed tasks', () => {
// Collapsed tasks live in collapsedTaskOrder and must not be reachable
// by index — the user can't see them, so jumping there would surprise.
mockStore.taskOrder = ['task-1', 'task-2'];
mockStore.collapsedTaskOrder = ['task-3'];
jumpToTask(2);
expect(mockStore.activeTaskId).toBe(null);
});
});
7 changes: 7 additions & 0 deletions src/store/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export function moveActiveTask(direction: 'left' | 'right'): void {
setTaskFocusedPanel(activeTaskId, getTaskFocusedPanel(activeTaskId));
}

export function jumpToTask(index: number): void {
// Index against taskOrder so Cmd+N matches the left-to-right tile order
// shown in the main area (and the order Cmd+Left/Right cycles through).
const id = store.taskOrder[index];
if (id) setActiveTask(id);
}

export function toggleNewTaskDialog(show?: boolean): void {
const shouldShow = show ?? !store.showNewTaskDialog;
if (shouldShow && store.projects.length === 0) {
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
navigateTask,
navigateAgent,
moveActiveTask,
jumpToTask,
toggleNewTaskDialog,
} from './navigation';
export {
Expand Down
Loading