Skip to content

Commit 8642fda

Browse files
johannesjoCopilot
andcommitted
Simplify focus navigation behavior
Remove remembered right-column focus so split-mode entry into the right column is deterministic, keep the simpler procedural grid-walking flow, and add regression coverage for the focus navigation cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0c5de26 commit 8642fda

4 files changed

Lines changed: 219 additions & 37 deletions

File tree

src/store/core.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,11 @@ export const [store, setStore] = createStore<AppStore>({
6868
keybindingMigrationDismissed: false,
6969
focusMode: false,
7070
taskSplitMode: {},
71-
lastRightColFocus: {},
7271
});
7372

7473
type CleanupPanelStore = Pick<
7574
AppStore,
76-
| 'focusedPanel'
77-
| 'panelSizes'
78-
| 'taskOrder'
79-
| 'collapsedTaskOrder'
80-
| 'taskSplitMode'
81-
| 'lastRightColFocus'
75+
'focusedPanel' | 'panelSizes' | 'taskOrder' | 'collapsedTaskOrder' | 'taskSplitMode'
8276
>;
8377

8478
/** Remove panelSizes, focusedPanel, and taskOrder entries for a given ID.
@@ -87,7 +81,6 @@ export function cleanupPanelEntries(s: CleanupPanelStore, id: string): number {
8781
const idx = s.taskOrder.indexOf(id);
8882
delete s.focusedPanel[id];
8983
delete s.taskSplitMode[id];
90-
delete s.lastRightColFocus[id];
9184
const prefix = id + ':';
9285
for (const key of Object.keys(s.panelSizes)) {
9386
if (key === id || key.startsWith(prefix)) delete s.panelSizes[key];

src/store/focus.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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, MockTask>;
7+
terminals: Record<string, Record<string, unknown>>;
8+
taskOrder: string[];
9+
projects: Array<{ id: string; terminalBookmarks?: Array<{ id: string; command: string }> }>;
10+
focusedPanel: Record<string, string>;
11+
sidebarFocused: boolean;
12+
sidebarFocusedProjectId: string | null;
13+
sidebarFocusedTaskId: string | null;
14+
placeholderFocused: boolean;
15+
placeholderFocusedButton: 'add-task' | 'add-terminal';
16+
showNewTaskDialog: boolean;
17+
showHelpDialog: boolean;
18+
showSettingsDialog: boolean;
19+
showPromptInput: boolean;
20+
sidebarVisible: boolean;
21+
taskSplitMode: Record<string, boolean>;
22+
};
23+
24+
type MockTask = {
25+
id: string;
26+
name: string;
27+
projectId: string;
28+
agentIds: string[];
29+
shellAgentIds: string[];
30+
stepsEnabled: boolean;
31+
stepsContent: Array<{ id: string }>;
32+
collapsed?: boolean;
33+
[key: string]: unknown;
34+
};
35+
36+
let mockStore: MockStore;
37+
38+
function setStorePath(...args: unknown[]): void {
39+
const value = args[args.length - 1];
40+
let target: Record<string, unknown> = mockStore as unknown as Record<string, unknown>;
41+
for (let i = 0; i < args.length - 2; i++) {
42+
const key = args[i] as string;
43+
const next = target[key] as Record<string, unknown> | undefined;
44+
if (!next || typeof next !== 'object') {
45+
target[key] = {};
46+
}
47+
target = target[key] as Record<string, unknown>;
48+
}
49+
target[args[args.length - 2] as string] = value;
50+
}
51+
52+
vi.mock('solid-js', () => ({
53+
batch: (fn: () => void) => fn(),
54+
}));
55+
56+
vi.mock('./core', () => ({
57+
store: new Proxy(
58+
{},
59+
{
60+
get(_target, prop) {
61+
return mockStore[prop as keyof MockStore];
62+
},
63+
},
64+
),
65+
setStore: vi.fn((...args: unknown[]) => setStorePath(...args)),
66+
}));
67+
68+
vi.mock('./navigation', () => ({
69+
setActiveTask: vi.fn((id: string) => {
70+
mockStore.activeTaskId = id;
71+
mockStore.activeAgentId = mockStore.tasks[id]?.agentIds?.[0] ?? null;
72+
}),
73+
}));
74+
75+
vi.mock('./sidebar-order', () => ({
76+
computeSidebarTaskOrder: vi.fn(() => mockStore.taskOrder),
77+
}));
78+
79+
vi.mock('./tasks', () => ({
80+
uncollapseTask: vi.fn((id: string) => {
81+
if (mockStore.tasks[id]) mockStore.tasks[id].collapsed = false;
82+
}),
83+
}));
84+
85+
import { navigateColumn, navigateRow } from './focus';
86+
87+
function setTask(id: string, overrides: Record<string, unknown> = {}): void {
88+
mockStore.tasks[id] = {
89+
id,
90+
name: id,
91+
projectId: 'project-1',
92+
agentIds: ['agent-1'],
93+
shellAgentIds: [],
94+
stepsEnabled: false,
95+
stepsContent: [],
96+
...overrides,
97+
};
98+
}
99+
100+
beforeEach(() => {
101+
mockStore = {
102+
activeTaskId: 'task-1',
103+
activeAgentId: 'agent-1',
104+
tasks: {},
105+
terminals: {},
106+
taskOrder: ['task-1'],
107+
projects: [{ id: 'project-1', terminalBookmarks: [] }],
108+
focusedPanel: {},
109+
sidebarFocused: false,
110+
sidebarFocusedProjectId: null,
111+
sidebarFocusedTaskId: null,
112+
placeholderFocused: false,
113+
placeholderFocusedButton: 'add-task',
114+
showNewTaskDialog: false,
115+
showHelpDialog: false,
116+
showSettingsDialog: false,
117+
showPromptInput: true,
118+
sidebarVisible: true,
119+
taskSplitMode: {},
120+
};
121+
122+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
123+
cb(0);
124+
return 0;
125+
});
126+
vi.stubGlobal('document', { querySelector: () => null });
127+
vi.stubGlobal('CSS', { escape: (value: string) => value });
128+
});
129+
130+
afterEach(() => {
131+
vi.unstubAllGlobals();
132+
});
133+
134+
describe('focus navigation neighbor map', () => {
135+
it('moves down through stacked layout using explicit neighbors', () => {
136+
setTask('task-1');
137+
mockStore.focusedPanel['task-1'] = 'notes';
138+
139+
navigateRow('down');
140+
141+
expect(mockStore.focusedPanel['task-1']).toBe('shell-toolbar:0');
142+
});
143+
144+
it('always enters the top-right panel when moving right from ai-terminal in split mode', () => {
145+
setTask('task-1', {
146+
stepsEnabled: true,
147+
stepsContent: [{ id: 'step-1' }],
148+
});
149+
mockStore.taskSplitMode['task-1'] = true;
150+
mockStore.focusedPanel['task-1'] = 'ai-terminal';
151+
152+
navigateColumn('right');
153+
154+
expect(mockStore.focusedPanel['task-1']).toBe('changed-files');
155+
});
156+
157+
it('falls into the top-right panel when split-mode ai-terminal has no lower left neighbor', () => {
158+
setTask('task-1', {
159+
shellAgentIds: ['shell-1'],
160+
});
161+
mockStore.taskSplitMode['task-1'] = true;
162+
mockStore.showPromptInput = false;
163+
mockStore.focusedPanel['task-1'] = 'ai-terminal';
164+
165+
navigateRow('down');
166+
167+
expect(mockStore.focusedPanel['task-1']).toBe('changed-files');
168+
});
169+
170+
it('falls back from vanished focused panels before navigating', () => {
171+
setTask('task-1', {
172+
stepsEnabled: false,
173+
stepsContent: [],
174+
});
175+
mockStore.focusedPanel['task-1'] = 'steps';
176+
177+
navigateRow('down');
178+
179+
expect(mockStore.focusedPanel['task-1']).toBe('prompt');
180+
});
181+
182+
it('preserves cross-task row alignment when exiting to the right', () => {
183+
setTask('task-1');
184+
setTask('task-2');
185+
mockStore.taskOrder = ['task-1', 'task-2'];
186+
mockStore.focusedPanel['task-1'] = 'changed-files';
187+
188+
navigateColumn('right');
189+
190+
expect(mockStore.activeTaskId).toBe('task-2');
191+
expect(mockStore.focusedPanel['task-2']).toBe('notes');
192+
});
193+
194+
it('clamps split shell-toolbar down-moves to the last available shell', () => {
195+
setTask('task-1', {
196+
shellAgentIds: ['shell-1'],
197+
});
198+
mockStore.projects[0].terminalBookmarks = [
199+
{ id: 'bookmark-1', command: 'npm test' },
200+
{ id: 'bookmark-2', command: 'npm run lint' },
201+
];
202+
mockStore.taskSplitMode['task-1'] = true;
203+
mockStore.focusedPanel['task-1'] = 'shell-toolbar:2';
204+
205+
navigateRow('down');
206+
207+
expect(mockStore.focusedPanel['task-1']).toBe('shell:0');
208+
});
209+
});

src/store/focus.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,14 @@ export function triggerAction(key: string): void {
3232
actionRegistry.get(key)?.();
3333
}
3434

35-
// Grid-based spatial navigation. Two layouts:
35+
// Grid-based spatial navigation. Two task layouts:
3636
// - vertical stack (default): everything in one column
3737
// - split (focus mode, panel wide enough): ai-terminal/prompt anchor the left,
3838
// changed-files/notes/steps/shell anchor the right, and `ai-terminal` is
39-
// repeated down col 0 so ←/→ cross cleanly into the right column.
40-
// navigateRow skip-repeats past those duplicates; row-aware → uses
41-
// `lastRightColFocus` so bouncing ←→ over ai-terminal returns to the origin.
39+
// repeated down col 0 so left/right crossings into the right column stay
40+
// consistent.
4241

43-
/** Cells that belong to the left column in split mode. Anything else is treated
44-
* as right-column for `lastRightColFocus` memory and the dead-end fallback. */
42+
/** Cells that belong to the left column in split mode. */
4543
const LEFT_COL_PANELS = new Set(['title', 'ai-terminal', 'prompt', 'terminal']);
4644

