Skip to content
Open
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
9 changes: 7 additions & 2 deletions src/components/FocusModeTaskIndicators.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { For, Show } from 'solid-js';
import { getTaskAttentionState, getTaskDotStatus, setActiveTask, store } from '../store/store';
import {
getTaskAttentionState,
getTaskDotStatus,
activateTaskFromPointer,
store,
} from '../store/store';
import { openPanelOrder } from '../store/navigation';
import { documentAgentTaskId } from '../documents/task-id';
import { StatusDot } from './StatusDot';
Expand Down Expand Up @@ -32,7 +37,7 @@ export function FocusModeTaskIndicators() {
type="button"
class={`focus-mode-task-indicator${isActive() ? ' active' : ''}`}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => setActiveTask(item.id)}
onClick={() => activateTaskFromPointer(item.id)}
title={isActive() ? `${item.name} (current)` : `Switch to ${item.name}`}
aria-label={isActive() ? `${item.name}, current item` : `Switch to ${item.name}`}
aria-current={isActive() ? 'true' : undefined}
Expand Down
4 changes: 2 additions & 2 deletions src/components/SubTaskStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { For, Show, createMemo, createSignal, createUniqueId, onMount } from 'solid-js';
import { store, setActiveTask, getTaskDotStatus, uncollapseTask } from '../store/store';
import { store, activateTaskFromPointer, getTaskDotStatus, uncollapseTask } from '../store/store';
import { getCoordinatorChildren } from '../store/sidebar-order';
import { invoke } from '../lib/ipc';
import { IPC } from '../../electron/ipc/channels';
Expand Down Expand Up @@ -189,7 +189,7 @@ export function SubTaskStrip(props: SubTaskStripProps) {
if (task.collapsed) {
uncollapseTask(task.id);
}
setActiveTask(task.id);
activateTaskFromPointer(task.id);
}}
title={taskTone(task) ? `${task.name} — ${taskTone(task)?.label}` : task.name}
style={{
Expand Down
1 change: 1 addition & 0 deletions src/components/TaskPanel.reasoning.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock('../store/store', () => {
setTaskFocusedPanel: vi.fn(),
triggerFocus: vi.fn(),
setActiveTask: (id: string) => setStore('activeTaskId', id),
activateTaskFromPointer: (id: string) => setStore('activeTaskId', id),
toggleFocusMode: (on?: boolean) => setStore('focusMode', on ?? !store.focusMode),
};
});
Expand Down
4 changes: 2 additions & 2 deletions src/components/TaskPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Show, createSignal, createEffect, createMemo, onMount, onCleanup, batch
import {
store,
retryCloseTask,
setActiveTask,
activateTaskFromPointer,
setActiveAgent,
clearInitialPrompt,
clearPrefillPrompt,
Expand Down Expand Up @@ -676,7 +676,7 @@ export function TaskPanel(props: TaskPanelProps) {
position: 'relative',
}}
onClick={() => {
setActiveTask(props.task.id);
activateTaskFromPointer(props.task.id);
}}
>
<TaskClosingOverlay
Expand Down
4 changes: 2 additions & 2 deletions src/components/TaskTitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Show } from 'solid-js';
import {
store,
reorderTask,
setActiveTask,
activateTaskFromPointer,
updateTaskName,
collapseTask,
getTaskDotStatus,
Expand Down Expand Up @@ -158,7 +158,7 @@ export function TaskTitleBar(props: TaskTitleBarProps) {
itemId: props.task.id,
getTaskOrder: () => store.taskOrder,
onReorder: reorderTask,
onTap: () => setActiveTask(props.task.id),
onTap: () => activateTaskFromPointer(props.task.id),
});
}

Expand Down
6 changes: 3 additions & 3 deletions src/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
store,
closeTerminal,
updateTerminalName,
setActiveTask,
activateTaskFromPointer,
reorderTask,
registerFocusFn,
unregisterFocusFn,
Expand Down Expand Up @@ -51,7 +51,7 @@ export function TerminalPanel(props: TerminalPanelProps) {
itemId: props.terminal.id,
getTaskOrder: () => store.taskOrder,
onReorder: reorderTask,
onTap: () => setActiveTask(props.terminal.id),
onTap: () => activateTaskFromPointer(props.terminal.id),
});
}

