From 702a0acbdaf650f4302ee40aa1b396afe64a0d40 Mon Sep 17 00:00:00 2001 From: Mia Date: Sat, 19 Sep 2026 05:02:17 -0400 Subject: [PATCH] fix(ui): hand focus to a column clicked while the sidebar has it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After moving through the sidebar with Alt+Up/Down, clicking a task or terminal column's title bar or header selected it but left the keyboard with the sidebar: no panel in the column showed focus, typing went nowhere, and Alt+Up/Down kept moving the sidebar highlight. `sidebarFocused` is a hard gate. While it is set, `isPanelFocused` returns false for every panel and `scheduleTaskFocus` declines to move DOM focus. The column's pointer handlers call `setActiveTask`, which clears the new-task panel and placeholder gates but deliberately not this one, because keyboard jumps use it too and keep the sidebar focused. The title bar's drag handler also prevents the mousedown default, so a tap there moves no DOM focus by itself. Clicks inside a terminal, notes or prompt were already fine: their handlers call `setTaskFocusedPanel`, which clears the gate. `activateTaskFromPointer` activates the task and clears `sidebarFocused` when `setActiveTask` accepted the id. For a column that was already active while the sidebar had focus, whose focus effect does not run again because nothing about the selection changed, it also moves DOM focus to `getTaskFocusedPanel(id)` — the same target Enter on a sidebar row uses. The pointer-driven switchers use it: a task or terminal column, a title-bar tap, a sub-task chip, a focus-mode pill. Sidebar clicks, keyboard jumps and handlers inside a column's own content keep `setActiveTask`. The document workspace column activates through its own focus-preserving handler and is left as it is. Tests: - 4 client tests on the real store, focus registry, sidebar navigation and title-bar drag handler, with a copy of TaskPanel's focus effect: a control showing `setActiveTask` leaves DOM focus on the sidebar; focus landing in another column, and back in the already-active one (fails without that branch); and focus left where it was when the sidebar did not have it (fails without the `leavingSidebar` guard). - 5 store tests against the real focus gate; the two covering the fix fail without its clearing line. - TaskPanel's reasoning test mocks the store module explicitly, so it gains the new function beside `setActiveTask`. --- src/components/FocusModeTaskIndicators.tsx | 9 +- src/components/SubTaskStrip.tsx | 4 +- .../TaskPanel.reasoning.client.test.tsx | 1 + src/components/TaskPanel.tsx | 4 +- src/components/TaskTitleBar.tsx | 4 +- src/components/TerminalPanel.tsx | 6 +- src/store/navigation.client.test.tsx | 173 ++++++++++++++++++ src/store/navigation.test.ts | 46 ++++- src/store/navigation.ts | 25 ++- src/store/store.ts | 1 + 10 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 src/store/navigation.client.test.tsx diff --git a/src/components/FocusModeTaskIndicators.tsx b/src/components/FocusModeTaskIndicators.tsx index 54c51fd5..f2673206 100644 --- a/src/components/FocusModeTaskIndicators.tsx +++ b/src/components/FocusModeTaskIndicators.tsx @@ -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'; @@ -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} diff --git a/src/components/SubTaskStrip.tsx b/src/components/SubTaskStrip.tsx index fbf44c16..0f72dffc 100644 --- a/src/components/SubTaskStrip.tsx +++ b/src/components/SubTaskStrip.tsx @@ -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'; @@ -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={{ diff --git a/src/components/TaskPanel.reasoning.client.test.tsx b/src/components/TaskPanel.reasoning.client.test.tsx index 81ac5b2e..71ca0565 100644 --- a/src/components/TaskPanel.reasoning.client.test.tsx +++ b/src/components/TaskPanel.reasoning.client.test.tsx @@ -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), }; }); diff --git a/src/components/TaskPanel.tsx b/src/components/TaskPanel.tsx index 0d22193b..171753ed 100644 --- a/src/components/TaskPanel.tsx +++ b/src/components/TaskPanel.tsx @@ -3,7 +3,7 @@ import { Show, createSignal, createEffect, createMemo, onMount, onCleanup, batch import { store, retryCloseTask, - setActiveTask, + activateTaskFromPointer, setActiveAgent, clearInitialPrompt, clearPrefillPrompt, @@ -676,7 +676,7 @@ export function TaskPanel(props: TaskPanelProps) { position: 'relative', }} onClick={() => { - setActiveTask(props.task.id); + activateTaskFromPointer(props.task.id); }} > store.taskOrder, onReorder: reorderTask, - onTap: () => setActiveTask(props.task.id), + onTap: () => activateTaskFromPointer(props.task.id), }); } diff --git a/src/components/TerminalPanel.tsx b/src/components/TerminalPanel.tsx index 62fd737e..0df77018 100644 --- a/src/components/TerminalPanel.tsx +++ b/src/components/TerminalPanel.tsx @@ -3,7 +3,7 @@ import { store, closeTerminal, updateTerminalName, - setActiveTask, + activateTaskFromPointer, reorderTask, registerFocusFn, unregisterFocusFn, @@ -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), }); } @@ -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 */}
({ 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 = {}; + +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 ( +
+
+ + {(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 ( +
activate(id)}> +
+ handleDragReorder(e, { + itemId: id, + getTaskOrder: () => store.taskOrder, + onReorder: () => {}, + onTap: () => activate(id), + }) + } + > + title +
+