4745
function buildGrid(panelId: string): string[][] {
@@ -93,12 +91,8 @@ function buildGrid(panelId: string): string[][] {
9391
return [['title'], ['terminal']];
9492
}
9593

96-
/** In split mode, pick the right-column cell to jump to when → from the left.
97-
* Prefers the user's last right-column position (so ← then → round-trips),
98-
* falls back to the top of the right column (changed-files / shell row). */
99-
function pickRightColumnTarget(taskId: string, grid: string[][]): string | null {
100-
const remembered = store.lastRightColFocus[taskId];
101-
if (remembered && findInGrid(grid, remembered)) return remembered;
94+
/** In split mode, find the first focusable panel in the right column. */
95+
function pickTopRightColumnTarget(grid: string[][]): string | null {
10296
for (const row of grid) {
10397
for (let c = 1; c < row.length; c++) {
10498
const cell = row[c];
@@ -134,11 +128,6 @@ export function setTaskFocusedPanel(taskId: string, panel: string): void {
134128
setStore('focusedPanel', taskId, panel);
135129
setStore('sidebarFocused', false);
136130
setStore('placeholderFocused', false);
137-
// Remember right-column visits so → from ai-terminal can return to where the
138-
// user was instead of always jumping to the top of the right column.
139-
if (!LEFT_COL_PANELS.has(panel)) {
140-
setStore('lastRightColFocus', taskId, panel);
141-
}
142131
triggerFocus(`${taskId}:${panel}`);
143132
scrollTaskIntoView(taskId);
144133
}
@@ -275,15 +264,14 @@ export function navigateRow(direction: 'up' | 'down'): void {
275264
}
276265

277266
// Dead-end: in split mode, ↓ from ai-terminal when no prompt/shells anchor
278-
// the left column's bottom would otherwise stop — enter the right column
279-
// (the ← from any right-col cell will come back to ai-terminal).
267+
// the left column's bottom would otherwise stop — enter the top-right panel.
280268
if (
281269
direction === 'down' &&
282270
store.taskSplitMode[taskId] &&
283271
pos.col === 0 &&
284272
current === 'ai-terminal'
285273
) {
286-
const target = pickRightColumnTarget(taskId, grid);
274+
const target = pickTopRightColumnTarget(grid);
287275
if (target) setTaskFocusedPanel(taskId, target);
288276
}
289277
}
@@ -338,16 +326,13 @@ export function navigateColumn(direction: 'left' | 'right'): void {
338326
setTaskFocusedPanel(taskId, fallback);
339327
}
340328

341-
// In split mode, → from ai-terminal is row-aware: go to the last right-column
342-
// cell the user visited, not the first match in findInGrid (which always
343-
// returned `changed-files` at row 1 regardless of where they came from).
344329
if (
345330
direction === 'right' &&
346331
pos.col === 0 &&
347332
current === 'ai-terminal' &&
348333
store.taskSplitMode[taskId]
349334
) {
350-
const target = pickRightColumnTarget(taskId, grid);
335+
const target = pickTopRightColumnTarget(grid);
351336
if (target) {
352337
setTaskFocusedPanel(taskId, target);
353338
return;
@@ -356,8 +341,6 @@ export function navigateColumn(direction: 'left' | 'right'): void {
356341

357342
const row = grid[pos.row];
358343
const nextCol = direction === 'left' ? pos.col - 1 : pos.col + 1;
359-
360-
// Within-row movement
361344
if (nextCol >= 0 && nextCol < row.length) {
362345
setTaskFocusedPanel(taskId, row[nextCol]);
363346
return;

src/store/types.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,4 @@ export interface AppStore {
230230
focusMode: boolean;
231231
/** Per-task flag: true when the task is rendering its focus-mode two-column layout. */
232232
taskSplitMode: Record<string, boolean>;
233-
/** Per-task memory of the last right-column cell focused, so crossing ai-terminal and back
234-
* with the arrow keys returns to where the user was instead of always jumping to `changed-files`. */
235-
lastRightColFocus: Record<string, string>;
236233
}

0 commit comments

Comments
 (0)