Expand All @@ -69,7 +69,7 @@ export function TerminalPanel(props: TerminalPanelProps) {
overflow: 'clip',
position: 'relative',
}}
onClick={() => setActiveTask(props.terminal.id)}
onClick={() => activateTaskFromPointer(props.terminal.id)}
>
{/* Title bar */}
<div
Expand Down
173 changes: 173 additions & 0 deletions src/store/navigation.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Where DOM focus goes when a column is clicked after moving through the sidebar.
// Real store, focus registry, sidebar navigation and title-bar drag handler; the
// column is reduced to a title, a terminal and an input, with a copy of
// TaskPanel's "respond to focus panel changes" effect (TerminalPanel's version
// only adds a 'terminal' default).
import { createEffect, For } from 'solid-js';
import { render } from 'solid-js/web';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { store, setStore } from './core';
import { activateTaskFromPointer, setActiveTask } from './navigation';
import { registerFocusFn, scheduleTaskFocus, unregisterFocusFn } from './focused-panel';
import { focusSidebar, navigateRow } from './focus';
import { handleDragReorder } from '../lib/dragReorder';

vi.mock('../lib/ipc', () => ({ invoke: vi.fn() }));
vi.mock('./persistence', () => ({ saveState: vi.fn() }));

const ids = ['task-a', 'task-b'];
const panel = (id: string) => `ai-terminal:${id}-agent`;
let dispose: (() => void) | undefined;
let sidebarEl!: HTMLDivElement;
const terminals: Record<string, HTMLTextAreaElement> = {};

function mount(activate: (id: string) => void) {
const root = document.createElement('div');
document.body.appendChild(root);
dispose = render(() => {
createEffect(() => {
if (store.sidebarFocused) sidebarEl.focus();
});
registerFocusFn('sidebar', () => sidebarEl.focus());
return (
<div>
<div ref={sidebarEl} tabIndex={0} />
<For each={ids}>
{(id) => {
createEffect(() => {
if (store.activeTaskId !== id) return;
const p = store.focusedPanel[id];
if (p) scheduleTaskFocus(id, p);
});
registerFocusFn(`${id}:${panel(id)}`, () => terminals[id].focus());
return (
<div data-task-id={id} onClick={() => activate(id)}>
<div
data-testid={`title-${id}`}
onMouseDown={(e) =>
handleDragReorder(e, {
itemId: id,
getTaskOrder: () => store.taskOrder,
onReorder: () => {},
onTap: () => activate(id),
})
}
>
title
</div>
<textarea ref={(el) => (terminals[id] = el)} />
<input data-testid={`input-${id}`} />
</div>
);
}}
</For>
</div>
);
}, root);
}

function tapTitle(id: string) {
const el = document.querySelector<HTMLElement>(`[data-testid="title-${id}"]`);
if (!el) throw new Error(`no title for ${id}`);
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 10 }));
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0, clientX: 10 }));
el.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0, clientX: 10 }));
}

const settle = () => new Promise((resolve) => setTimeout(resolve, 0));

async function moveIntoSidebar() {
terminals['task-a'].focus();
focusSidebar();
navigateRow('down');
await settle();
expect(store.sidebarFocused).toBe(true);
expect(document.activeElement).toBe(sidebarEl);
}

beforeEach(() => {
setStore({
tasks: Object.fromEntries(
ids.map((id) => [
id,
{
id,
name: id,
projectId: 'p',
branchName: id,
worktreePath: `/w/${id}`,
agentIds: [`${id}-agent`],
shellAgentIds: [],
notes: '',
lastPrompt: '',
gitIsolation: 'worktree',
},
]),
),
taskOrder: [...ids],
collapsedTaskOrder: [],
projects: [{ id: 'p', name: 'p', path: '/p', color: '#fff' }] as never,
terminals: {},
focusedPanel: { 'task-a': panel('task-a'), 'task-b': panel('task-b') },
activeTaskId: 'task-a',
activeAgentId: 'task-a-agent',
activeDocumentProjectId: null,
newTaskPanelFocused: false,
placeholderFocused: false,
sidebarFocused: false,
sidebarFocusedTaskId: null,
sidebarFocusedProjectId: null,
} as never);
});

afterEach(() => {
dispose?.();
dispose = undefined;
document.body.innerHTML = '';
for (const id of ids) unregisterFocusFn(`${id}:${panel(id)}`);
});

// Control: a tap moves no DOM focus by itself (in the app the drag handler
// prevents the mousedown default; happy-dom moves none on mousedown either), so
// through setActiveTask alone focus stays on the sidebar.
it('setActiveTask leaves focus on the sidebar after a title tap', async () => {
mount(setActiveTask);
await moveIntoSidebar();
tapTitle('task-b');
await settle();
expect(store.activeTaskId).toBe('task-b');
expect(document.activeElement).toBe(sidebarEl);
});

it('a title tap on another column moves focus into it', async () => {
mount(activateTaskFromPointer);
await moveIntoSidebar();
tapTitle('task-b');
await settle();
expect(store.sidebarFocused).toBe(false);
expect(document.activeElement).toBe(terminals['task-b']);
});

// Nothing about the selection changes here, so the column's focus effect does
// not run again; the activation itself has to move focus back.
it('a title tap on the column that was already active moves focus back into it', async () => {
mount(activateTaskFromPointer);
await moveIntoSidebar();
tapTitle('task-a');
await settle();
expect(store.sidebarFocused).toBe(false);
expect(document.activeElement).toBe(terminals['task-a']);
});

