fix: restore command-center task focus (#3065)

This commit is contained in:
Carlos Florêncio
2026-08-26 21:05:08 +00:00
committed by GitHub
parent 8e8cdd184d
commit 37e0906490
15 changed files with 794 additions and 85 deletions
+22
View File
@@ -217,6 +217,10 @@
animation: settings-target-highlight 1400ms ease-out;
}
.task-sidebar-row-reveal {
animation: task-sidebar-row-reveal 1400ms ease-out;
}
[data-settings-dirty="true"] {
border-color: color-mix(in oklab, var(--success) 65%, var(--border)) !important;
}
@@ -254,12 +258,30 @@
}
}
@keyframes task-sidebar-row-reveal {
0%,
35% {
background-color: color-mix(in oklab, var(--primary) 28%, transparent);
box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--primary) 55%, transparent);
}
100% {
background-color: transparent;
box-shadow: inset 0 0 0 2px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
[data-settings-target-highlight="true"] {
animation: none;
background-color: color-mix(in oklab, var(--primary) 10%, transparent);
box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--primary) 55%, transparent);
}
.task-sidebar-row-reveal {
animation: none;
background-color: color-mix(in oklab, var(--primary) 28%, transparent);
box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--primary) 55%, transparent);
}
}
/* WebKit can leave a fixed dialog's transform animation on a separate layer,
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import type { DockviewApi } from "dockview-react";
import { StateProvider } from "@/components/state-provider";
import { defaultState } from "@/lib/state/default-state";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { TaskSession, TaskId } from "@/lib/types/http";
import { makeReorderingAutoSessionApi } from "./dockview-session-tabs.test-utils";
import { useAutoSessionTab } from "./dockview-session-tabs";
const TASK_ID = "task-1" as TaskId;
const ACTIVE_SESSION_ID = "session-a";
const SIBLING_SESSION_ID = "session-b";
function Harness() {
useAutoSessionTab(ACTIVE_SESSION_ID);
return null;
}
function renderHookWithHydratedSessions() {
const sessions = [ACTIVE_SESSION_ID, SIBLING_SESSION_ID].map((id) => ({ id }) as TaskSession);
return render(
<StateProvider
initialState={{
...defaultState,
tasks: {
...defaultState.tasks,
activeTaskId: TASK_ID,
activeSessionId: ACTIVE_SESSION_ID,
},
taskSessionsByTask: {
...defaultState.taskSessionsByTask,
itemsByTaskId: { [TASK_ID]: sessions },
},
}}
>
<Harness />
</StateProvider>,
);
}
afterEach(() => {
cleanup();
useDockviewStore.setState({ api: null });
});
describe("useAutoSessionTab", () => {
it("reconciles every hydrated session when Dockview becomes ready later", () => {
// @covers AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.1
useDockviewStore.setState({ api: null });
renderHookWithHydratedSessions();
const { api } = makeReorderingAutoSessionApi();
act(() => {
useDockviewStore.setState({ api: api as DockviewApi });
});
expect(api.panels.map((panel) => panel.id)).toEqual(
expect.arrayContaining([`session:${ACTIVE_SESSION_ID}`, `session:${SIBLING_SESSION_ID}`]),
);
});
});
@@ -611,6 +611,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
const prevTaskIdRef = useRef<string | null>(null);
const prevSessionIdRef = useRef<string | null>(null);
const appStore = useAppStoreApi();
const dockviewApi = useDockviewStore((state) => state.api);
// Key-based dependency so the effect re-runs when the task's session list
// changes (add/remove). Inside the effect we re-read the real array from
@@ -629,5 +630,5 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
prevTaskIdRef,
prevSessionIdRef,
});
}, [effectiveSessionId, sessionIdsKey, appStore]);
}, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey]);
}
@@ -94,6 +94,54 @@ test.describe("Multi-session UX", () => {
await expect(tab2).toBeVisible({ timeout: 10_000 });
});
test("command panel navigation renders all existing session tabs", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const title = "Command panel multi-session task";
const task = await apiClient.createTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const primary = await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: `command-panel-primary-${task.id}`,
agentProfileId: seedData.agentProfileId,
startedAt: "2026-08-26T00:00:00Z",
});
const secondary = await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: `command-panel-secondary-${task.id}`,
agentProfileId: seedData.agentProfileId,
startedAt: "2026-08-26T00:01:00Z",
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardByTitle(title)).toBeVisible({ timeout: 10_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+k`);
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole("combobox").fill(title);
const option = dialog.getByRole("option").filter({ hasText: title });
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click();
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}$`));
const session = new SessionPage(testPage);
await expect(session.sessionTabBySessionId(primary.session_id)).toBeVisible({
timeout: 10_000,
});
await expect(session.sessionTabBySessionId(secondary.session_id)).toBeVisible({
timeout: 10_000,
});
});
test("+ dropdown shows sessions with correct numbering", async ({
testPage,
apiClient,
@@ -349,6 +349,7 @@ test.describe("sidebar scrolling", () => {
"aria-current",
"true",
);
await expect(targetRow).toHaveClass(/task-sidebar-row-reveal/, { timeout: 1_000 });
await expect
.poll(
async () => {
+60 -4
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TASK_ROW_DOM_ATTR,
TASK_ROW_REVEAL_CLASS,
cancelSidebarTaskReveal,
revealSidebarTask,
taskRowSelector,
@@ -41,9 +42,18 @@ function mountRow(viewport: HTMLElement, taskId: string, rect: Rect) {
return row;
}
function setReducedMotion(reducedMotion: boolean) {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: reducedMotion })),
);
}
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("taskRowSelector", () => {
@@ -53,20 +63,32 @@ describe("taskRowSelector", () => {
});
describe("revealSidebarTask", () => {
it("scrolls an off-screen rendered row with nearest alignment", async () => {
it("smoothly scrolls and cues an off-screen rendered row", async () => {
setReducedMotion(false);
const viewport = mountViewport();
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 120, width: 320, height: 24 });
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
expect(row.scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" });
expect(row.scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
});
it("scrolls an off-screen row above the viewport with nearest alignment", async () => {
it("uses immediate nearest scrolling and a non-animated cue for reduced motion", async () => {
setReducedMotion(true);
const viewport = mountViewport();
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: -24, width: 320, height: 24 });
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
expect(row.scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" });
expect(row.scrollIntoView).toHaveBeenCalledWith({
behavior: "auto",
block: "nearest",
inline: "nearest",
});
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
});
it("does not reposition a row that is already inside the viewport", async () => {
@@ -75,8 +97,29 @@ describe("revealSidebarTask", () => {
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
expect(row.scrollIntoView).not.toHaveBeenCalled();
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
});
it("restarts the cue without letting stale cleanup clear the newer cue", async () => {
vi.useFakeTimers();
const viewport = mountViewport();
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 120, width: 320, height: 24 });
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
vi.advanceTimersByTime(1_000);
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
vi.advanceTimersByTime(400);
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
vi.advanceTimersByTime(999);
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
vi.advanceTimersByTime(1);
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(false);
});
});
describe("revealSidebarTask edge cases", () => {
it("finds a row that renders after a later animation frame", async () => {
const viewport = mountViewport();
const callbacks: Array<() => void> = [];
@@ -123,6 +166,19 @@ describe("revealSidebarTask", () => {
expect(row.scrollIntoView).not.toHaveBeenCalled();
});
it("clears an applied cue when navigation is canceled", async () => {
vi.useFakeTimers();
const viewport = mountViewport();
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 120, width: 320, height: 24 });
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
cancelSidebarTaskReveal();
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(false);
});
it("ignores a matching row inside a hidden sidebar viewport", async () => {
const hiddenViewport = mountViewport(false);
const hiddenRow = mountRow(hiddenViewport, TEST_TASK_ID, {
+51 -1
View File
@@ -1,12 +1,28 @@
export const TASK_ROW_DOM_ATTR = "data-task-row-id";
export const TASK_SIDEBAR_SCROLL_SELECTOR = '[data-testid="task-sidebar-scroll"]';
export const TASK_ROW_REVEAL_CLASS = "task-sidebar-row-reveal";
const MAX_TASK_NAVIGATION_ATTEMPTS = 60;
const TASK_ROW_REVEAL_DURATION_MS = 1400;
let latestNavigationRequestId = 0;
let latestCueId = 0;
type ActiveTaskRowCue = {
cueId: number;
row: HTMLElement;
timeoutId: number;
};
let activeTaskRowCue: ActiveTaskRowCue | null = null;
/** Invalidates the current reveal so a pending selection cannot scroll a stale row. */
export function cancelSidebarTaskReveal(): void {
latestNavigationRequestId += 1;
if (!activeTaskRowCue) return;
window.clearTimeout(activeTaskRowCue.timeoutId);
activeTaskRowCue.row.classList.remove(TASK_ROW_REVEAL_CLASS);
activeTaskRowCue = null;
}
/** CSS selector for a rendered task row by its stable task id. */
@@ -63,6 +79,35 @@ function defaultRequestFrame(callback: () => void): void {
}
}
function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
/** Restarts the short-lived cue on the latest command-selected row. */
function cueTaskRow(row: HTMLElement): void {
if (activeTaskRowCue) {
window.clearTimeout(activeTaskRowCue.timeoutId);
activeTaskRowCue.row.classList.remove(TASK_ROW_REVEAL_CLASS);
}
const cueId = ++latestCueId;
row.classList.remove(TASK_ROW_REVEAL_CLASS);
// Force a reflow so selecting the same task twice restarts its animation.
void row.offsetWidth;
row.classList.add(TASK_ROW_REVEAL_CLASS);
const timeoutId = window.setTimeout(() => {
if (activeTaskRowCue?.cueId !== cueId) return;
row.classList.remove(TASK_ROW_REVEAL_CLASS);
activeTaskRowCue = null;
}, TASK_ROW_REVEAL_DURATION_MS);
activeTaskRowCue = { cueId, row, timeoutId };
}
/**
* Reveals a rendered task row in the visible desktop sidebar.
*
@@ -92,8 +137,13 @@ export function revealSidebarTask(
return;
}
if (!isInsideViewport(match.row, match.viewport)) {
match.row.scrollIntoView({ block: "nearest", inline: "nearest" });
match.row.scrollIntoView({
behavior: prefersReducedMotion() ? "auto" : "smooth",
block: "nearest",
inline: "nearest",
});
}
cueTaskRow(match.row);
resolve(true);
return;
}
@@ -0,0 +1,106 @@
---
spec: docs/specs/ui/requirements/task-agent-tab-reconciliation.md
created: 2026-08-26
status: implemented
---
# Implementation Plan: Command-center Task Focus
## Overview
Repair two UI outcomes that follow command-panel task selection. First, make
Agent-tab reconciliation react when Dockview becomes ready after the selected
task's sessions have already hydrated. Second, extend the existing sidebar
reveal with smooth minimum-distance scrolling and a transient row cue that
respects reduced-motion preferences.
Applicable contracts:
- `docs/specs/ui/requirements/task-agent-tab-reconciliation.md`
- `docs/specs/ui/system-design/task-agent-tab-reconciliation.md`
- `docs/specs/ui/requirements/command-panel-sidebar-task-reveal.md`
- `docs/specs/ui/system-design/command-panel-sidebar-task-reveal.md`
## Confirmed root cause
`useAutoSessionTab` reacts to the effective session and the active task's
session-ID key. Its effect calls `runAutoSessionTabEffect`, which reads the
Dockview API through `useDockviewStore.getState()` and returns when that API is
null. The hook does not subscribe to the API value. Route hydration can settle
before Dockview `onReady`. In that order, API publication does not change an
effect dependency. The sibling Agent panels remain absent until another event,
such as reload, runs reconciliation.
A temporary React hook test reproduced this order: hydrate task sessions with
a null API, render the hook, then publish a fake API. The test failed because
the API was never read after publication. The temporary test was removed after
the diagnosis. Task 01 owns the permanent RED regression.
The sidebar already has bounded route-aware reveal behavior, but
`scrollIntoView` has no `behavior` option and the row receives only its normal
active state. The existing UI review-target navigation provides the nearest
reduced-motion and transient-cue precedent.
## Delivery order
Both work orders are frontend-only and have no code dependency. Execute them
sequentially in the primary session because implementation delegation has not
been authorized.
- [x] [Task 01: Reconcile Agent tabs on workbench readiness](task-01-reconcile-agent-tabs-on-readiness.md)
- [x] [Task 02: Animate and mark the command-selected sidebar row](task-02-animate-command-selected-sidebar-row.md)
## Verification strategy
Task 01 uses a hook-level timing regression for the API-late order, existing
pure reconciliation tests, and a user-level Cmd+K multi-session scenario.
Task 02 uses DOM unit tests for scroll options and cue lifecycle, the existing
overflowing-sidebar Playwright scenario, and the existing phone command-panel
navigation regression.
After both work orders pass, run these package-level checks:
1. `cd apps/web && pnpm run typecheck`
2. `cd apps && pnpm --filter @kandev/web lint`
3. `cd apps && pnpm --filter @kandev/web build:vite`
4. `git diff --check`
No new user-facing copy is planned. If implementation adds copy, it must add
all required locale entries and run the repository i18n checks.
## Mobile contract
Phone and tablet keep their existing task route and session controls. They do
not mount desktop Dockview tabs or use the hidden desktop sidebar as a scroll
target. Task 01 preserves the shared session-membership source, and Task 02
retains the existing focused mobile Playwright regression.
## Risks and boundaries
- A broad Dockview subscription can cause redundant reconciliation. Subscribe
only to API readiness and keep the existing session-key dependency.
- Smooth scrolling makes immediate geometry assertions unreliable. Browser
coverage must poll for final containment and use unit coverage for the exact
`scrollIntoView` options.
- Cue cleanup must not let an older timer remove a newer cue.
- Filtered and collapsed rows remain intentional no-ops. The repair must not
mutate sidebar preferences to expose them.
- No backend API, session lifecycle, saved layout, or public documentation
contract changes.
## Results
Completed on 2026-08-26.
- Task 01 adds Dockview API readiness to `useAutoSessionTab` dependencies and
permanently covers the session-hydrated-before-workbench-ready ordering.
- Task 01's focused unit run passed 2 files and 35 tests. Its command-panel
multi-session Chromium scenario passed 1 test without a page reload.
- Task 02 adds reduced-motion-aware nearest scrolling and a restartable
transient row cue. Its focused unit run passed 1 file and 10 tests.
- Task 02's desktop reveal scenario passed 1 test, including cue presence,
nested-viewport containment, active state, and unchanged document scroll.
The existing mobile command-panel scenario passed 1 test.
- `pnpm run typecheck`, the web lint, and the Vite production build passed.
- The managed E2E runner created disposable backend, fixture-plugin, and web
build artifacts. None were added to the change.
@@ -0,0 +1,87 @@
---
id: "01-reconcile-agent-tabs-on-readiness"
title: "Reconcile Agent tabs on workbench readiness"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/task-agent-tab-reconciliation.md"
---
# Task 01: Reconcile Agent Tabs on Workbench Readiness
## Outcome
Opening a task with several sessions renders every Agent tab on the first task
view, including when task hydration finishes before Dockview becomes ready.
## In scope
- Add a permanent hook regression for API-late readiness.
- Make `useAutoSessionTab` react to Dockview API readiness while preserving its
existing session and active-panel reconciliation.
- Add a command-panel multi-session Playwright scenario with no page reload.
## Exclusions
- Session lifecycle, ordering, or backend changes.
- Saved layout geometry and non-Agent active-panel rules.
- Mobile session-control redesign.
## Requirements and design
- `REQ-UI-TASK-AGENT-TAB-RECONCILIATION-001`
- `AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.1` through
`AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.5`
- `docs/specs/ui/system-design/task-agent-tab-reconciliation.md`
## Implementation acceptance
1. A hook test fails before the production change when sessions hydrate before
the Dockview API, then passes after the API is published.
2. API readiness triggers one reconciliation path that reads the latest task
and session state. Existing session-key updates continue to reconcile.
3. Cmd+K opens a seeded multi-session task and shows all Agent tabs without a
reload.
## TDD and verification
1. RED: add the API-late hook case, run it against current production code, and
record the missing reconciliation assertion.
2. Unit GREEN: `cd apps/web && pnpm exec vitest run components/task/dockview-session-tabs.hook.test.tsx components/task/dockview-session-tabs.test.ts`
3. Typecheck: `cd apps/web && pnpm run typecheck`
4. E2E GREEN: `cd apps/web && pnpm e2e:run --host --no-build tests/session/multi-session-ux.spec.ts --grep "command panel navigation renders all existing session tabs"`
Make sure that Playwright discovers the focused scenario. Then accept the
browser result as evidence.
## Files likely touched
- `apps/web/components/task/dockview-session-tabs.ts`
- `apps/web/components/task/dockview-session-tabs.hook.test.tsx`
- `apps/web/e2e/tests/session/multi-session-ux.spec.ts`
## Dependencies and parallelism
No code dependency. Execute in the primary session. Do not delegate unless the
user explicitly authorizes implementation agents.
## Results
Completed on 2026-08-26.
### RED evidence
- `pnpm exec vitest run components/task/dockview-session-tabs.hook.test.tsx`
failed 1 test because publishing the late Dockview API did not trigger a
reconciliation.
### GREEN evidence
- `pnpm exec vitest run components/task/dockview-session-tabs.hook.test.tsx components/task/dockview-session-tabs.test.ts` passed 2 files and 35 tests.
- `pnpm run typecheck` passed.
- `pnpm e2e:run --host --no-build tests/session/multi-session-ux.spec.ts --grep "command panel navigation renders all existing session tabs"` passed 1 Chromium test.
The implementation reads the current application state inside the existing
reconciliation path and only adds the Dockview API readiness subscription.
No backend or mobile surface changed.
@@ -0,0 +1,92 @@
---
id: "02-animate-command-selected-sidebar-row"
title: "Animate and mark the command-selected sidebar row"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/requirements/command-panel-sidebar-task-reveal.md"
---
# Task 02: Animate and Mark the Command-selected Sidebar Row
## Outcome
Command-panel task navigation smoothly reveals an off-screen desktop sidebar
row and gives the selected row a short visual cue. Reduced-motion users get an
immediate scroll and a non-animated cue.
## In scope
- Add motion-preference-aware nearest scrolling to the existing reveal helper.
- Add a restartable, bounded row cue and CSS reduced-motion behavior.
- Extend unit and desktop Playwright coverage while retaining the phone
navigation regression.
## Exclusions
- Sidebar filtering, expansion, ordering, or persistence changes.
- Command-panel search and task-route loading changes.
- Mobile task-switcher redesign.
## Requirements and design
- `REQ-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001`
- `AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.1` through
`AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.10`
- `docs/specs/ui/system-design/command-panel-sidebar-task-reveal.md`
## Implementation acceptance
1. Off-screen rows use smooth nearest scrolling in normal motion and immediate
nearest scrolling under reduced motion. Already visible rows do not move.
2. Every successful reveal restarts one transient cue on the current row. An
older request or cleanup timer cannot mark or clear the newer target.
3. Desktop E2E proves final nested-viewport containment, active state, cue
presence, and unchanged document scroll. Phone navigation remains direct.
## TDD and verification
1. RED: extend the DOM unit tests with exact smooth-scroll, reduced-motion, and
cue lifecycle assertions before changing production code.
2. Unit GREEN: `cd apps/web && pnpm exec vitest run lib/sidebar/task-navigation.test.ts`
3. Typecheck: `cd apps/web && pnpm run typecheck`
4. Desktop E2E GREEN: `cd apps/web && pnpm e2e:run --host --no-build tests/task/sidebar-scroll-preservation.spec.ts --grep "reveals a command-selected task$"`
5. Mobile E2E GREEN: `cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-command-panel-task-navigation.spec.ts`
Make sure that Playwright discovers each focused scenario. Then accept each
browser result as evidence.
## Files likely touched
- `apps/web/lib/sidebar/task-navigation.ts`
- `apps/web/lib/sidebar/task-navigation.test.ts`
- `apps/web/app/globals.css`
- `apps/web/e2e/tests/task/sidebar-scroll-preservation.spec.ts`
- `apps/web/e2e/tests/task/mobile-command-panel-task-navigation.spec.ts`
## Dependencies and parallelism
No code dependency. Execute in the primary session. Do not delegate unless the
user explicitly authorizes implementation agents.
## Results
Completed on 2026-08-26.
### RED evidence
- The extended DOM suite failed 4 expected assertions for smooth and reduced
motion options, visible-row cueing, and cue restart behavior.
### GREEN evidence
- `pnpm exec vitest run lib/sidebar/task-navigation.test.ts` passed 1 file and 10 tests.
- `pnpm run typecheck` passed.
- `pnpm e2e:run --host --no-build tests/task/sidebar-scroll-preservation.spec.ts --grep "reveals a command-selected task$"` passed 1 Chromium test.
- `pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-command-panel-task-navigation.spec.ts` passed 1 mobile test.
- The web lint, Vite production build, and `git diff --check` passed.
The helper keeps one latest-row cue, guards cleanup by generation, and leaves
hidden or missing rows as no-ops. The existing mobile test remains unchanged
and confirms that phone navigation does not target the desktop sidebar.
+3
View File
@@ -128,6 +128,7 @@ contract that other capabilities can reuse.
- [Nested Submodule Review](requirements/submodule-review.md)
- [Task Confirmation Warning Hierarchy](requirements/confirmation-warning-hierarchy.md)
- [Task Layout Profiles](requirements/task-layout-profiles.md)
- [Task Agent Tab Reconciliation](requirements/task-agent-tab-reconciliation.md)
- [Task Listing Display Preferences](requirements/task-listing-display-preferences.md)
- [Task transcript history visibility](requirements/task-prompt-transcript-visibility.md)
- [Task Review Shortcut Switcher](requirements/task-review-shortcut.md)
@@ -168,6 +169,8 @@ contract that other capabilities can reuse.
- [Task Confirmation Warning Hierarchy](system-design/confirmation-warning-hierarchy.md)
- [Resizable Markdown Table Columns](system-design/resizable-markdown-tables.md)
- [Task Layout Profiles](system-design/task-layout-profiles.md)
- [Task Agent Tab Reconciliation](system-design/task-agent-tab-reconciliation.md)
- [Command-panel Sidebar Task Reveal](system-design/command-panel-sidebar-task-reveal.md)
- [Terminal Rendering](system-design/terminal-rendering.md)
- [Task Transcript History Visibility](system-design/task-prompt-transcript-visibility.md)
@@ -1,105 +1,69 @@
---
status: draft
status: active
system: ui
created: 2026-08-05
owners:
- kandev
---
# Command-panel Sidebar Task Reveal Requirements
## Overview
When the desktop sidebar contains more tasks than fit in its scroll viewport, opening a task from Cmd+K can leave the newly active row above or below the visible portion of the list. The workbench changes correctly, but the sidebar no longer provides visible context for where that task sits.
When the desktop task list overflows, a task opened from Cmd+K can be outside
the visible part of the sidebar. The UI owns the scroll and visual-feedback
contract that helps the user find the newly active task without changing task
state or sidebar preferences.
## Requirements
### REQ-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001: Command-panel Sidebar Task Reveal
**Intent:** When the desktop sidebar contains more tasks than fit in its scroll viewport, opening a task from Cmd+K can leave the newly active row above or below the visible portion of the list. The workbench changes correctly, but the sidebar no longer provides visible context for where that task sits.
**Intent:** Keep the command-selected task visible and easy to locate in the
desktop sidebar after navigation.
#### Acceptance criteria
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.1:** Selecting a task from Cmd+K continues to open the task's canonical route.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.2:** If that task already has a rendered row in the visible desktop task sidebar, the sidebar scrolls by the minimum amount needed to bring the row inside its own task-list viewport.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.3:** The revealed row carries the normal active-task highlight after navigation. The reveal does not move keyboard focus, scroll the document, or reset the task list to its top.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.4:** A row that is already fully visible is not unnecessarily repositioned.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.5:** Reveal waits through bounded asynchronous route and sidebar rendering instead of relying on a fixed delay. Command-panel selection queues the task ID while guarded navigation is pending and starts the reveal only after the canonical task route and matching active-task state render.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.6:** If another command-panel task is selected before the previous reveal finishes, the latest request immediately invalidates the earlier one; a late row from the earlier request must never be scrolled into view, even while the newer route is waiting behind a navigation guard.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.7:** If the active sidebar view filters the task out, a saved group or subtask branch hides it, or the desktop sidebar is not rendered, task navigation still succeeds and sidebar preferences remain unchanged.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.8:** **GIVEN** an overflowing desktop task sidebar and a Cmd+K result whose rendered row is below the task-list viewport, **WHEN** the user selects that result, **THEN** the task route opens and the active row is fully inside the task-list viewport.
## Migrated source detail
## Why
When the desktop sidebar contains more tasks than fit in its scroll viewport, opening a task from
Cmd+K can leave the newly active row above or below the visible portion of the list. The workbench
changes correctly, but the sidebar no longer provides visible context for where that task sits.
## What
- Selecting a task from Cmd+K continues to open the task's canonical route.
- If that task already has a rendered row in the visible desktop task sidebar, the sidebar scrolls
by the minimum amount needed to bring the row inside its own task-list viewport.
- The revealed row carries the normal active-task highlight after navigation. The reveal does not
move keyboard focus, scroll the document, or reset the task list to its top.
- A row that is already fully visible is not unnecessarily repositioned.
- Reveal waits through bounded asynchronous route and sidebar rendering instead of relying on a
fixed delay. Command-panel selection queues the task ID while guarded navigation is pending and
starts the reveal only after the canonical task route and matching active-task state render.
- If another command-panel task is selected before the previous reveal finishes, the latest request
immediately invalidates the earlier one; a late row from the earlier request must never be
scrolled into view, even while the newer route is waiting behind a navigation guard.
- If the active sidebar view filters the task out, a saved group or subtask branch hides it, or the
desktop sidebar is not rendered, task navigation still succeeds and sidebar preferences remain
unchanged.
## Failure modes
- Failure to find a rendered sidebar row is a no-op for the sidebar after the bounded reveal
attempt; it never blocks or reverses task navigation.
- A hidden desktop sidebar must not become a scroll target on phone-sized viewports.
## Mobile contract
- Phone navigation keeps its existing direct task-route outcome. Cmd+K does not open the mobile
task-switcher sheet or interact with the CSS-hidden desktop sidebar.
- The existing mobile task switcher remains the nearest shipped navigation exemplar: an inset
bottom drawer with its own vertical scroll owner. This repair does not change that composition,
its safe-area handling, or its touch targets.
- Desktop and phone continue to share task search and route navigation; only the desktop sidebar
receives the additional reveal behavior.
## Scenarios
- **GIVEN** an overflowing desktop task sidebar and a Cmd+K result whose rendered row is below the
task-list viewport, **WHEN** the user selects that result, **THEN** the task route opens and the
active row is fully inside the task-list viewport.
- **GIVEN** an overflowing desktop task sidebar and a Cmd+K result whose rendered row is above the
task-list viewport, **WHEN** the user selects that result, **THEN** the task route opens and the
active row is fully inside the task-list viewport.
- **GIVEN** a dirty settings page that blocks navigation after a Cmd+K task selection, **WHEN** the
user confirms leaving after the initial reveal retry budget expires, **THEN** the task route opens
and the active row is still revealed after the guarded navigation completes.
- **GIVEN** two successive Cmd+K task selections where the first row is initially missing, **WHEN**
the second task is revealed and the first row later renders, **THEN** only the second task is
scrolled into view.
- **GIVEN** a Cmd+K task result whose sidebar row is already fully visible, **WHEN** the user selects
it, **THEN** task navigation succeeds without an unnecessary sidebar jump.
- **GIVEN** a Cmd+K task result excluded by the active sidebar view or hidden by a persisted
collapse, **WHEN** the user selects it, **THEN** task navigation succeeds without changing that
view or collapse preference.
- **GIVEN** a phone viewport, **WHEN** the user selects a task through Cmd+K, **THEN** the task route
opens and no hidden desktop sidebar becomes the reveal target.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.1:** Selecting a task from
Cmd+K shall open the task's canonical route.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.2:** When the selected task has
a rendered row outside the visible desktop task-list viewport, the sidebar
shall scroll smoothly. It shall use the minimum distance that makes the row
visible.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.3:** After navigation, the
selected row shall keep the normal active-task state and show a transient
reveal cue that distinguishes it from adjacent rows.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.4:** When the row is already
fully visible, the sidebar shall not reposition it, but the transient reveal
cue shall still identify it.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.5:** The reveal shall not move
keyboard focus, scroll the document, reset the task list, or change a saved
sidebar view or collapse preference.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.6:** The reveal shall wait
through bounded asynchronous route and sidebar rendering. It shall start
only after the canonical route and matching active-task state render.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.7:** When another task is
selected before an earlier reveal finishes, the latest request shall cancel
the earlier request. The stale row shall not scroll or receive the cue.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.8:** When the active sidebar
view, a collapsed branch, or a hidden desktop sidebar omits the row, task
navigation shall still succeed without changing sidebar preferences.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.9:** When the user prefers
reduced motion, the sidebar shall use immediate minimum-distance scrolling
and a non-animated transient cue.
- **AC-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001.10:** On a phone viewport,
Cmd+K shall keep the existing direct task-route outcome and shall not target
the CSS-hidden desktop sidebar.
## Out of scope
- Changing sidebar filters, the active saved view, collapsed groups, collapsed subtask branches, or
task ordering to force a row to render.
- Changing task search, task-route loading, or backend task APIs.
- Changing filters, saved views, task ordering, or collapsed branches so a row
renders.
- Expanding the desktop sidebar rail when the user has collapsed it.
- Opening or redesigning the mobile task-switcher sheet.
- Changing command-panel task search, task-route loading, or backend task APIs.
## Implementation plan
- [Command-panel sidebar task reveal](../../../plans/command-panel-sidebar-task-reveal/plan.md)
- [Initial command-panel sidebar task reveal](../../../plans/command-panel-sidebar-task-reveal/plan.md)
- [Command-center task focus repair](../../../plans/command-center-task-focus/plan.md)
@@ -0,0 +1,55 @@
---
status: active
system: ui
created: 2026-08-26
owners:
- kandev
---
# Task Agent Tab Reconciliation Requirements
## Overview
A desktop task can have several agent sessions. The task system owns session
membership, while the UI owns the projection of those sessions into task
workbench tabs. Every current session must appear without requiring a page
refresh, regardless of whether session hydration or workbench readiness
finishes first.
## Requirements
### REQ-UI-TASK-AGENT-TAB-RECONCILIATION-001: Task Agent Tab Reconciliation
**Intent:** Show a complete and current set of agent chats whenever a desktop
task workbench becomes usable.
#### Acceptance criteria
- **AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.1:** When the active task's session
list and desktop workbench become ready in either order, the workbench shall
show one Agent tab for every current task session. A page refresh shall not
be necessary.
- **AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.2:** The effective active session
shall own the active Agent tab. Other current sessions shall appear as
inactive sibling tabs without taking focus from a valid selected non-Agent
panel.
- **AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.3:** When session membership changes
after workbench readiness, the UI shall add new session tabs and remove stale
session tabs from the active task.
- **AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.4:** When Cmd+K opens a task that
already has multiple sessions, all Agent tabs shall appear during the first
task render without a manual reload.
- **AC-UI-TASK-AGENT-TAB-RECONCILIATION-001.5:** Phone and tablet task surfaces
shall continue to project the same task session membership through their
existing session controls without mounting desktop workbench tabs.
## Out of scope
- Changing session creation, lifecycle, ordering, or backend APIs.
- Changing saved desktop layout geometry or the active-panel rules for valid
non-Agent panels.
- Redesigning phone or tablet task navigation and session controls.
## Implementation plan
- [Command-center task focus repair](../../../plans/command-center-task-focus/plan.md)
@@ -0,0 +1,81 @@
---
status: current
system: ui
requirements:
- REQ-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001
---
# Command-panel Sidebar Task Reveal System Design
## Purpose and boundaries
This design extends the existing command-panel navigation with desktop sidebar
motion and visual feedback. It does not change route ownership, task search, or
sidebar persistence.
## Requirement mapping
| Requirement | Design section |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-UI-COMMAND-PANEL-SIDEBAR-TASK-REVEAL-001` | [Control flow](#control-flow), [Motion and visual feedback](#motion-and-visual-feedback), [Responsive behavior](#responsive-behavior) |
## Components and responsibilities
- `use-command-panel-task-navigation.ts` owns the pending selected task ID. It
starts a reveal only after the route and active-task state match that ID.
- `lib/sidebar/task-navigation.ts` finds the selected row inside a visible
`task-sidebar-scroll` viewport. It owns bounded retries, latest-request
cancellation, scroll behavior, and the transient cue.
- `task-item.tsx` exposes the stable task-row DOM attribute used by the helper.
- `globals.css` owns the reveal animation and its reduced-motion variant.
## Control flow
1. Cmd+K selection cancels an earlier reveal, records the selected task ID,
and starts canonical task navigation.
2. The navigation hook waits until both the route and active-task state match.
3. The reveal helper searches visible sidebar viewports for the matching row.
4. If the row is missing, the helper retries for its bounded animation-frame
budget. A newer request invalidates the current generation immediately.
5. When the row exists, the helper scrolls it only when it is outside the
sidebar viewport. It then restarts the transient cue on that row.
A missing row ends as a sidebar no-op. It never blocks or reverses task
navigation.
## Motion and visual feedback
The helper uses `scrollIntoView` with nearest block and inline alignment.
Normal motion uses smooth scrolling. Reduced motion uses immediate scrolling.
The cue uses a dedicated class on the interactive task row and is removed
after a bounded interval. Repeated selection restarts the cue, and a new
selection clears any earlier cue before it marks the new row.
CSS provides an animated emphasis for normal motion. Under
`prefers-reduced-motion: reduce`, the same class provides a static visual
emphasis until cleanup, with no animation. The existing `aria-current` state
remains the durable active-task signal.
## Responsive behavior
The helper ignores viewports and rows hidden by responsive CSS. Desktop uses
the sidebar animation and cue. Phone Cmd+K navigation continues directly to
the task route and uses the existing mobile task and session controls. It does
not open the task-switcher sheet or target hidden desktop markup.
## Failure and recovery
- A filtered or collapsed row exhausts the bounded retry budget and leaves
sidebar state unchanged.
- Guarded navigation does not consume the reveal budget because the hook does
not start the helper until navigation settles.
- A superseded generation cannot scroll or mark a stale row.
## Verification design
Vitest covers smooth and reduced-motion scroll options, visible-row behavior,
cue restart and cleanup, latest-request cancellation, hidden viewports, and
bounded failure. The existing sidebar Playwright suite covers command-panel
navigation, nested-viewport containment, the transient cue, and unchanged
document scroll. The mobile command-panel suite protects direct navigation and
the hidden desktop sidebar boundary.
@@ -0,0 +1,80 @@
---
status: current
system: ui
requirements:
- REQ-UI-TASK-AGENT-TAB-RECONCILIATION-001
---
# Task Agent Tab Reconciliation System Design
## Purpose and boundaries
This design makes desktop Agent-tab reconciliation depend on both task session
state and Dockview readiness. The task system remains authoritative for session
membership and the active session. This UI design only projects that state into
the desktop workbench.
## Requirement mapping
| Requirement | Design section |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-UI-TASK-AGENT-TAB-RECONCILIATION-001` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow), [Responsive behavior](#responsive-behavior) |
## Components and responsibilities
- `task-detail-route.tsx` loads the selected task and its session list for the
first client render.
- `StateHydrator` merges that payload into the application store before the
task workbench settles.
- `DockviewDesktopLayout` publishes the live `DockviewApi` through the Dockview
store when `onReady` fires.
- `useAutoSessionTab` observes the effective session, current task session IDs,
and Dockview API readiness. It invokes the existing reconciliation body when
any of these inputs changes.
- `runAutoSessionTabEffect` reads the current application state, removes stale
Agent panels, ensures the active session panel, and adds current sibling
session panels without activating them.
## Data and contracts
No new API, persisted value, or store field is required. The existing
`taskSessionsByTask.itemsByTaskId` collection is the session-membership source.
The existing Dockview store `api` value is the readiness source.
## Control flow
1. Route loading and application-store hydration can finish before or after
Dockview calls `onReady`.
2. `useAutoSessionTab` subscribes to both sources instead of reading Dockview
readiness only inside an effect triggered by session changes.
3. The effect does nothing while no Dockview API exists.
4. When the API becomes available, the readiness subscription triggers the
effect with the latest active task, effective session, and session list.
5. Existing reconciliation creates the active session panel first, preserves
valid non-Agent focus, and adds all sibling session panels as inactive tabs.
6. Later membership or active-session changes use the same path.
The readiness change is an event source, not a second copy of session state.
The effect always reads current application state when it runs.
## Failure and recovery
A null Dockview API is a temporary no-op. API readiness causes deterministic
reconciliation, so recovery does not depend on a timer, route retry, or page
reload. A session that disappears before readiness is not materialized because
the effect reads the latest session list.
## Responsive behavior
Desktop uses Dockview Agent tabs. Phone and tablet layouts do not mount the
desktop Dockview workbench. Their existing session controls continue to read
the same application-store membership. No mobile composition or touch target
changes.
## Verification design
A React hook regression test hydrates a multi-session task while the Dockview
API is null. The test publishes the API later and verifies reconciliation.
Existing pure reconciliation tests continue to cover active-tab and sibling
behavior. A Playwright scenario opens a multi-session task from Cmd+K and
verifies that every Agent tab appears without a reload.