Skip to content

Commit 0c93d63

Browse files
committed
Fix terminal row focus navigation
1 parent f3dcb9c commit 0c93d63

2 files changed

Lines changed: 104 additions & 3 deletions

File tree

src/store/focus.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,55 @@ describe('focus navigation neighbor map', () => {
301301
expect(mockStore.activeAgentId).toBe('agent-2b');
302302
});
303303

304+
it('stays on the AI terminal row when crossing into a task with shell terminals', () => {
305+
setTask('task-1', { agentIds: ['agent-1a', 'agent-1b'] });
306+
setTask('task-2', {
307+
agentIds: ['agent-2a', 'agent-2b'],
308+
selectedAgentId: 'agent-2b',
309+
shellAgentIds: ['shell-2'],
310+
});
311+
mockStore.taskOrder = ['task-1', 'task-2'];
312+
mockStore.focusedPanel['task-1'] = 'ai-terminal:agent-1b';
313+
314+
navigateColumn('right');
315+
316+
expect(mockStore.activeTaskId).toBe('task-2');
317+
expect(mockStore.focusedPanel['task-2']).toBe('ai-terminal:agent-2b');
318+
expect(mockStore.activeAgentId).toBe('agent-2b');
319+
});
320+
321+
it('stays on the AI terminal row when crossing left into a task with shell terminals', () => {
322+
setTask('task-1', {
323+
agentIds: ['agent-1a', 'agent-1b'],
324+
selectedAgentId: 'agent-1b',
325+
shellAgentIds: ['shell-1'],
326+
});
327+
setTask('task-2', { agentIds: ['agent-2a', 'agent-2b'] });
328+
mockStore.taskOrder = ['task-1', 'task-2'];
329+
mockStore.activeTaskId = 'task-2';
330+
mockStore.activeAgentId = 'agent-2a';
331+
mockStore.focusedPanel['task-2'] = 'ai-terminal:agent-2a';
332+
333+
navigateColumn('left');
334+
335+
expect(mockStore.activeTaskId).toBe('task-1');
336+
expect(mockStore.focusedPanel['task-1']).toBe('ai-terminal:agent-1b');
337+
expect(mockStore.activeAgentId).toBe('agent-1b');
338+
});
339+
340+
it('crosses from a shell terminal to the target shell toolbar when no shell is open', () => {
341+
setTask('task-1', { shellAgentIds: ['shell-1'] });
342+
setTask('task-2', { agentIds: ['agent-2a', 'agent-2b'] });
343+
mockStore.taskOrder = ['task-1', 'task-2'];
344+
mockStore.focusedPanel['task-1'] = 'shell:0';
345+
346+
navigateColumn('right');
347+
348+
expect(mockStore.activeTaskId).toBe('task-2');
349+
expect(mockStore.focusedPanel['task-2']).toBe('shell-toolbar:0');
350+
expect(mockStore.activeAgentId).toBe('agent-2a');
351+
});
352+
304353
it('moves down from a secondary split-mode AI terminal to the prompt', () => {
305354
setTask('task-1', { agentIds: ['agent-1', 'agent-2'] });
306355
mockStore.taskSplitMode['task-1'] = true;

src/store/focus.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export function triggerAction(key: string): void {
3939
// repeated down the left columns so left/right crossings stay consistent.
4040

4141
const AI_TERMINAL_PANEL = 'ai-terminal';
42+
const SHELL_PANEL_PREFIX = 'shell:';
43+
const SHELL_TOOLBAR_PANEL_PREFIX = 'shell-toolbar:';
4244

4345
function aiTerminalPanelId(agentId: string): string {
4446
return `${AI_TERMINAL_PANEL}:${agentId}`;
@@ -58,6 +60,48 @@ function aiTerminalPanels(task: { agentIds: string[] }): string[] {
5860
return task.agentIds.length > 0 ? task.agentIds.map(aiTerminalPanelId) : [AI_TERMINAL_PANEL];
5961
}
6062

63+
function shellToolbarPanels(task: { projectId: string }): string[] {
64+
const bookmarkCount =
65+
store.projects.find((p) => p.id === task.projectId)?.terminalBookmarks?.length ?? 0;
66+
return Array.from({ length: 1 + bookmarkCount }, (_, i) => `${SHELL_TOOLBAR_PANEL_PREFIX}${i}`);
67+
}
68+
69+
function isShellPanel(panel: string): boolean {
70+
return panel.startsWith(SHELL_PANEL_PREFIX);
71+
}
72+
73+
function isShellToolbarPanel(panel: string): boolean {
74+
return panel.startsWith(SHELL_TOOLBAR_PANEL_PREFIX);
75+
}
76+
77+
function edgeIndex(count: number, entryEdge: 'left' | 'right'): number {
78+
return entryEdge === 'right' ? 0 : count - 1;
79+
}
80+
81+
function pickTargetTerminalFamilyPanel(
82+
current: string,
83+
targetTask: { shellAgentIds: string[]; projectId: string },
84+
entryEdge: 'left' | 'right',
85+
): string | null {
86+
if (isAiTerminalPanel(current)) return AI_TERMINAL_PANEL;
87+
88+
if (isShellPanel(current)) {
89+
if (targetTask.shellAgentIds.length > 0) {
90+
return `${SHELL_PANEL_PREFIX}${edgeIndex(targetTask.shellAgentIds.length, entryEdge)}`;
91+
}
92+
93+
const toolbarPanels = shellToolbarPanels(targetTask);
94+
return toolbarPanels[edgeIndex(toolbarPanels.length, entryEdge)] ?? null;
95+
}
96+
97+
if (isShellToolbarPanel(current)) {
98+
const toolbarPanels = shellToolbarPanels(targetTask);
99+
return toolbarPanels[edgeIndex(toolbarPanels.length, entryEdge)] ?? null;
100+
}
101+
102+
return null;
103+
}
104+
61105
function normalizeTaskPanel(taskId: string, panel: string): string {
62106
if (panel !== AI_TERMINAL_PANEL) return panel;
63107
const task = store.tasks[taskId];
@@ -78,9 +122,7 @@ function isLeftColumnPanel(panel: string): boolean {
78122
function buildGrid(panelId: string): string[][] {
79123
const task = store.tasks[panelId];
80124
if (task) {
81-
const bookmarkCount =
82-
store.projects.find((p) => p.id === task.projectId)?.terminalBookmarks?.length ?? 0;
83-
const toolbarCols = Array.from({ length: 1 + bookmarkCount }, (_, i) => `shell-toolbar:${i}`);
125+
const toolbarCols = shellToolbarPanels(task);
84126
const aiCols = aiTerminalPanels(task);
85127

86128
if (store.taskSplitMode[panelId]) {
@@ -433,6 +475,16 @@ export function navigateColumn(direction: 'left' | 'right'): void {
433475
} else if (!store.tasks[targetId]) {
434476
focusTaskPanel(targetId, defaultPanelFor(targetId));
435477
} else {
478+
const semanticTarget = pickTargetTerminalFamilyPanel(
479+
current,
480+
store.tasks[targetId],
481+
entryEdge,
482+
);
483+
if (semanticTarget) {
484+
focusTaskPanel(targetId, semanticTarget);
485+
return;
486+
}
487+
436488
const targetGrid = buildGrid(targetId);
437489
const targetPos = findInGrid(targetGrid, current);
438490
const targetRow = targetPos ? targetPos.row : pos.row;

0 commit comments

Comments
 (0)