// Only leaving the sidebar moves focus. A tap on the active column's title while
// focus is already somewhere in the column must leave it there.
it('leaves focus alone when the sidebar did not have it', async () => {
mount(activateTaskFromPointer);
await settle(); // let the active column's initial focus land first
const input = document.querySelector<HTMLInputElement>('[data-testid="input-task-a"]');
if (!input) throw new Error('no input');
input.focus();
tapTitle('task-a');
await settle();
expect(document.activeElement).toBe(input);
});
46 changes: 45 additions & 1 deletion src/store/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ vi.mock('./notification', () => ({ showNotification: vi.fn() }));
vi.mock('./projects', () => ({ pickAndAddProject: vi.fn() }));
vi.mock('./tasks', () => ({ reorderTask: vi.fn() }));

import { jumpToTask, moveActiveTask } from './navigation';
import { activateTaskFromPointer, jumpToTask, moveActiveTask, setActiveTask } from './navigation';
import { isPanelFocused } from './focused-panel';
import { reorderTask } from './tasks';

beforeEach(() => {
Expand Down Expand Up @@ -164,3 +165,46 @@ describe('jumpToTask', () => {
expect(mockStore.sidebarFocusedProjectId).toBe(null);
});
});

// `sidebarFocused` gates every panel: while it is set, `isPanelFocused` is false
// and `scheduleTaskFocus` will not move DOM focus. A click into a column is the
// user leaving the sidebar; a keyboard jump is not.
describe('activateTaskFromPointer', () => {
const panel = 'ai-terminal:agent-b';

beforeEach(() => {
mockStore.activeTaskId = 'task-1';
mockStore.sidebarFocused = true;
mockStore.focusedPanel = { 'task-2': panel };
});

it('setActiveTask leaves sidebarFocused set (keyboard jumps rely on it)', () => {
setActiveTask('task-2');
expect(mockStore.activeTaskId).toBe('task-2');
expect(isPanelFocused('task-2', panel)).toBe(false);
});

it('activates the column and hands it focus', () => {
activateTaskFromPointer('task-2');
expect(mockStore.activeTaskId).toBe('task-2');
expect(mockStore.sidebarFocused).toBe(false);
expect(isPanelFocused('task-2', panel)).toBe(true);
});

it('clears the sidebar gate when the clicked column is already active', () => {
mockStore.activeTaskId = 'task-2';
activateTaskFromPointer('task-2');
expect(isPanelFocused('task-2', panel)).toBe(true);
});

it('leaves the sidebar focused when the id is not something setActiveTask accepts', () => {
activateTaskFromPointer('no-such-task');
expect(mockStore.activeTaskId).toBe('task-1');
expect(mockStore.sidebarFocused).toBe(true);
});

it('keeps keyboard jumps on the sidebar, as they were', () => {
jumpToTask(1);
expect(mockStore.sidebarFocused).toBe(true);
});
});
25 changes: 24 additions & 1 deletion src/store/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { batch } from 'solid-js';
import { documentAgentTaskId } from '../documents/task-id';
import { store, setStore } from './core';
import { getTaskFocusedPanel, setTaskFocusedPanel, triggerFocus } from './focused-panel';
import {
getTaskFocusedPanel,
scheduleTaskFocus,
setTaskFocusedPanel,
triggerFocus,
} from './focused-panel';
import { showNotification } from './notification';
import { pickAndAddProject } from './projects';
import { reorderTask } from './tasks';
Expand Down Expand Up @@ -52,6 +58,23 @@ export function setActiveTask(id: string): void {
setStore('activeAgentId', activeAgentId);
}

/** Activate a task from a click or tap into its column. Unlike `setActiveTask`,
* which keyboard jumps share, this also takes focus away from the sidebar. */
export function activateTaskFromPointer(id: string): void {
const leavingSidebar = store.sidebarFocused;
const wasActive = store.activeTaskId === id;
batch(() => {
setActiveTask(id);
// Only when the id was one setActiveTask accepted.
if (store.activeTaskId === id) setStore('sidebarFocused', false);
});
// A column that was already active re-runs none of its focus effects, so
// nothing else would move DOM focus back into it.
if (leavingSidebar && wasActive && store.activeTaskId === id) {
scheduleTaskFocus(id, getTaskFocusedPanel(id));
}
}

export function setActiveAgent(agentId: string): void {
setStore('activeAgentId', agentId);
const taskId = store.activeTaskId;
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
export { updateTaskBranch, undoBranchAdoption, dismissBranchAdoptionNotice } from './task-branch';
export {
setActiveTask,
activateTaskFromPointer,
setActiveAgent,
moveActiveTask,
jumpToTask,
Expand Down
Loading