From 131c4f50032a7ef4bb1fc873ca310e64bc6633bb Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:19:57 +0530 Subject: [PATCH 1/7] feat(sidebar): a status section above Projects, in the board's columns Lists the tasks that need you, are working or are in review above the project tree, grouped by the Kanban board's columns, so the board's attention half stays on screen while you work (the board itself is an overlay that deselects the open task). Settled and Not started show a count until opened. Off by default; a check row in the Project list options menu and Settings > Appearance > Sidebar write the one pref. Once on, the section starts open, and its fold and each bucket's persist in scoped localStorage. Buckets come only from the board's precedence: taskBoardColumn now delegates to boardColumnFromFacts over three raw facts, and the section calls the same function. Nothing is stored, and there is no sidebar-only rule. The project tree does not change. The facts live in a record of their own (useStatusTabFacts), not on SidebarTaskFacts, so an agent starting a turn never re-renders the Sidebar body, and the section costs nothing while it is off. Rows select their badge as a value, carry data-status-task-id and none of the tree's data-sidebar-* attributes, and render their badges under their own testids, so every one-row-per-task assumption still holds. Tests: the facts path agrees with taskBoardColumn over the whole matrix, bucket order and filtering, the prefs' bail on unchanged values, fan-out pins (each verified by injecting the regression), and an e2e spec of eight cases. Docs: ui.md, performance.md, profiles.md, e2e-coverage.md. Refs #298 --- docs/e2e-coverage.md | 1 + docs/performance.md | 2 +- docs/profiles.md | 4 +- docs/ui.md | 114 +++++ e2e/specs/sidebar-status.e2e.ts | 402 ++++++++++++++++++ src/components/TaskPrBadge.tsx | 6 +- src/components/TaskWorkBadge.tsx | 18 +- src/components/settings/AppearanceSection.tsx | 10 + src/components/sidebar/Sidebar.tsx | 26 +- src/components/sidebar/StatusSection.tsx | 213 ++++++++++ src/lib/sidebarStatus.test.ts | 140 ++++++ src/lib/sidebarStatus.ts | 111 +++++ src/lib/taskBoardState.test.ts | 44 ++ src/lib/taskBoardState.ts | 41 +- src/locales/en/settings.ts | 4 + src/locales/en/sidebar.ts | 5 + src/locales/zh-CN/settings.ts | 4 + src/locales/zh-CN/sidebar.ts | 5 + src/store/prefs.test.ts | 54 +++ src/store/prefs.ts | 49 +++ src/store/selectorFanout.test.ts | 110 +++++ src/store/sidebarTabs.ts | 84 +++- 22 files changed, 1419 insertions(+), 28 deletions(-) create mode 100644 e2e/specs/sidebar-status.e2e.ts create mode 100644 src/components/sidebar/StatusSection.tsx create mode 100644 src/lib/sidebarStatus.test.ts create mode 100644 src/lib/sidebarStatus.ts diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index 0f2c5366..8bd8f61c 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -26,6 +26,7 @@ until `make e2e` is green and this file reflects it. | ✅ App shell | Renders; `__termic` exposes real store state | `app.e2e.ts` | | ✅ Navigation | Dashboard ↔ History via real clicks | `app.e2e.ts` | | ✅ Kanban view (GH #318) | The Kanban nav entry opens the overlay; untouched tasks land in Not started under their agent's lane divider (one per agent in use); submitting a prompt through the real input path runs the fake agent's busy→idle cycle and moves the card into Settled while its untouched sibling stays behind; clicking a card activates the task and closes the board; dragging a card within its same-project group reorders and persists via `task_reorder`; dragging to another column snaps back with no dialog and no write; dropping on the Archived column runs the real confirm dialog and the card lands in the Archived column; the card's column accent edge is asserted by computed style so a dropped color-mix cannot ship invisibly; under a custom archive limit the column renders exactly the N most recent archived cards, newest first, the older ones (and a task archived before them) fall off, and the badge keeps the full count; Settings → Tasks' three-way limit control (default / unlimited / custom) reveals the number input only on custom and stores a typed number as-is, with no bounds | `board.e2e.ts`, `settings.e2e.ts` | +| ✅ Sidebar status section (#298) | Off by default; the Project list options check row turns it on, open, above the PROJECTS header; tasks nobody has opened are counted under Not started, folded, and unfold into exactly that many rows in the tree's relative order; a status row carries `data-status-task-id` and none of the tree's `data-sidebar-*` attributes, so each task still has one `[data-sidebar-task-id]`; attention seeded on a background agent lists it under Needs attention with `status-work-badge` while the tree keeps its own `work-badge` and the copy adds none; clicking the row opens the task, re-expands its folded project in the tree, and (the visit clearing `unread`) the row moves on to Not started carrying the active mark and a painted background, measured; the section's fold and each bucket's are written to scoped localStorage and survive the section being turned off and on; a held open PR lookup on a worktree task with a persisted identity lists it under In review with `status-pr-badge` while the first bare `task-pr-badge` stays the tree's, and a merge moves it out; Settings > Appearance > Sidebar's switch and the menu row write the same pref; the icon rail carries no section and the hover overlay carries exactly one. Working is covered by `src/lib/sidebarStatus.test.ts` and the fan-out pins, not by racing the fake agent | `sidebar-status.e2e.ts` | | ✅ History scrolling | The archive pane fills its overlay instead of sizing to its content, so an archive taller than the window overflows INSIDE the scroller: the last row starts out of view and scrolling brings it in. Filtering down and back keeps the pane full-height | `app.e2e.ts` | | ✅ Create (wizard) | NewTaskDialog: name + shell CLI + Main-checkout → Create → task exists | `task.e2e.ts` | | ✅ Check out an existing branch | NewTaskDialog's "Existing branch" mode: a remote-only branch picked from the list becomes a worktree task on a local branch TRACKING it (HEAD on the colleague's commit, upstream set); a branch never fetched is typed, flagged as unfetched, and fetched on create; an unknown name fails the pending task with the "no branch" error and leaves no local branch behind (the old path cut a fresh one from main); "New branch instead" restores the ordinary form; archiving the checkout with delete-branch and restoring it puts it back on the colleague's commit, not a branch cut from main. The resolution rules (local wins, remote prefix, fetch on/off, no remote, invalid names) are pinned by `checkout_existing_branch` tests in `lib.rs`; restore's branch recreation by the `ensure_restore_branch` tests; the picker rows by `existingBranch.test.ts` | `task.e2e.ts` | diff --git a/docs/performance.md b/docs/performance.md index a545d5ce..05b32758 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -7,7 +7,7 @@ 2b. **Hiding the WINDOW does not pause the renderers.** Same trap as 2, one level up, and the reason windowless mode has a webview half at all. `win.hide()` (plus `ActivationPolicy::Accessory`) takes the window off screen but leaves the DOM fully laid out — measured: `document.visibilityState === "hidden"` while `.xterm-screen` still reported **1368×1190 with 7 live canvases**. xterm keys its pause on ZERO GEOMETRY, which only `display:none` produces, so a windowless Termic would keep running WebGL draws for a window nobody can see. Rust emits `termic://windowless` on every windowless edge and `MainArea` drops the ACTIVE pane's display exemption (`src/lib/windowlessMode.ts`), which is what actually stops the draws. Deliberately NOT keyed on `visibilitychange`: that also fires for an occluded window or a Space switch, and collapsing panes on every Space switch would churn layout and xterm viewport state for a window still one gesture away. Measured cost, 3 tasks idle, WITH the collapse in place: hidden 0.23% CPU vs visible 0.33%. Both are near zero, so treat that delta as directional, not as a headline saving - and memory is not reclaimed at all (every mounted task keeps its scrollback and React tree). Windowless mode is about keeping agents alive, not about getting cheaper. Under load the comparison was confounded by the measurement harness and no reliable figure was obtained. THE 1 Hz CLAMP IS NOW LOAD-BEARING: `--wait`, `termic list` and the work-done indicator all ride the settle signal, and there is exactly one timer behind every settle path (the `setInterval` in TerminalPane's settle effect, period `SAMPLE_MS`). A knob tuned under the clamp stops being observable the moment Termic has no window, and nothing else catches it. The four knobs live in `src/lib/settleTiming.ts` with the floor asserted in `settleTiming.test.ts` — tune them freely above the floor; going below should mean deleting a test that says why. 3. **WebGL non-negotiable.** Load AFTER `term.open(host)`. Dispose `webglAddon` BEFORE `term.dispose()` — render loop fires on half-disposed terminal otherwise (`_isDisposed` crash). Same fix in TerminalPane AND AuxTerminal. A LOST context is a different thing from a disposed one and must be RECOVERED, not just disposed: WKWebView reaps GPU resources for an idle webview (sleep, hours backgrounded, memory pressure) and every context in the process dies at once, leaving every pane black with a live PTY behind it. `loadTerminalRenderer` re-attaches a fresh addon on a sliding budget (`CONTEXT_LOSS_MAX` per `CONTEXT_LOSS_WINDOW_MS`, then it stays on the DOM renderer and force-`refresh()`es until the losses age out), and probes `gl.isContextLost()` on focus/visibilitychange because a suspended webview can drop the context without ever firing `webglcontextlost`. Any new path that attaches WebGL must check the budget, or it silently undoes the give-up, and any new path must also attach only where the pane has pixels. A context WebKit RESTORES needs the same rebuild and reports none of this: `onContextLoss` never fires and `isContextLost()` is false, while xterm's in-place repair leaves the renderer on a stale glyph atlas, so the raw `webglcontextlost`/`webglcontextrestored` events on the addon's canvas are the signals that actually cover it. And a pane can go blank with NONE of that firing and no focus/visibility edge to probe on (a laptop driven over screen sharing, Termic frontmost, display asleep): the first input after `AWAY_MS` without any (`src/lib/userPresence.ts`) rebuilds the addon unconditionally, recovering a state nothing can detect. Mechanics and the "don't reduce this to a bare dispose" corollary: [gotchas.md](gotchas.md). 4. **`lineHeight: 1.0` in xterm.** Anything else inflates cells; TUIs show ribbons between rows. -5. **Tight Zustand selectors.** Never destructure the whole store. Use frozen empty constants (`EMPTY_TABS`) for referential stability — React 19 warns "getSnapshot should be cached". GUARDED: `src/store/selectorFanout.test.ts` mounts 500 `useTaskTabs` subscribers, runs 1000 `setSidebarWidth` writes (a sidebar drag) and asserts **zero** snapshot invalidations. Selector bodies are exported from `app.ts` (`selectTaskTabs`, `selectActiveTabId`) so the test measures the real thing. Making a selector derive a fresh array turns that 0 into 500,000 and fails the build — verified by injecting the regression, not assumed. **Selecting the whole `tabs` map is the same mistake one level up.** The Sidebar did (`useApp(s => s.tabs)`) to draw a handful of per-task booleans, and its rows were not memoized, so every `lastOutputAt` stamp (bear trap 9 limits those to one per 500 ms per streaming terminal; the problem was fan-out, not rate) re-rendered the sidebar and every row in it. A WebContent profile of the installed 1.9.1 app with 16 tasks and 10 live claude PTYs had the main thread about 12% busy, about 70% of that DOM timer callbacks followed by a microtask drain (React sync renders from store writes), about 10% drawing, and object spreads (`globalFuncCopyDataProperties`, `putOwnDataPropertyBatching`) as the hot leaves. The fix (`src/store/sidebarTabs.ts`) has two layers with opposite rules. The body reads only `SidebarTaskFacts`, an INCLUSION list: `tabs` is not in its scope, so a new read has to become a fact, and the selector returns the same record until a fact changes. Each row reads its own tabs through a comparator that skips `ROW_HIDDEN_TAB_FIELDS`, an EXCLUSION list, so a field missing from it costs a render and never shows stale; and `TaskRowSlot` is `memo`ized with a stable drag callback, so a body render passes through rows whose props did not move. Do not put the exclusion comparator on the whole map for the body: agents rewrite `liveTitle` about once a second while they work, and the body draws none of it. GUARDED in the same file: 1000 round-robin stamps over 16 tasks give 0 invalidations of the body and of every row (the old subscriptions give 1000 each); a finished turn, an attention mark, a tab rename and a main agent exiting each reach the body and exactly their own row; a live title reaches only its row; and a source check fails if `Sidebar.tsx` selects `s => s.tabs` again. Each was verified by injecting the regression. +5. **Tight Zustand selectors.** Never destructure the whole store. Use frozen empty constants (`EMPTY_TABS`) for referential stability — React 19 warns "getSnapshot should be cached". GUARDED: `src/store/selectorFanout.test.ts` mounts 500 `useTaskTabs` subscribers, runs 1000 `setSidebarWidth` writes (a sidebar drag) and asserts **zero** snapshot invalidations. Selector bodies are exported from `app.ts` (`selectTaskTabs`, `selectActiveTabId`) so the test measures the real thing. Making a selector derive a fresh array turns that 0 into 500,000 and fails the build — verified by injecting the regression, not assumed. **Selecting the whole `tabs` map is the same mistake one level up.** The Sidebar did (`useApp(s => s.tabs)`) to draw a handful of per-task booleans, and its rows were not memoized, so every `lastOutputAt` stamp (bear trap 9 limits those to one per 500 ms per streaming terminal; the problem was fan-out, not rate) re-rendered the sidebar and every row in it. A WebContent profile of the installed 1.9.1 app with 16 tasks and 10 live claude PTYs had the main thread about 12% busy, about 70% of that DOM timer callbacks followed by a microtask drain (React sync renders from store writes), about 10% drawing, and object spreads (`globalFuncCopyDataProperties`, `putOwnDataPropertyBatching`) as the hot leaves. The fix (`src/store/sidebarTabs.ts`) has two layers with opposite rules. The body reads only `SidebarTaskFacts`, an INCLUSION list: `tabs` is not in its scope, so a new read has to become a fact, and the selector returns the same record until a fact changes. Each row reads its own tabs through a comparator that skips `ROW_HIDDEN_TAB_FIELDS`, an EXCLUSION list, so a field missing from it costs a render and never shows stale; and `TaskRowSlot` is `memo`ized with a stable drag callback, so a body render passes through rows whose props did not move. Do not put the exclusion comparator on the whole map for the body: agents rewrite `liveTitle` about once a second while they work, and the body draws none of it. GUARDED in the same file: 1000 round-robin stamps over 16 tasks give 0 invalidations of the body and of every row (the old subscriptions give 1000 each); a finished turn, an attention mark, a tab rename and a main agent exiting each reach the body and exactly their own row; a live title reaches only its row; and a source check fails if `Sidebar.tsx` selects `s => s.tabs` again. Each was verified by injecting the regression. The sidebar's status section (docs/ui.md "The sidebar's status section") is a third consumer with a facts record of its OWN (`useStatusTabFacts`), and both alternatives are traps: as fields on `SidebarTaskFacts`, every idle -> working flip of every agent would re-render the whole body, section on or off; derived per row from `useRowTabs`, it would miss a task's first input, since `lastInputAt` is one of the fields the row comparator holds back. Its rows select their badge as a value, not their tabs, so a live title reaches none of them. GUARDED in the same file: stamps, live titles and a sidebar drag invalidate nothing; a task's first `lastInputAt` reaches the section once and its second not at all; a working flip reaches the section and NOT the body; and a source check fails if `StatusSection.tsx` selects `tabs`. Each was verified by injecting the regression. 6. **`Math.round` every dimension.** Sub-pixel widths blur glyphs in WKWebView. All sidebar/right-panel/footer/split setters round on write AND on `localStorage` read. 7. **Disable transitions during drag.** `App.tsx` grid uses `transition: var(--cols-transition, …)` and `ResizeHandle` sets `--cols-transition: none` on `` while dragging. 8. **A store setter that writes an UNCHANGED value is an idle CPU leak.** Zustand 5's `setState` does `Object.assign({}, state, next)` over the whole ~233-key `AppState`, and a fresh `tabs` record then invalidates every selector in every mounted task plus three module-level `useApp.subscribe` consumers (`cliAgentState`, `trayAttention`, `useAttentionNotifier`), each of which walks all tasks × all tabs. So the cost of one write is paid across the whole app, not just the tab that changed. Those three now return early unless something they read moved (`tabs`, `tasks`, `agents`, and `projects` for the tray), and the notifier skips every task whose array is unchanged, but a real tab write still costs the two debounced ones a full walk. `lastOutputAt` is such a write every time, so it reaches them bounded only by their 80 ms and 150 ms debounce; moving it out of the reactive store is the lever left. `setTabLiveTitle` had no equality check while `setWorkState` and `setWorkProgress` did, and that asymmetry was worth ~a third of idle CPU: xterm fires `onTitleChange` for EVERY OSC 0/2 without comparing it to the previous value (`InputHandler.setTitle`), and an agent TUI re-emits its unchanged title while it sits at the prompt. Measured on a 16-terminal fixture doing nothing but repainting one title twice a second per terminal: **19.02% of a core and 62 store writes/s, falling to 12.70% and 31 writes/s** once the setter bailed. Any new setter on a PTY-driven path needs the same bail, and the invariant is a count assertion (`app.test.ts` "notifies subscribers ONCE for a title repainted 100 times"), so it can gate a PR where a timing would not. NB the profile of this failure has **zero** WebCore layout/paint frames — it is all `globalFuncCopyDataProperties` plus GC — so "the terminals aren't drawing" does not mean the terminals are free. diff --git a/docs/profiles.md b/docs/profiles.md index cb5e3968..91c1abb8 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -274,7 +274,9 @@ colors and prompt library from exactly the keys they are already in. No migration, and nothing resets on the release that ships this. Scoped: project/task/group collapse state, folder colors, `taskExpandMode`, -`hideInactiveProjects`, `newTaskLast*`, member modes, the prompt library. +`hideInactiveProjects`, the status section's switch and folds +(`showStatusSection`, `statusSectionCollapsed`, `statusBucketCollapsed`), +`newTaskLast*`, member modes, the prompt library. **NOT scoped, on purpose:** theme, fonts, terminal and editor settings, shortcut bindings. Those are machine-level (muscle memory does not change per identity) diff --git a/docs/ui.md b/docs/ui.md index f05624a0..75654d26 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -447,6 +447,117 @@ badge always shows the full count, and History still lists everything. [src/lib/taskBoardState.ts](../src/lib/taskBoardState.ts) is the one sort + cap; the badge reads the uncapped filter. +## The sidebar's status section + +A STATUS section above PROJECTS, in the same scroller, that lists the tasks +needing you or in flight, grouped by the Kanban board's columns. It is the +board's attention half, compressed and always on screen: the board answers +"what stage is everything at", but it is a main-area overlay, and opening it +deselects the open task (`setView` sets `activeTaskId: null`), so it is never +on screen while you work (#298). + +**Off by default** (`prefs.showStatusSection`): with two tasks it is clutter, +with twenty it is the point. Two switches write the one pref: a check row in +the Project list options menu next to "Collapse inactive projects", and +Settings > Appearance > Interface > Sidebar. Once on, the section starts +OPEN, since turning it on is already the choice to see it; its header folds +it, and that fold and each bucket's are prefs too (`statusSectionCollapsed`, +`statusBucketCollapsed`, scoped localStorage keys, setters that bail on an +unchanged value). + +**It is a copy, and the tree does not change.** Every task keeps its one home +in the project tree; the section lists a subset again. Copying rather than +moving is what keeps spatial memory intact (Gajos et al., AVI 2006, on split +interfaces; Linear's Favorites above Your Teams is the same shape). It copies +the ACTIONABLE subset only: a full second tree of every task is the case that +research found adds a second place to look, and the full grouping already +lives on the board. + +### Buckets are board columns + +Every task's bucket is `boardColumnFromFacts` in +[src/lib/taskBoardState.ts](../src/lib/taskBoardState.ts), the same +precedence the board uses (`taskBoardColumn` delegates to it). Nothing is +stored, and there is no sidebar-only rule: if a bucket looks wrong, the fix +goes in `taskBoardState.ts` and the board moves with it. The layout +(`statusBuckets` in [src/lib/sidebarStatus.ts](../src/lib/sidebarStatus.ts)) +is the board's columns without Archived, attention first: + +| Bucket | Default | Why | +| --- | --- | --- | +| Needs attention | listed | the reason the section exists | +| Working | listed | what is in flight | +| In review | listed | a PR is waiting on someone | +| Settled | count only | the largest bucket, and the least urgent | +| Not started | count only | session-scoped: after a relaunch every unopened task is here | + +An empty bucket is hidden; the header stays even when every bucket is, so the +section cannot silently vanish. The labels are `chrome:board.col*`, so a +bucket and its column cannot be called two different things. The work prefs +gate it as they gate the board: `attentionIndicator` off empties Needs +attention, `workingIndicator` off empties Working. + +### Rows + +A lighter row than the tree's, modelled on the dashboard's: agent glyph, +label, project name in the faint colour, PR chip, work badge. No terminal +children, no drag, no rename, no run controls, no menu. Clicking it is +`setActiveTask`, which reveals the task in the tree (expands its project, +folder and group) the way every other way of opening a task does. The active +task is marked in both places (`data-active` here). + +Opening a task is also "I've seen this": `setActiveTask` clears `unread` on +every tab, the write that silences the tree's bell. So a row clicked under +Needs attention leaves it straight away, for whatever bucket its other +evidence gives it, and when that is Settled (folded by default) it drops out +of view. The board behaves the same way; the section has no rule of its own. + +Rows keep TREE order: `visualProjectOrder`, then each project's rows as the +tree lays them out (`layoutTaskList`, so a task group is one block at its +first member's position). The section walks projects rather than tasks, so a +task whose project left the profile is skipped exactly as the tree skips it, +and a row never shuffles inside its bucket; it moves only when its bucket +changes. The per-project task filter (#324) does not apply here. + +`TaskRow` is not reused: its rename and auto-expand effects would run twice +per task, and every auto-expand would be a second whole-state write. + +**Identity.** A status row carries `data-status-task-id` and NONE of +`data-sidebar-task-id`, `data-sidebar-task-project-id` or +`data-sidebar-task-row`. The task drag's hit tests, `SpawnLinksOverlay` and +the e2e helpers all assume one `[data-sidebar-task-id]` per task. Its badges +render under their own testids (`status-work-badge`, `status-pr-badge`), so a +bare `task-pr-badge` query still returns the tree's, first in the document. + +**Keyboard.** ⌥↑/↓ and ⌘[ / ⌘] keep walking the tree's project order; the +section adds no stops to them. The rows themselves take focus and Enter. + +**The icon rail does not carry it.** The hover-reveal overlay is a full +sidebar and shows it there. + +### Rendering + +The section's facts are a record of their own (`useStatusTabFacts` in +[src/store/sidebarTabs.ts](../src/store/sidebarTabs.ts)): three raw booleans +per task (`attention`, `working`, `untouched`), cached per task the way the +body's facts are. Not more fields on `SidebarTaskFacts`: every idle -> working +flip of every agent would then re-render the whole Sidebar body, section on +or off. Not derived per row from `useRowTabs`: `untouched` reads +`lastInputAt`, which the row selector holds back on purpose. The PR snapshot +is read non-reactively, with a `usePr` re-render trigger like `BoardView`'s. +A row selects its badge as a VALUE, so a working agent's once-a-second title +rewrite re-renders no status row. `selectorFanout.test.ts` pins all of it. + +### Not built + +Batches (a spawn tree via `spawned_by` drawn as one unit across projects, in +the bucket of its most urgent member), a count on the compact rail, and drops +as commands (`boardDropCommand` is reusable once there is a vertical hit +test). Still open: whether a finished turn you have not looked at belongs in +Needs attention (the title-bar pill says yes, the board says Settled), and +whether `taskBoardColumn` should grow a Merged column so "finished and can +go" has an answer on both surfaces. + ## What a task is called (name vs branch) A task's label is decided in ONE place, `taskLabel()` in @@ -1272,6 +1383,9 @@ with a live agent renders the badge TWICE. A bare `[data-testid="work-badge"]` query returns the sidebar's, in document order. Every assertion must scope: `dashboardBadge()` goes through `[data-dashboard-task-id]`, `sidebarBadge()` through `[data-sidebar-task-row]`. +The sidebar's status section is a third surface, and it does NOT add a copy: +its badges render as `status-work-badge` and `status-pr-badge`, scoped through +`[data-status-task-id]`. ### Recent is a way back in, not a second History diff --git a/e2e/specs/sidebar-status.e2e.ts b/e2e/specs/sidebar-status.e2e.ts new file mode 100644 index 00000000..aacd3edf --- /dev/null +++ b/e2e/specs/sidebar-status.e2e.ts @@ -0,0 +1,402 @@ +// The sidebar's status section (docs/ui.md "The sidebar's status section"): +// the toggle in both of its places, bucket membership from the board's own +// derivation, the row identity rules the tree depends on, the click, the +// folds that persist, and the icon rail that does not carry it. +// +// Deterministic by construction, like board.e2e.ts: every task here is filed +// by a state the spec seeds on an IDLE agent (attention, a held PR lookup) or +// by having no tabs at all (Not started). The transient Working bucket is +// covered by src/lib/sidebarStatus.test.ts and the fan-out pins in +// src/store/selectorFanout.test.ts; racing the fake agent's sub-second busy +// window here would be the flaky version of the same assertion. + +import { + archiveTask, + createWorktreeTask, + dismissOverlays, + ensureActiveTask, + openTask, + requireTermicApi, + sidebarBadge, + snap, + textOf, + waitForAgentReady, + waitForAppShell, + waitForText, + waitGone, + waitVisible, +} from "../helpers.js"; + +const SECTION = '[data-testid="status-section"]'; +const HEADER = '[data-testid="status-section-header"]'; +const BUCKET = (b: string) => `${SECTION} [data-status-bucket="${b}"]`; +const BUCKET_HEADER = (b: string) => `${BUCKET(b)} [data-testid="status-bucket-header"]`; +const ROW = (id: string) => `${SECTION} [data-status-task-id="${id}"]`; +const ROW_IN = (b: string, id: string) => `${BUCKET(b)} [data-status-task-id="${id}"]`; +const TOGGLE_ROW = '[data-testid="sidebar-toggle-status-section"]'; +const SETTINGS_LABEL = "Status section"; + +const present = (sel: string) => browser.execute(s => !!document.querySelector(s), sel); + +const ariaExpanded = (sel: string) => + browser.execute(s => document.querySelector(s)?.getAttribute("aria-expanded") ?? null, sel); + +const click = (sel: string) => + browser.execute(s => (document.querySelector(s) as HTMLElement).click(), sel); + +/** Task ids one bucket lists, in DOM order. */ +const bucketIds = (b: string) => + browser.execute( + sel => [...document.querySelectorAll(`${sel} [data-status-task-id]`)] + .map(el => el.dataset.statusTaskId as string), + BUCKET(b), + ); + +/** Open or fold one bucket through its header, a real click. */ +async function setBucketOpen(b: string, open: boolean): Promise { + await waitVisible(BUCKET_HEADER(b)); + if ((await ariaExpanded(BUCKET_HEADER(b))) !== String(open)) await click(BUCKET_HEADER(b)); + await browser.waitUntil(async () => (await ariaExpanded(BUCKET_HEADER(b))) === String(open), { + timeout: 5_000, timeoutMsg: `bucket ${b} never became ${open ? "open" : "folded"}`, + }); +} + +/** The Project list options menu. Radix opens on pointerdown, so a bare + * .click() is not enough (same as projects.e2e.ts's openMenu). */ +async function openListOptions(): Promise { + await waitVisible('[data-testid="sidebar-list-options"]'); + await browser.execute(() => { + const el = document.querySelector('[data-testid="sidebar-list-options"]') as HTMLElement; + const opts = { bubbles: true, pointerType: "mouse", button: 0 } as any; + el.dispatchEvent(new PointerEvent("pointerdown", opts)); + el.dispatchEvent(new PointerEvent("pointerup", opts)); + el.click(); + }); + await waitVisible(TOGGLE_ROW); +} + +/** The value a profile-scoped localStorage key holds, whatever the scope + * prefix is in this run. */ +const stored = (key: string) => + browser.execute(k => { + const hit = Object.keys(localStorage).find(x => x === k || x.endsWith(`:${k}`)); + return hit ? localStorage.getItem(hit) : null; + }, key); + +/** The switch in the settings row whose label matches exactly (the + * settings.e2e.ts helpers, which are local to that file). */ +const settingsSwitch = (label: string, act: "read" | "click") => + browser.execute((lbl, a) => { + const labelEl = [...document.querySelectorAll("div")].find(d => d.textContent?.trim() === lbl); + const sw = labelEl?.closest(".justify-between")?.querySelector('[role="switch"]') as HTMLElement | null; + if (!sw) throw new Error("toggle switch not found for: " + lbl); + if (a === "click") sw.click(); + return sw.getAttribute("aria-checked"); + }, label, act); + +describe("sidebar status section", () => { + let projectId = ""; + let fresh = ""; + let blocked = ""; + let reviewed = ""; + let hoverRevealWas = false; + + /** Every pref this spec touches, back to the shipped defaults. */ + const resetPrefs = () => + browser.execute(() => { + const p = window.__termic!.usePrefs.getState(); + p.setShowStatusSection(false); + p.setStatusSectionCollapsed(false); + const defaults = [["attention", false], ["working", false], ["review", false], ["settled", true], ["backlog", true]] as const; + for (const [b, c] of defaults) p.setStatusBucketCollapsed(b, c); + }); + + before(async () => { + await waitForAppShell(); + await requireTermicApi(); + await dismissOverlays(); + await resetPrefs(); + hoverRevealWas = await browser.execute(() => window.__termic!.usePrefs.getState().sidebarHoverReveal); + projectId = await browser.execute(() => + window.__termic!.useApp.getState().projects.find((p: any) => p.name === "fixture-repo").id as string); + }); + + after(async () => { + await browser.execute((was) => { + const t = window.__termic!; + if (t.useApp.getState().compactSidebar) t.useApp.getState().toggleCompactSidebar(); + t.usePrefs.getState().setSidebarHoverReveal(was); + t.useApp.getState().closeSettings(); + }, hoverRevealWas); + await resetPrefs(); + for (const id of [fresh, blocked, reviewed]) if (id) await archiveTask(id); + }); + + it("is off by default, and the list options menu turns it on above PROJECTS", async () => { + expect(await present(SECTION)).toBe(false); + + await openListOptions(); + await click(TOGGLE_ROW); + await waitVisible(SECTION); + + // Open by default: turning the section on is already the choice to see it. + expect(await ariaExpanded(HEADER)).toBe("true"); + // Above the PROJECTS header (which holds the Add project button), in + // document order. + const above = await browser.execute(sec => { + const s = document.querySelector(sec)!; + const projects = document.querySelector('[data-testid="sidebar-add-project"]')!; + return !!(s.compareDocumentPosition(projects) & Node.DOCUMENT_POSITION_FOLLOWING); + }, SECTION); + expect(above).toBe(true); + expect(await stored("showStatusSection")).toBe("1"); + }); + + it("files tasks nobody has opened under Not started, behind a count, in tree order", async () => { + // Created, never visited: no tabs at all, so no work evidence. + fresh = await openTask("status-fresh", false); + blocked = await openTask("status-blocked", false); + await browser.execute(pid => window.__termic!.useApp.getState().setProjectCollapsed(pid, false), projectId); + await waitVisible(BUCKET("backlog")); + + // Count-only: folded, listing no rows, yet counting them. + expect(await ariaExpanded(BUCKET_HEADER("backlog"))).toBe("false"); + expect(await present(ROW(fresh))).toBe(false); + const count = Number(await textOf(`${BUCKET("backlog")} [data-testid="status-bucket-count"]`)); + expect(count).toBeGreaterThanOrEqual(2); + + await setBucketOpen("backlog", true); + await waitVisible(ROW_IN("backlog", fresh)); + const listed = await bucketIds("backlog"); + // The count is the rows it unfolds into. + expect(listed.length).toBe(count); + // Same relative order as the tree: a row never shuffles inside a bucket. + const tree = await browser.execute( + () => [...document.querySelectorAll("[data-sidebar-task-id]")].map(el => el.dataset.sidebarTaskId as string)); + const inTree = (id: string) => tree.indexOf(id); + const inBucket = (id: string) => listed.indexOf(id); + expect(inTree(fresh)).toBeGreaterThanOrEqual(0); + expect(inTree(blocked)).toBeGreaterThanOrEqual(0); + expect(inBucket(fresh) < inBucket(blocked)).toBe(inTree(fresh) < inTree(blocked)); + + // Identity: the copy carries none of the tree's row attributes, so the + // drag hit tests, the spawn-link overlay and every `[data-sidebar-task-id]` + // helper still find exactly one row per task. + const ident = await browser.execute(id => { + const row = document.querySelector(`[data-status-task-id="${id}"]`) as HTMLElement; + return { + treeRows: document.querySelectorAll(`[data-sidebar-task-id="${id}"]`).length, + sidebarAttrs: row.getAttributeNames().filter(n => n.startsWith("data-sidebar")), + insideTreeRow: !!row.closest("[data-sidebar-task-row]"), + }; + }, fresh); + expect(ident).toEqual({ treeRows: 1, sidebarAttrs: [], insideTreeRow: false }); + }); + + it("lists a blocked agent under Needs attention with its bell, and the tree keeps its own", async () => { + // A task has tabs only once something mounts it; visit it, let the fake + // agent settle into its idle title, then step away so the seed lands on + // a task the user is not looking at. + await ensureActiveTask(blocked); + await waitForAgentReady(blocked); + await browser.execute(() => window.__termic!.useApp.getState().setView("dashboard")); + + // SETUP, not the assertion: the tab state the detector would write. + await browser.execute(id => { + const app = window.__termic!.useApp.getState(); + const tab = (app.tabs[id] ?? []).find((t: any) => t.type === "terminal"); + app.markAttention(id, tab.id, "attention", "needs you"); + }, blocked); + + const bell = `${ROW_IN("attention", blocked)} [data-testid="status-work-badge"][data-work-state="attention"]`; + await waitVisible(bell); + // Needs attention is a listed bucket: open without being asked. + expect(await ariaExpanded(BUCKET_HEADER("attention"))).toBe("true"); + expect(await present(ROW_IN("backlog", blocked))).toBe(false); + // The tree's own badge is untouched, and the copy adds no `work-badge`. + expect(await sidebarBadge(blocked)).toBe("attention"); + expect(await present(`${ROW(blocked)} [data-testid="work-badge"]`)).toBe(false); + await snap("sidebar-status-attention.png"); + }); + + it("a click opens the task, reveals it in the tree, and the row follows it out of Needs attention", async () => { + // Fold the project first, so the reveal is something the click has to do. + await browser.execute(pid => window.__termic!.useApp.getState().setProjectCollapsed(pid, true), projectId); + await waitGone(`[data-sidebar-task-id="${blocked}"]`); + + await click(ROW(blocked)); + await browser.waitUntil( + () => browser.execute(id => document.querySelector("header[data-active-task]")?.getAttribute("data-active-task") === id, blocked), + { timeout: 8_000, timeoutMsg: "the status row's click never opened its task" }, + ); + await waitVisible(`[data-sidebar-task-id="${blocked}"]`); + + // Opening a task is "I've seen this": setActiveTask clears `unread` on + // every tab, the same write that silences the tree's bell. So the row + // leaves Needs attention, and with no other work evidence (the seed was + // the only one) the board's rule files it back under Not started. + await waitGone(ROW_IN("attention", blocked)); + // Measured, not looked at: the active mark and a painted background + // (waited for, since the row eases its colour in). + await browser.waitUntil( + () => browser.execute(sel => { + const el = document.querySelector(sel) as HTMLElement | null; + return el?.dataset.active === "true" && getComputedStyle(el).backgroundColor !== "rgba(0, 0, 0, 0)"; + }, ROW_IN("backlog", blocked)), + { timeout: 5_000, timeoutMsg: "the status row never painted itself active under Not started" }, + ); + // And no other status row claims it. + const actives = await browser.execute( + sec => [...document.querySelectorAll(`${sec} [data-active]`)].map(el => el.dataset.statusTaskId), + SECTION, + ); + expect(actives).toEqual([blocked]); + }); + + it("remembers the section's fold and each bucket's", async () => { + // The whole section folds to its header; the buckets go, the header stays. + await click(HEADER); + await waitGone(`${SECTION} [data-status-bucket]`); + await waitVisible(HEADER); + expect(await ariaExpanded(HEADER)).toBe("false"); + expect(await stored("statusSectionCollapsed")).toBe("1"); + + // Turning the section off and on keeps the fold: it is a pref, not + // component state. + await browser.execute(() => window.__termic!.usePrefs.getState().setShowStatusSection(false)); + await waitGone(SECTION); + await browser.execute(() => window.__termic!.usePrefs.getState().setShowStatusSection(true)); + await waitVisible(HEADER); + expect(await ariaExpanded(HEADER)).toBe("false"); + + await click(HEADER); + await waitVisible(BUCKET_HEADER("backlog")); + expect(await stored("statusSectionCollapsed")).toBe("0"); + + // A bucket's fold is stored as an override of its default. Not started + // was opened earlier; folding it again writes that back. + expect(await ariaExpanded(BUCKET_HEADER("backlog"))).toBe("true"); + expect(JSON.parse((await stored("statusBucketCollapsed")) ?? "{}").backlog).toBe(false); + await setBucketOpen("backlog", false); + expect(JSON.parse((await stored("statusBucketCollapsed")) ?? "{}").backlog).toBe(true); + // And a listed bucket folds too. Re-seed the bell (SETUP), which the + // visit in the case above cleared. Off the task first: on the ACTIVE task + // the seeded mark does not hold (measured: the store's `unread` reads + // null straight after, and the tree's badge shows nothing either). + await browser.execute(() => window.__termic!.useApp.getState().setView("dashboard")); + await browser.execute(id => { + const app = window.__termic!.useApp.getState(); + const tab = (app.tabs[id] ?? []).find((t: any) => t.type === "terminal"); + app.markAttention(id, tab.id, "attention", "needs you"); + }, blocked); + await waitVisible(ROW_IN("attention", blocked)); + await setBucketOpen("attention", false); + expect(await present(ROW(blocked))).toBe(false); + await setBucketOpen("attention", true); + await waitVisible(ROW_IN("attention", blocked)); + }); + + it("puts a task with an open PR in review, and a merge takes it out", async () => { + reviewed = await createWorktreeTask("status-review", "status-review-branch", false); + // The identity Rust persists once a lookup finds a PR, patched into the + // store the way store/pr.ts's refresh writes it back. SETUP: the poller + // cannot find a PR on the fixture's local remote. + await browser.execute(id => { + window.__termic!.useApp.setState((st: any) => ({ + tasks: st.tasks.map((t: any) => t.id === id + ? { ...t, pr_number: 77, pr_provider: "github", pr_url: "https://github.com/acme/widgets/pull/77" } + : t), + })); + }, reviewed); + const lookup = (state: string) => ({ + provider: "github", + remote_url: "https://github.com/acme/widgets.git", + status: "ok", + message: "", + pr: { + provider: "github", number: 77, url: "https://github.com/acme/widgets/pull/77", + title: "Teach the parser about trailing commas", state, checks: "passing", review: "none", + base: "main", head: "status-review-branch", + }, + }); + /** Hold a lookup in place until the DOM agrees: a real poll of this task + * can land between the seed and the read and replace it. */ + const holdUntil = (state: string, done: () => Promise, msg: string) => + browser.waitUntil(async () => { + await browser.execute((id, lk) => { + window.__termic!.usePr.setState((s: any) => ({ + byTask: { ...s.byTask, [id]: { lookup: lk, loading: false, fetchedAt: Date.now() } }, + })); + }, reviewed, lookup(state)); + return done(); + }, { timeout: 15_000, timeoutMsg: msg }); + + const chip = `${ROW_IN("review", reviewed)} [data-testid="status-pr-badge"][data-pr-state="open"]`; + await holdUntil("open", () => present(chip), "the task never showed under In review with an open PR chip"); + // The tree's chip keeps its testid and stays the first in the document, + // so the specs that query `task-pr-badge` bare still read the tree's. + expect(await present(`${ROW(reviewed)} [data-testid="task-pr-badge"]`)).toBe(false); + expect(await browser.execute(() => + !document.querySelector('[data-testid="task-pr-badge"]')?.closest("[data-status-task-id]"))).toBe(true); + + // Merged falls through, here to Not started: nothing has run in it. + await holdUntil("merged", async () => + !(await present(ROW_IN("review", reviewed))) && (await present(BUCKET("backlog"))), + "a merged PR never left In review"); + await setBucketOpen("backlog", true); + await waitVisible(ROW_IN("backlog", reviewed)); + }); + + it("Settings > Appearance > Sidebar writes the same switch", async () => { + await browser.execute(() => window.__termic!.useApp.getState().openSettings("appearance")); + await waitVisible('[data-appearance-tab="interface"]'); + await click('[data-appearance-tab="interface"]'); + await waitForText(SETTINGS_LABEL); + expect(await settingsSwitch(SETTINGS_LABEL, "read")).toBe("true"); + + await settingsSwitch(SETTINGS_LABEL, "click"); + await waitGone(SECTION); + expect(await stored("showStatusSection")).toBe("0"); + await settingsSwitch(SETTINGS_LABEL, "click"); + await waitVisible(SECTION); + await browser.execute(() => window.__termic!.useApp.getState().closeSettings()); + + // The menu row agrees: it shows the check, and turns the section off. + await openListOptions(); + expect(await present(`${TOGGLE_ROW} svg`)).toBe(true); + await click(TOGGLE_ROW); + await waitGone(SECTION); + await openListOptions(); + expect(await present(`${TOGGLE_ROW} svg`)).toBe(false); + await click(TOGGLE_ROW); + await waitVisible(SECTION); + }); + + it("the icon rail does not carry it, and the hover overlay does", async () => { + await browser.execute(() => { + const t = window.__termic!; + t.usePrefs.getState().setSidebarHoverReveal(false); + if (!t.useApp.getState().compactSidebar) t.useApp.getState().toggleCompactSidebar(); + }); + // Rail only: nothing renders it. + await waitGone(SECTION); + + // With hover reveal the full sidebar is kept mounted, off screen, over + // the rail. It carries the section; the rail still does not. + await browser.execute(() => window.__termic!.usePrefs.getState().setSidebarHoverReveal(true)); + // Present, not visible: the retracted overlay sits translated off screen. + await browser.waitUntil(() => present(SECTION), { + timeout: 5_000, timeoutMsg: "the hover overlay never mounted the status section", + }); + const where = await browser.execute(sec => + [...document.querySelectorAll(sec)].map(s => !!s.closest("[aria-hidden]")), SECTION); + expect(where).toEqual([true]); + + await browser.execute(() => window.__termic!.useApp.getState().toggleCompactSidebar()); + await browser.waitUntil( + () => browser.execute(sec => document.querySelectorAll(sec).length === 1 + && !document.querySelector(sec)!.closest("[aria-hidden]"), SECTION), + { timeout: 5_000, timeoutMsg: "the full sidebar did not get its status section back" }, + ); + }); +}); diff --git a/src/components/TaskPrBadge.tsx b/src/components/TaskPrBadge.tsx index 8a5224a2..8abbd851 100644 --- a/src/components/TaskPrBadge.tsx +++ b/src/components/TaskPrBadge.tsx @@ -16,7 +16,9 @@ import { openPath } from "@/lib/ipc"; import { forgeName, prNounShort, prRef } from "@/lib/forge"; import type { Task } from "@/lib/types"; -export function TaskPrBadge({ task }: { task: Task }) { +/** `testId`: the sidebar's status section passes its own, so the tree's + * `task-pr-badge` stays the first one in document order. */ +export function TaskPrBadge({ task, testId = "task-pr-badge" }: { task: Task; testId?: string }) { const { t } = useTranslation("chrome"); const pr = usePr(s => s.byTask[task.id]?.lookup?.pr ?? null); const url = pr?.url ?? task.pr_url ?? null; @@ -48,7 +50,7 @@ export function TaskPrBadge({ task }: { task: Task }) { @@ -1231,6 +1238,23 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { {t("collapseInactiveHint")} + {/* Same check-row shape. Mirrored in Settings > Appearance > + Sidebar, which writes the same pref. */} + setShowStatusSection(!showStatusSection)} + className={showStatusSection + ? "bg-[var(--color-sel)] data-[highlighted]:bg-[var(--color-sel)]" + : undefined} + > + {showStatusSection + ? + : } +
+ {t("showStatusSection")} + {t("showStatusSectionHint")} +
+
)} diff --git a/src/components/sidebar/StatusSection.tsx b/src/components/sidebar/StatusSection.tsx new file mode 100644 index 00000000..b0f612ec --- /dev/null +++ b/src/components/sidebar/StatusSection.tsx @@ -0,0 +1,213 @@ +// The sidebar's STATUS section, above PROJECTS (docs/ui.md "The sidebar's +// status section"): the Kanban board's attention half, compressed and always +// on screen. It is a COPY: every task keeps its one home in the project tree, +// and this lists the actionable subset of the same tasks again. +// +// Buckets are the board's columns, from the board's own precedence +// (src/lib/sidebarStatus.ts over boardColumnFromFacts). Nothing is stored. +// +// Rendering discipline (bear traps 5 and 8): +// - The section reads three raw facts per task from a record of its own +// (useStatusTabFacts), never `tabs`, so an output stamp or a live title +// re-renders nothing here. The PR snapshot is read non-reactively; a tiny +// usePr subscription is the re-render trigger, the way BoardView does it. +// - Each row selects its own badge as a VALUE and its own active flag as a +// boolean, so a task switch re-renders two rows and a working agent's title +// churn re-renders none. +// +// Identity: a row carries `data-status-task-id` and NONE of the tree's +// `data-sidebar-task-*` attributes, and its badges use their own testids. The +// task drag, SpawnLinksOverlay and the e2e helpers all assume one +// `[data-sidebar-task-id]` per task. Rows are mouse-only: keyboard navigation +// walks the tree's project order, which this section shares but does not own. + +import { memo, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useApp } from "@/store/app"; +import { usePrefs } from "@/store/prefs"; +import { usePr } from "@/store/pr"; +import { selectStatusRowBadge, selectStatusRowDelegated, useStatusTabFacts } from "@/store/sidebarTabs"; +import { CliIcon, CLI_BRAND_COLOR, resolveIconId } from "@/icons/cli"; +import { TaskWorkBadge } from "@/components/TaskWorkBadge"; +import { TaskPrBadge } from "@/components/TaskPrBadge"; +import { cn } from "@/lib/utils"; +import { taskLabel } from "@/lib/taskLabel"; +import { isStatusBucketCollapsed, statusBuckets, type StatusBucket } from "@/lib/sidebarStatus"; +import type { WorkStatePrefs } from "@/lib/taskWorkState"; +import type { Agent, Task } from "@/lib/types"; + +/** Literal keys, so usedKeys.test.ts can see them. The board's own labels: + * a bucket and its column must not be called two different things. */ +function bucketLabel(bucket: StatusBucket, t: TFunction<"sidebar">): string { + switch (bucket) { + case "attention": return t("chrome:board.colAttention"); + case "working": return t("chrome:board.colWorking"); + case "review": return t("chrome:board.colReview"); + case "settled": return t("chrome:board.colSettled"); + case "backlog": return t("chrome:board.colBacklog"); + } +} + +export function StatusSection() { + const { t } = useTranslation("sidebar"); + const projects = useApp(s => s.projects); + const tasks = useApp(s => s.tasks); + const agents = useApp(s => s.agents); + const facts = useStatusTabFacts(); + const settledHighlight = usePrefs(s => s.settledHighlight); + const workingIndicator = usePrefs(s => s.workingIndicator); + const attentionIndicator = usePrefs(s => s.attentionIndicator); + const useBranchAsTaskName = usePrefs(s => s.useBranchAsTaskName); + const collapsed = usePrefs(s => s.statusSectionCollapsed); + const setCollapsed = usePrefs(s => s.setStatusSectionCollapsed); + const bucketCollapsed = usePrefs(s => s.statusBucketCollapsed); + const setBucketCollapsed = usePrefs(s => s.setStatusBucketCollapsed); + // The board's pref set, so the same toggles fill the same buckets. Stable + // identity: it keys every row's badge selector. + const workPrefs: WorkStatePrefs = useMemo( + () => ({ settledHighlight, workingIndicator, attentionIndicator }), + [settledHighlight, workingIndicator, attentionIndicator], + ); + + // Re-render trigger for PR polls, nothing more: the pr store lives outside + // useApp so its 60s tick re-renders nobody by default, and an open -> + // merged transition moves a task out of In review. Same key as BoardView. + const prKey = usePr(s => Object.values(s.byTask).map(e => e.lookup?.pr?.state ?? "?").join("|")); + + const groups = useMemo( + () => statusBuckets(projects, tasks, facts, usePr.getState().byTask, workPrefs), + // prKey stands in for the snapshot read above. + // eslint-disable-next-line react-hooks/exhaustive-deps + [projects, tasks, facts, workPrefs, prKey], + ); + const projectName = useMemo(() => new Map(projects.map(p => [p.id, p.name])), [projects]); + + return ( +
+ {/* Same type treatment as the PROJECTS header, with the inactive fold's + chevron. It stays when every bucket is empty, so the section cannot + silently vanish. */} + + {!collapsed && groups.map(g => { + const open = !isStatusBucketCollapsed(g.bucket, bucketCollapsed); + const count = g.tasks.length; + const countLabel = count === 1 + ? t("statusBucketCount_one", { count }) + : t("statusBucketCount_other", { count }); + return ( +
+ + {open && g.tasks.map(w => ( + + ))} +
+ ); + })} +
+ ); +} + +/** A lighter row than the tree's TaskRow, modelled on the dashboard's: no + * terminal children, no drag, no rename, no run controls, no menu. TaskRow + * is NOT reused: its rename and auto-expand effects would run twice per + * task, and every auto-expand would be a second whole-state write. */ +const StatusTaskRow = memo(function StatusTaskRow({ task: w, projectName, agents, useBranchAsTaskName, workPrefs }: { + task: Task; + projectName: string; + agents: Agent[]; + useBranchAsTaskName: boolean; + workPrefs: WorkStatePrefs; +}) { + const { t } = useTranslation("sidebar"); + const setActive = useApp(s => s.setActiveTask); + const isActive = useApp(s => s.activeTaskId === w.id); + const selectBadge = useMemo(() => selectStatusRowBadge(w.id, workPrefs), [w.id, workPrefs]); + const selectDelegated = useMemo(() => selectStatusRowDelegated(w.id, workPrefs), [w.id, workPrefs]); + const badge = useApp(selectBadge); + const delegated = useApp(selectDelegated); + const label = taskLabel(w, useBranchAsTaskName); + const labelIsBranch = label !== w.name; + const icon = resolveIconId(w.cli, agents); + + return ( + // A div with a button role, not a - {!collapsed && groups.map(g => { + {t("statusHeader")} + + {groups.map(g => { const open = !isStatusBucketCollapsed(g.bucket, bucketCollapsed); const count = g.tasks.length; const countLabel = count === 1 diff --git a/src/store/prefs.test.ts b/src/store/prefs.test.ts index 81d1c943..6cd5917a 100644 --- a/src/store/prefs.test.ts +++ b/src/store/prefs.test.ts @@ -691,32 +691,27 @@ describe("prefs: status section", () => { }); afterEach(() => { vi.unstubAllGlobals(); }); - it("ships off, and open once turned on, with the count-only buckets folded", async () => { + it("ships off, with no bucket overrides (count-only buckets folded)", async () => { const { usePrefs } = await import("./prefs"); const s = usePrefs.getState(); expect(s.showStatusSection).toBe(false); - expect(s.statusSectionCollapsed).toBe(false); expect(s.statusBucketCollapsed).toEqual({}); }); - it("reads all three back from localStorage, dropping junk bucket entries", async () => { + it("reads both back from localStorage, dropping junk bucket entries", async () => { localStorage.setItem("showStatusSection", "1"); - localStorage.setItem("statusSectionCollapsed", "1"); localStorage.setItem("statusBucketCollapsed", '{"settled":false,"archived":true}'); const { usePrefs } = await import("./prefs"); const s = usePrefs.getState(); expect(s.showStatusSection).toBe(true); - expect(s.statusSectionCollapsed).toBe(true); expect(s.statusBucketCollapsed).toEqual({ settled: false }); }); it("the setters persist", async () => { const { usePrefs } = await import("./prefs"); usePrefs.getState().setShowStatusSection(true); - usePrefs.getState().setStatusSectionCollapsed(true); usePrefs.getState().setStatusBucketCollapsed("settled", false); expect(localStorage.getItem("showStatusSection")).toBe("1"); - expect(localStorage.getItem("statusSectionCollapsed")).toBe("1"); expect(JSON.parse(localStorage.getItem("statusBucketCollapsed")!)).toEqual({ settled: false }); }); @@ -725,7 +720,6 @@ describe("prefs: status section", () => { let notified = 0; const unsub = usePrefs.subscribe(() => { notified++; }); usePrefs.getState().setShowStatusSection(false); - usePrefs.getState().setStatusSectionCollapsed(false); // Already the default for both, so there is no override to write. usePrefs.getState().setStatusBucketCollapsed("attention", false); usePrefs.getState().setStatusBucketCollapsed("backlog", true); diff --git a/src/store/prefs.ts b/src/store/prefs.ts index 5d04a739..31f1cf27 100644 --- a/src/store/prefs.ts +++ b/src/store/prefs.ts @@ -100,7 +100,6 @@ const LS_BOARD_ARCHIVE_LIMIT_MODE = scoped("boardArchiveLimitMode"); const LS_BOARD_ARCHIVE_LIMIT = scoped("boardArchiveLimit"); const LS_BOARD_PINNED_COLUMNS = scoped("boardPinnedColumns"); const LS_SHOW_STATUS_SECTION = scoped("showStatusSection"); -const LS_STATUS_SECTION_COLLAPSED = scoped("statusSectionCollapsed"); const LS_STATUS_BUCKET_COLLAPSED = scoped("statusBucketCollapsed"); const LS_BRANCH_AS_TASK_NAME = "useBranchAsTaskName"; const LS_DOUBLE_SHIFT_MODE = "doubleShiftMode"; @@ -799,9 +798,6 @@ interface PrefsState { * status section"). Off by default: it pays off with many parallel tasks * and is clutter with two. */ showStatusSection: boolean; - /** The section folded to its header. Open by default: turning the section - * on is already the choice to see it. */ - statusSectionCollapsed: boolean; /** Per-bucket overrides of the default fold (count-only buckets start * closed). Only buckets the user toggled are stored. */ statusBucketCollapsed: StatusBucketCollapsed; @@ -966,7 +962,6 @@ interface PrefsState { setBoardArchiveLimit: (n: number) => void; setBoardPinnedColumns: (cols: readonly BoardStateColumn[]) => void; setShowStatusSection: (v: boolean) => void; - setStatusSectionCollapsed: (v: boolean) => void; setStatusBucketCollapsed: (bucket: StatusBucket, collapsed: boolean) => void; setUseBranchAsTaskName: (v: boolean) => void; setDoubleShiftMode: (v: DoubleShiftMode) => void; @@ -1193,7 +1188,6 @@ const initialBoardArchiveLimit = (() => { })(); const initialBoardPinnedColumns = parseBoardPinnedColumns(lsGet(LS_BOARD_PINNED_COLUMNS, "")); const initialShowStatusSection = lsGet(LS_SHOW_STATUS_SECTION, "") === "1"; -const initialStatusSectionCollapsed = lsGet(LS_STATUS_SECTION_COLLAPSED, "") === "1"; const initialStatusBucketCollapsed = parseStatusBucketCollapsed(lsGet(LS_STATUS_BUCKET_COLLAPSED, "")); const initialUseBranchAsTaskName = lsGet(LS_BRANCH_AS_TASK_NAME, "") === "1"; // Absent means never set, and the gesture ships on, left-Shift only. @@ -1284,7 +1278,6 @@ export const usePrefs = create(set => ({ boardArchiveLimit: initialBoardArchiveLimit, boardPinnedColumns: initialBoardPinnedColumns, showStatusSection: initialShowStatusSection, - statusSectionCollapsed: initialStatusSectionCollapsed, statusBucketCollapsed: initialStatusBucketCollapsed, useBranchAsTaskName: initialUseBranchAsTaskName, doubleShiftMode: initialDoubleShiftMode, @@ -1625,19 +1618,14 @@ export const usePrefs = create(set => ({ try { localStorage.setItem(LS_BOARD_PINNED_COLUMNS, JSON.stringify(v)); } catch {} set({ boardPinnedColumns: v }); }, - // The three status-section setters bail on an unchanged value (returning - // `s` is a no-op notify): a fold click re-renders the section, and a - // repeated one should not. + // The status-section setters bail on an unchanged value (returning `s` is + // a no-op notify): a fold click re-renders the section, and a repeated one + // should not. setShowStatusSection: (v) => set(s => { if (s.showStatusSection === v) return s; try { localStorage.setItem(LS_SHOW_STATUS_SECTION, v ? "1" : "0"); } catch {} return { showStatusSection: v }; }), - setStatusSectionCollapsed: (v) => set(s => { - if (s.statusSectionCollapsed === v) return s; - try { localStorage.setItem(LS_STATUS_SECTION_COLLAPSED, v ? "1" : "0"); } catch {} - return { statusSectionCollapsed: v }; - }), setStatusBucketCollapsed: (bucket, collapsed) => set(s => { // Effective state, not the stored override: an absent override already // means the default, so writing the default would change nothing. From 7c5e12315ae4bf842c76fc1546ee4c211079e618 Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:43:34 +0530 Subject: [PATCH 3/7] fix(attention): looking at a question is not answering it An agent blocked on the user (a permission prompt, a question) lost its bell the moment its tab was on screen in a focused window, on opening the task, or on switching to the tab. Nothing then said the agent was still waiting, so a question glanced at and left unanswered read as a finished turn: no bell, and Settled on the board and in the sidebar's status section. docs/agent-states.md already said attention ends on "answering it"; the code did not. The three "seen" paths now leave an attention mark alone (unreadClearsOnSight); every other mark still clears on sight. The bell ends on an answer instead: any key typed in that terminal (claude's permission prompt takes a bare digit, no Enter), a bare Escape or Ctrl-C, or the agent's own done hook (answered through remote control, or given up). xterm's automated replies and arrow keys begin with ESC and do not count. Nor does a working heartbeat, since parallel subagents fire tool hooks while one of them waits on a prompt. Tests: the rule itself, the store's activation and tab-switch paths with a control for the other marks, watchedBadgedTab, an e2e case that pins dot-clears-on-sight against bell-waits-for-a-key, and the status section's click case. Docs: agent-states.md, ui.md, e2e-coverage.md (including the CSS-transition stall found while measuring the row). Refs #298 --- docs/agent-states.md | 21 +++++++++- docs/e2e-coverage.md | 4 +- docs/ui.md | 10 ++--- e2e/specs/agent.e2e.ts | 42 +++++++++++++++----- e2e/specs/sidebar-status.e2e.ts | 59 ++++++++++++++++++++-------- src/components/task/TerminalPane.tsx | 25 ++++++++++-- src/hooks/useSeenWhenWatched.test.ts | 20 +++++++++- src/hooks/useSeenWhenWatched.ts | 13 ++++-- src/lib/taskWorkState.test.ts | 16 +++++++- src/lib/taskWorkState.ts | 13 ++++++ src/store/app.test.ts | 50 +++++++++++++++++++++++ src/store/app.ts | 10 +++-- 12 files changed, 240 insertions(+), 43 deletions(-) diff --git a/docs/agent-states.md b/docs/agent-states.md index d0a1c20c..6d86c875 100644 --- a/docs/agent-states.md +++ b/docs/agent-states.md @@ -32,7 +32,7 @@ that distinction. | 4 | `working` + delegated `partial` | Some of that work came back; the rest runs on | outlined blue dot, or the ring when `partialDoneIndicator` is off | no | the remaining work, or the grace | | 5 | `done` | The turn ended | solid blue dot | YES | focusing the tab, or the next submit | | 6 | `done` / `idle` + delegated | The turn ended and left something running | blue dot, then the dashed ring once acknowledged | yes, once | the leftovers finishing, or the next turn | -| 7 | attention (`unread.reason`) | The agent is blocked ON YOU: a permission prompt, a question | bell | YES | answering it | +| 7 | attention (`unread.reason`) | The agent is blocked ON YOU: a permission prompt, a question | bell | YES | answering it (a key in that terminal, Escape, Ctrl-C) or the agent's done hook; NOT looking at it | | 8 | interrupted | You pressed Escape or Ctrl-C | nothing | no | (already over) | | 9 | failed | A run or setup script exited non-zero | red triangle | no | a re-run | | 10 | ceiling | termic gave up waiting after 20 minutes | nothing (clears to `idle`) | NO | the next heartbeat re-arms working | @@ -129,6 +129,25 @@ ring, because the rest is still running. The next piece of work to report back sets it again, and the last one ends the turn with the usual done. Nothing but the badges reads the flag, so clearing it changes the mark only. +### A question is not answered by looking at it + +Every other mark clears when the tab is in front of you (`setActiveTask`, +`setActiveTabId`, `useSeenWhenWatched`). Attention does not +(`unreadClearsOnSight` in `lib/taskWorkState.ts`): the agent is still +blocked while you read its question. It clears on an answer, meaning any key +you type in that terminal (claude's permission prompt takes a bare digit, no +Enter), a bare Escape or Ctrl-C, or the agent's own done hook (the turn is +over, so nothing in it is waiting: a question answered through claude's +remote control, or one it gave up on). xterm's automated replies begin with +ESC and arrow keys do too, so neither counts. A working heartbeat does not +count either: parallel subagents fire tool hooks while one of them sits on a +permission prompt. The board's "mark settled" drop still clears it, as an +explicit command. + +It used to clear on sight like the rest. A question you had glanced at and +left read as a finished turn: off the bell, and under Settled on the board and +in the sidebar's status section, while the agent sat waiting. + ### Agent messages while delegated In the delegated and partially-done states the agent's own loop has diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index 39a60217..d9798eeb 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -26,7 +26,7 @@ until `make e2e` is green and this file reflects it. | ✅ App shell | Renders; `__termic` exposes real store state | `app.e2e.ts` | | ✅ Navigation | Dashboard ↔ History via real clicks | `app.e2e.ts` | | ✅ Kanban view (GH #318) | The Kanban nav entry opens the overlay; untouched tasks land in Not started under their agent's lane divider (one per agent in use); submitting a prompt through the real input path runs the fake agent's busy→idle cycle and moves the card into Settled while its untouched sibling stays behind; clicking a card activates the task and closes the board; dragging a card within its same-project group reorders and persists via `task_reorder`; dragging to another column snaps back with no dialog and no write; dropping on the Archived column runs the real confirm dialog and the card lands in the Archived column; the card's column accent edge is asserted by computed style so a dropped color-mix cannot ship invisibly; under a custom archive limit the column renders exactly the N most recent archived cards, newest first, the older ones (and a task archived before them) fall off, and the badge keeps the full count; Settings → Tasks' three-way limit control (default / unlimited / custom) reveals the number input only on custom and stores a typed number as-is, with no bounds | `board.e2e.ts`, `settings.e2e.ts` | -| ✅ Sidebar status section (#298) | Off by default; the Project list options check row turns it on, open, above the PROJECTS header; tasks nobody has opened are counted under Not started, folded, and unfold into exactly that many rows in the tree's relative order; a status row carries `data-status-task-id` and none of the tree's `data-sidebar-*` attributes, so each task still has one `[data-sidebar-task-id]`; attention seeded on a background agent lists it under Needs attention with `status-work-badge` while the tree keeps its own `work-badge` and the copy adds none; clicking the row opens the task, re-expands its folded project in the tree, and (the visit clearing `unread`) the row moves on to Not started carrying the active mark and a painted background, measured; the STATUS header is a plain label like PROJECTS, not a fold; each bucket's fold is written to scoped localStorage and survives the section being turned off and on; a held open PR lookup on a worktree task with a persisted identity lists it under In review with `status-pr-badge` while the first bare `task-pr-badge` stays the tree's, and a merge moves it out; Settings > Appearance > Sidebar's switch and the menu row write the same pref; the icon rail carries no section and the hover overlay carries exactly one. Working is covered by `src/lib/sidebarStatus.test.ts` and the fan-out pins, not by racing the fake agent | `sidebar-status.e2e.ts` | +| ✅ Sidebar status section (#298) | Off by default; the Project list options check row turns it on, open, above the PROJECTS header; tasks nobody has opened are counted under Not started, folded, and unfold into exactly that many rows in the tree's relative order; a status row carries `data-status-task-id` and none of the tree's `data-sidebar-*` attributes, so each task still has one `[data-sidebar-task-id]`; attention seeded on a background agent lists it under Needs attention with `status-work-badge` while the tree keeps its own `work-badge` and the copy adds none; clicking the row opens the task and re-expands its folded project in the tree, and the row STAYS under Needs attention (opening is not answering) carrying the active mark and a painted background, measured, until a bare digit typed into the terminal answers it and it moves to Not started; the STATUS header is a plain label like PROJECTS, not a fold; each bucket's fold is written to scoped localStorage and survives the section being turned off and on; a held open PR lookup on a worktree task with a persisted identity lists it under In review with `status-pr-badge` while the first bare `task-pr-badge` stays the tree's, and a merge moves it out; Settings > Appearance > Sidebar's switch and the menu row write the same pref; the icon rail carries no section and the hover overlay carries exactly one. Working is covered by `src/lib/sidebarStatus.test.ts` and the fan-out pins, not by racing the fake agent | `sidebar-status.e2e.ts` | | ✅ History scrolling | The archive pane fills its overlay instead of sizing to its content, so an archive taller than the window overflows INSIDE the scroller: the last row starts out of view and scrolling brings it in. Filtering down and back keeps the pane full-height | `app.e2e.ts` | | ✅ Create (wizard) | NewTaskDialog: name + shell CLI + Main-checkout → Create → task exists | `task.e2e.ts` | | ✅ Check out an existing branch | NewTaskDialog's "Existing branch" mode: a remote-only branch picked from the list becomes a worktree task on a local branch TRACKING it (HEAD on the colleague's commit, upstream set); a branch never fetched is typed, flagged as unfetched, and fetched on create; an unknown name fails the pending task with the "no branch" error and leaves no local branch behind (the old path cut a fresh one from main); "New branch instead" restores the ordinary form; archiving the checkout with delete-branch and restoring it puts it back on the colleague's commit, not a branch cut from main. The resolution rules (local wins, remote prefix, fetch on/off, no remote, invalid names) are pinned by `checkout_existing_branch` tests in `lib.rs`; restore's branch recreation by the `ensure_restore_branch` tests; the picker rows by `existingBranch.test.ts` | `task.e2e.ts` | @@ -67,6 +67,7 @@ until `make e2e` is green and this file reflects it. | ✅ One turn, one notification | A two-stage turn produces TWO sidebar dots and ONE newsworthy edge (the thing the notifier turns into a banner), down BOTH paths: the heuristic one via the title (`#stage`) and the hook one via `OSC 133;C/;D` (`#hookstage`), which reach `fireDone` under opposite guards. GH #276 | `agent.e2e.ts` | | ✅ Split restore | A pane whose tabs are all gone collapses instead of restoring a blank leg; a task whose main tabs were all non-durable still restores with a main tab and a real activeTab | `tabs-layout.e2e.ts` | | ✅ Agent notifications | OSC 9 raises attention carrying the agent's verbatim body; the "waiting for your input" idle nag raises nothing | `agent.e2e.ts` | +| ✅ Seen vs answered | Back at a focused window on the badged tab, a done dot from a turn that finished while away clears on sight, while an attention bell raised while away survives the return (both the tab strip and the sidebar row) and clears only on a keystroke in that terminal: a bare digit, no Enter | `agent.e2e.ts` | | ✅ Welcome wizard layout step | The wizard opens on the step that explains projects, tasks and the worktree vs main-checkout distinction, and its LAST step is still the project picker that carries Finish. Asserts the five pips by label, because inserting a step at the front renumbers every other one and an off-by-one there shows the wrong body or strands Finish | `app.e2e.ts` | | ✅ Work-mark switches | Settings → Notifications draws one switch per state a tab can report, each rendering the REAL badge it governs, in the order that encodes the model (every mid-turn mark under Working). A mark that changes without its row changing fails here | `settings.e2e.ts` | | ✅ Delegated work (agent hooks) | A done hook that finds work OUTSTANDING reports it (`#delegated`, the real `agent delegated: ...` wire format) instead of writing nothing. Three cases, one each for what the measurements found: a `subagent` keeps the turn open, names itself on the badge (`data-delegated`) and is NOT cut short by the detached grace; a report whose ids all predate the turn ends it with a real announced done, which is the per-session hold that used to swallow every later turn; and a detached `shell` nobody resumes ends at the grace (shortened via `localStorage.delegatedGraceMs`) rather than at the silent 20-minute ceiling. See docs/agent-hooks.md "Delegated work" | `agent.e2e.ts` | @@ -312,6 +313,7 @@ These are intentionally NOT covered by written specs — asserting them would be - **Terminal content is not in the DOM** (WebGL canvas) — assert `lastOutputAt`/`liveTitle`/store, never innerText, for PTY output. - **`workState === "working"`** won't flip from a raw `ipc.ptyWrite`; termic gates it on a real submit through the input path. - **Radix menus open on pointerdown** — dispatch `pointerdown`/`pointerup`, not just `.click()` (see `tabs-layout.e2e.ts`). +- **CSS transitions can stall for seconds.** They run on `document.timeline`, which advances only while the window paints frames, and the suite's window often paints none: measured, the timeline moved 13 ms over about 1.5 s while a `transition-colors` row sat at alpha 0.016 of its selection colour. A computed colour read through a transition is therefore a race. Read it with `el.style.transition = "none"` (and put it back), which measures the end state the class asks for (`sidebar-status.e2e.ts`). Unrelated to the WKWebView `transition-colors` border bug in docs/gotchas.md, which never settles in any window. - **Hover-gated controls** (theme picker, History "Restore →") need a dispatched `mouseover`/`mouseenter` first, or drive the underlying store/IPC. - **rAF-deferred effects are frozen when the window is occluded** (e.g. the command palette's `act()` → `requestAnimationFrame`). Assert the synchronous part, or drive the underlying store, rather than the deferred side effect. - **Run/Setup tab PTY spawn is rAF-gated** in TerminalPane, so a newly-added run tab's PTY lags on an occluded/offscreen window (CI). Assert the tab is *created* (launch wiring); PTY spawn/execution is covered by task-spawn's agent PTY. diff --git a/docs/ui.md b/docs/ui.md index 24119615..e12400e2 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -506,11 +506,11 @@ children, no drag, no rename, no run controls, no menu. Clicking it is folder and group) the way every other way of opening a task does. The active task is marked in both places (`data-active` here). -Opening a task is also "I've seen this": `setActiveTask` clears `unread` on -every tab, the write that silences the tree's bell. So a row clicked under -Needs attention leaves it straight away, for whatever bucket its other -evidence gives it, and when that is Settled (folded by default) it drops out -of view. The board behaves the same way; the section has no rule of its own. +Opening a task does not answer it. A row under Needs attention stays there, +open task or not, until you answer in that terminal or the agent's turn ends +(docs/agent-states.md "A question is not answered by looking at it"); then it +moves to whatever bucket its other evidence gives it. The board reads the same +field, so it agrees; the section has no rule of its own. Rows keep TREE order: `visualProjectOrder`, then each project's rows as the tree lays them out (`layoutTaskList`, so a task group is one block at its diff --git a/e2e/specs/agent.e2e.ts b/e2e/specs/agent.e2e.ts index ba249ab9..fece4ce2 100644 --- a/e2e/specs/agent.e2e.ts +++ b/e2e/specs/agent.e2e.ts @@ -2101,7 +2101,13 @@ describe("agent notifications", () => { // case stuck: the tab you are already on earns a badge while you are in // another app, you come back, and because the tab never CHANGED nothing // cleared it. Clicking away and back was the only way out. - it("clears a badge on the tab you are looking at, once you are back", async () => { + // + // Every badge but the BELL. An agent blocked on the user is still blocked + // while you read its question, so seeing it is not answering it: the bell + // stays until a key in that terminal (`unreadClearsOnSight`). Clearing it on + // sight filed a question you had glanced at under Settled, on the board and + // in the sidebar's status section, while the agent sat waiting. + it("clears a done dot on the tab you are looking at, and keeps a bell until you answer", async () => { await ensureActiveTask(taskId!); await setWindowPresence(false); await browser.execute((id) => { @@ -2109,23 +2115,41 @@ describe("agent notifications", () => { s.clearAttention(id, s.tabs[id][0].id); }, taskId); + // A turn that finished while you were away: the dot. + await submitToAgent(taskId!, "do something"); + await waitForWorkBadge(taskId!, "done", { + timeout: 20_000, + message: "a turn finished while away never left the done dot", + }); + await setWindowPresence(true); + await waitForWorkBadgeGone(taskId!, "done", { + timeout: 20_000, + message: "returning to a focused window never cleared the dot on the visible tab", + }); + + // A question that arrived while you were away: the bell. + await setWindowPresence(false); await submitToAgent(taskId!, "#osc9 FakeAgent needs your permission"); await waitForWorkBadge(taskId!, "attention", { timeout: 20_000, message: "the badge never appeared while the user was away", }); - - // Still away, and still badged. Without this the test would also pass on a - // bug that simply drops every attention, since the assertion below is that - // a badge went away. + await setWindowPresence(true); + // Back on that very tab, for longer than the instant clear the dot got + // above: still asked, because nobody answered. + await waitPtyQuiet(taskId!, 1_000); expect(await taskViewBadge(taskId!)).toBe("attention"); + expect(await sidebarBadge(taskId!)).toBe("attention"); - // Back at the keyboard, on that very tab. - await setWindowPresence(true); + // Answering is a key in that terminal, not only Enter: claude's + // permission prompt takes a bare digit. + await typeIntoAgent(taskId!, "1"); await waitForWorkBadgeGone(taskId!, "attention", { - timeout: 20_000, - message: "returning to a focused window never cleared the badge on the visible tab", + timeout: 10_000, + message: "a keystroke in the terminal never cleared the bell", }); + // Leave the fake agent's input line as it was for the cases after this. + await typeIntoAgent(taskId!, "\x7f"); }); // Once an agent reports its own state, the terminal TITLE stops being // allowed to end a turn for it. This is the case the whole design turns on: diff --git a/e2e/specs/sidebar-status.e2e.ts b/e2e/specs/sidebar-status.e2e.ts index 0ef5b168..6ae9ae28 100644 --- a/e2e/specs/sidebar-status.e2e.ts +++ b/e2e/specs/sidebar-status.e2e.ts @@ -20,6 +20,7 @@ import { sidebarBadge, snap, textOf, + typeIntoAgent, waitForAgentReady, waitForAppShell, waitForText, @@ -224,7 +225,7 @@ describe("sidebar status section", () => { await snap("sidebar-status-attention.png"); }); - it("a click opens the task, reveals it in the tree, and the row follows it out of Needs attention", async () => { + it("a click opens the task and reveals it in the tree; the row leaves Needs attention only when you answer", async () => { // Fold the project first, so the reveal is something the click has to do. await browser.execute(pid => window.__termic!.useApp.getState().setProjectCollapsed(pid, true), projectId); await waitGone(`[data-sidebar-task-id="${blocked}"]`); @@ -236,26 +237,54 @@ describe("sidebar status section", () => { ); await waitVisible(`[data-sidebar-task-id="${blocked}"]`); - // Opening a task is "I've seen this": setActiveTask clears `unread` on - // every tab, the same write that silences the tree's bell. So the row - // leaves Needs attention, and with no other work evidence (the seed was - // the only one) the board's rule files it back under Not started. - await waitGone(ROW_IN("attention", blocked)); - // Measured, not looked at: the active mark and a painted background - // (waited for, since the row eases its colour in). + // Opening a task is not answering it: the agent is still blocked, so + // the row stays under Needs attention (`unreadClearsOnSight`). Measured, + // not looked at: the active mark, and the background it paints. + // + // The background is read with the row's colour transition switched off. + // Measured in this window: with it running, the background sat at its + // start value (alpha 0, then 0.016) while data-active was already true, + // because `document.timeline.currentTime`, the clock CSS transitions run + // on, moved 13 ms in about 1.5 s of wall time: the window was painting no + // frames. One class change, one transition, never restarted. With + // `transition: none` the same element reads the selection colour, so the + // transition's end state is what is asserted. await browser.waitUntil( () => browser.execute(sel => { const el = document.querySelector(sel) as HTMLElement | null; - return el?.dataset.active === "true" && getComputedStyle(el).backgroundColor !== "rgba(0, 0, 0, 0)"; - }, ROW_IN("backlog", blocked)), - { timeout: 5_000, timeoutMsg: "the status row never painted itself active under Not started" }, - ); + if (el?.dataset.active !== "true") return false; + const was = el.style.transition; + el.style.transition = "none"; + const bg = getComputedStyle(el).backgroundColor; + el.style.transition = was; + return bg !== "rgba(0, 0, 0, 0)" && !/, 0\)$/.test(bg); + }, ROW_IN("attention", blocked)), + { timeout: 5_000 }, + ).catch(async () => { + const why = await browser.execute(id => { + const t = (window.__termic!.useApp.getState().tabs[id] ?? []).find((x: any) => x.type === "terminal"); + const row = document.querySelector(`[data-status-task-id="${id}"]`) as HTMLElement | null; + return JSON.stringify({ + unread: t?.unread ?? null, workState: t?.workState ?? null, + bucket: row?.closest("[data-status-bucket]")?.getAttribute("data-status-bucket") ?? null, + active: row?.dataset.active ?? null, + }); + }, blocked); + throw new Error(`the status row never painted itself active under Needs attention: ${why}`); + }); // And no other status row claims it. const actives = await browser.execute( sec => [...document.querySelectorAll(`${sec} [data-active]`)].map(el => el.dataset.statusTaskId), SECTION, ); expect(actives).toEqual([blocked]); + + // Answering it is a key in that terminal. With no other work evidence + // (the seed was the only one) the board's rule files it under Not started. + await typeIntoAgent(blocked, "1"); + await waitGone(ROW_IN("attention", blocked)); + await waitVisible(ROW_IN("backlog", blocked)); + await typeIntoAgent(blocked, "\x7f"); }); it("remembers each bucket's fold", async () => { @@ -277,10 +306,8 @@ describe("sidebar status section", () => { await waitVisible(BUCKET_HEADER("backlog")); expect(await ariaExpanded(BUCKET_HEADER("backlog"))).toBe("false"); // And a listed bucket folds too. Re-seed the bell (SETUP), which the - // visit in the case above cleared. Off the task first: on the ACTIVE task - // the seeded mark does not hold (measured: the store's `unread` reads - // null straight after, and the tree's badge shows nothing either). - await browser.execute(() => window.__termic!.useApp.getState().setView("dashboard")); + // answer in the case above cleared. On the task you are looking at, which + // is where a bell used to vanish on sight. await browser.execute(id => { const app = window.__termic!.useApp.getState(); const tab = (app.tabs[id] ?? []).find((t: any) => t.type === "terminal"); diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 0da0c7d4..b025bb03 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -2363,9 +2363,16 @@ const captureArmedRef = useRef(false); if (origin === "hook") { delegatedSeenRef.current = null; delegatedSeenAtRef.current = 0; - const held = (useApp.getState().tabs[task.id] - ?.find(t => t.id === tab.id) as TerminalTab | undefined)?.delegatedWork; - if (held) patchTab(task.id, tab.id, { delegatedWork: null, delegatedSince: 0 }); + const live = useApp.getState().tabs[task.id] + ?.find(t => t.id === tab.id) as TerminalTab | undefined; + if (live?.delegatedWork) patchTab(task.id, tab.id, { delegatedWork: null, delegatedSince: 0 }); + // The agent's own report that the turn is over, so nothing in it is + // still waiting on the user: a question answered from somewhere + // else (claude's remote control), or one the agent gave up on. + // ONLY the done hook: a working heartbeat is not this, since + // parallel subagents keep firing tool hooks while one of them sits + // on a permission prompt. + if (live?.unread?.reason === "attention") useApp.getState().clearAttention(task.id, tab.id); } // 133;D is a hard "command ended" — no need to wait SETTLE_MS. goIdle(`${origin} D`, 0, true); @@ -3281,6 +3288,18 @@ const captureArmedRef = useRef(false); // payload keeps those out. Alt-chords arrive as `\x1b` PLUS the // character in one call, so they are excluded too. trackDraft(data); + // An agent blocked on the user stays blocked while you read it, so + // its bell ends when you ANSWER, not when you look + // (`unreadClearsOnSight`). Answering is any key you type here, not + // only Enter: claude's permission prompt takes a bare digit. The + // automated replies above all begin with ESC, which keeps them out, + // and a bare ESC (or Ctrl-C) is the cancel. Arrow keys begin with + // ESC too and are moving through the choices, not making one. A + // store read per keystroke, and a write only when a bell is there. + if (data === "\x1b" || data === "\x03" || !data.startsWith("\x1b")) { + const cur = useApp.getState().tabs[task.id]?.find(t => t.id === tab.id); + if (cur?.unread?.reason === "attention") useApp.getState().clearAttention(task.id, tab.id); + } if (data === "\x1b" || data === "\x03") { escAtRef.current = Date.now(); wdlog(`${data === "\x03" ? "Ctrl-C" : "ESC"} pressed; the title or a quiet terminal may end this turn briefly`); diff --git a/src/hooks/useSeenWhenWatched.test.ts b/src/hooks/useSeenWhenWatched.test.ts index 0c37807c..a9d9189c 100644 --- a/src/hooks/useSeenWhenWatched.test.ts +++ b/src/hooks/useSeenWhenWatched.test.ts @@ -19,7 +19,7 @@ describe("watchedBadgedTab", () => { useApp.setState({ tasks: [task("t1"), task("t2")], activeTaskId: "t1", - tabs: { t1: [tab("a", { reason: "attention" })], t2: [tab("b", { reason: "done" })] }, + tabs: { t1: [tab("a", { reason: "bell" })], t2: [tab("b", { reason: "done" })] }, activeTab: { t1: "a", t2: "b" }, splitTree: {}, activePaneId: {}, } as never); @@ -67,6 +67,24 @@ describe("watchedBadgedTab", () => { expect(watchedBadgedTab(useApp.getState())).toBe("t1:a"); }); + it("does not name a tab whose only mark is an attention bell", () => { + // Seeing a question is not answering it, so sight never clears the bell, + // and a target parked on that tab would only hide the next clearable + // mark behind it. + useApp.setState({ + tabs: { ...useApp.getState().tabs, t1: [tab("a", { reason: "attention" } as Tab["unread"])] }, + } as never); + expect(watchedBadgedTab(useApp.getState())).toBe(""); + }); + + it("still names a bell's tab once it ALSO holds the done dot, to clear the dot", () => { + useApp.setState({ + tabs: { ...useApp.getState().tabs, + t1: [{ id: "a", type: "terminal", cli: "claude", title: "a", unread: { reason: "attention" }, workState: "done" }] }, + } as never); + expect(watchedBadgedTab(useApp.getState())).toBe("t1:a"); + }); + it("names nothing when no task is active", () => { useApp.setState({ activeTaskId: null } as never); expect(watchedBadgedTab(useApp.getState())).toBe(""); diff --git a/src/hooks/useSeenWhenWatched.ts b/src/hooks/useSeenWhenWatched.ts index 6fddc044..4d915056 100644 --- a/src/hooks/useSeenWhenWatched.ts +++ b/src/hooks/useSeenWhenWatched.ts @@ -17,9 +17,15 @@ // behind someone's browser still counts as watched there. That is right for // deciding whether to BADGE (you were away, you should be told) and wrong for // deciding whether you have SEEN it, which is what this asks. +// +// One mark is exempt: ATTENTION. An agent blocked on a question is still +// blocked while you read it, so seeing it is not answering it, and its bell +// stays until you do (`unreadClearsOnSight`). A done dot on the same tab still +// clears on sight. import { useEffect } from "react"; import { isTabOnScreenIn, useApp } from "@/store/app"; +import { unreadClearsOnSight } from "@/lib/taskWorkState"; import { useUI } from "@/store/ui"; import type { AppState } from "@/store/app"; @@ -33,9 +39,10 @@ export function watchedBadgedTab(s: AppState): string { const tabs = s.tabs[taskId] || []; // `unread` OR a done work state: they are separate fields feeding separate // badges, and a tab can hold either without the other (a keystroke clears - // unread and leaves the dot). + // unread and leaves the dot). An attention bell alone is not a target: sight + // does not clear it, and naming it would park the target on that tab. const t = tabs.find(t => - (t.unread || (t.type === "terminal" && (t.workState === "done" || !!t.delegatedWork?.partial)) + (unreadClearsOnSight(t.unread) || (t.type === "terminal" && (t.workState === "done" || !!t.delegatedWork?.partial)) || (t.type === "scratch" && t.unseen)) && isTabOnScreenIn(s, taskId, t.id)); return t ? `${taskId}:${t.id}` : ""; @@ -72,8 +79,8 @@ export function useSeenWhenWatched() { // TabBar's showBell vs showDone). Clearing only `unread` left a finished // agent's dot up after the user came back, with clicking the sidebar item // (the one path that also writes the work state) the only way to shift it. - app.clearAttention(taskId, tabId); const tab = (app.tabs[taskId] ?? []).find(t => t.id === tabId); + if (unreadClearsOnSight(tab?.unread)) app.clearAttention(taskId, tabId); if (tab?.type === "terminal" && tab.workState === "done") { app.setWorkState(taskId, tabId, "idle", "seen: on screen in a focused window"); } diff --git a/src/lib/taskWorkState.test.ts b/src/lib/taskWorkState.test.ts index 42fac331..1971219c 100644 --- a/src/lib/taskWorkState.test.ts +++ b/src/lib/taskWorkState.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { - taskNeedsAttention, taskWorkDone, taskWorking, taskWorkBadge, taskDelegated, + taskNeedsAttention, taskWorkDone, taskWorking, taskWorkBadge, taskDelegated, unreadClearsOnSight, } from "@/lib/taskWorkState"; import type { Tab } from "@/lib/types"; import type { DelegatedWork } from "@/lib/delegatedWork"; @@ -152,3 +152,17 @@ describe("taskWorkBadge precedence", () => { .toBe("working"); }); }); + +describe("unreadClearsOnSight", () => { + it("every mark clears on sight except an agent blocked on the user", () => { + for (const reason of ["bell", "idle", "exit", "done"]) { + expect(unreadClearsOnSight({ reason })).toBe(true); + } + expect(unreadClearsOnSight({ reason: "attention" })).toBe(false); + }); + + it("nothing to clear is not a mark", () => { + expect(unreadClearsOnSight(null)).toBe(false); + expect(unreadClearsOnSight(undefined)).toBe(false); + }); +}); diff --git a/src/lib/taskWorkState.ts b/src/lib/taskWorkState.ts index e4820d0c..9924e4f1 100644 --- a/src/lib/taskWorkState.ts +++ b/src/lib/taskWorkState.ts @@ -42,6 +42,19 @@ export interface WorkStatePrefs { attentionIndicator?: boolean; } +/** Does LOOKING at a tab answer its `unread` mark? Every mark but one. + * + * An agent blocked on the user (a permission prompt, a question) is still + * blocked while you read it, so its bell ends when you ANSWER: a key you type + * in that terminal, Escape or Ctrl-C, or the agent's own hook ending the turn + * (docs/agent-states.md, state 7). Clearing it on sight, as the other marks + * are, made a question you had glanced at and left unanswered read as a + * finished turn: off the bell, and filed under Settled on the board and in the + * sidebar's status section, while the agent sat waiting. */ +export function unreadClearsOnSight(unread: { reason: string } | null | undefined): boolean { + return !!unread && unread.reason !== "attention"; +} + /** The agent is explicitly blocked on the user (Gemini "Action Required", * Codex "Waiting", OSC 1337 RequestAttention). */ export const taskNeedsAttention = (tabs: Tab[], p: WorkStatePrefs): boolean => diff --git a/src/store/app.test.ts b/src/store/app.test.ts index 1b3655e2..46c43bb8 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -1225,6 +1225,56 @@ describe("visiting a task does not stop a hook-driven agent looking busy", () => }); }); +describe("looking at a question is not answering it", () => { + // The agent is blocked on the user until they answer, so the "seen" paths + // that clear every other mark leave an attention bell alone (state 7 in + // docs/agent-states.md). Clearing it on sight filed a question you had + // glanced at under Settled, on the board and in the sidebar's status section. + const seed = (reason: string) => { + useUI.getState().setWindowless(false); + useUI.getState().setWindowFocused(true); + useApp.setState({ + tasks: [{ id: "t1", project_id: "p", name: "t1", path: "/tmp/t1" }], + activeTaskId: null, + activeTab: { t1: "a" }, + splitTree: {}, activePaneId: {}, + tabs: { t1: [ + { id: "a", type: "terminal", cli: "claude", title: "a", unread: { reason } }, + { id: "b", type: "terminal", cli: "claude", title: "b", unread: { reason } }, + ] }, + } as never); + }; + const reasons = () => + useApp.getState().tabs.t1.map(t => (t as { unread?: { reason: string } | null }).unread?.reason ?? null); + + it("opening the task keeps an attention bell on every tab", () => { + seed("attention"); + useApp.getState().setActiveTask("t1"); + expect(reasons()).toEqual(["attention", "attention"]); + }); + + it("opening the task still clears every other mark (control)", () => { + seed("bell"); + useApp.getState().setActiveTask("t1"); + expect(reasons()).toEqual([null, null]); + }); + + it("switching to the tab keeps the bell, and clears a plain one", () => { + seed("attention"); + useApp.getState().setActiveTabId("t1", "b"); + expect(reasons()).toEqual(["attention", "attention"]); + seed("exit"); + useApp.getState().setActiveTabId("t1", "b"); + expect(reasons()).toEqual(["exit", null]); + }); + + it("an explicit clear still ends it: the board's settle command", () => { + seed("attention"); + useApp.getState().clearTaskWorkState("t1"); + expect(reasons()).toEqual([null, null]); + }); +}); + describe("isUserWatching and window focus", () => { beforeEach(() => { useUI.getState().setWindowless(false); diff --git a/src/store/app.ts b/src/store/app.ts index 811eff88..b7099295 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -24,6 +24,7 @@ import { hydrateScheduled, scheduledOf } from "@/lib/scheduledQueue"; import { focusTerminalTab, focusMainTab, focusPaneTab } from "@/lib/tabFocus"; import { agentDisplayName, STICKY_DONE_MS } from "@/lib/agents"; import { visitMayClearWorking } from "@/lib/taskBoardState"; +import { unreadClearsOnSight } from "@/lib/taskWorkState"; import { scoped } from "@/lib/profileScope"; import { pruneMemberSets } from "@/components/dialogs/memberModes"; @@ -1034,7 +1035,9 @@ export const useApp = create((set, get) => ({ // only cleared the active tab's unread, but `isUnread(taskId)` in the // sidebar checks ANY tab — so the task icon stayed in its // unread color until the user manually visited each other tab. - // Clicking the task = "I've seen this" → clear all. + // Clicking the task = "I've seen this" → clear all, except an agent + // blocked on the user: seeing a question is not answering it + // (`unreadClearsOnSight`). const tabs = get().tabs[id] || []; const activeId = get().activeTab[id]; const now = Date.now(); @@ -1048,7 +1051,7 @@ export const useApp = create((set, get) => ({ const next = list.map(t => { if (t.type !== "terminal") return t; let nt = t; - if (t.unread) { + if (unreadClearsOnSight(t.unread)) { nt = { ...nt, unread: null }; } const clearable = t.workState === "done" @@ -2393,7 +2396,8 @@ export const useApp = create((set, get) => ({ // patch. Edit/diff tabs only have `unread` to clear. if (t.type === "terminal") { const patch: Partial = {}; - if (t.unread) patch.unread = null; + // Not an attention bell: the agent is still waiting on an answer. + if (unreadClearsOnSight(t.unread)) patch.unread = null; // "Some of the delegated work came back" is news, and looking at the // tab is reading it: drop the partial mark and keep the plain // delegated ring, which still says the rest is running. From 4de46fd797b724aa8a3a652c2775709958a0423d Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:54:41 +0530 Subject: [PATCH 4/7] feat(sidebar): task groups in the status section, whole and in colour A task group draws in the status section the way the tree draws it: a caption in the group's colour and its members behind a rail of the same colour. The group stays one unit, in the bucket of its most urgent member, so a settled lead sits under Needs attention while one of its workers asks something. Every member keeps its own badge, which is what says why the group is there. A layout rule over the board's buckets, not a state: each task's own bucket is still boardColumnFromFacts. A bucket's count is task rows, members included. The caption does not fold, rename or open a menu, and carries data-status-group-id, never the tree's data-task-group-id, which the task drag hit-tests. Cross-project batches (spawn trees) are the same rule one level up and are still to come. Refs #298 --- docs/e2e-coverage.md | 2 +- docs/ui.md | 18 +++++- e2e/specs/sidebar-status.e2e.ts | 78 +++++++++++++++++++++++- src/components/sidebar/StatusSection.tsx | 73 +++++++++++++++++++--- src/lib/sidebarStatus.test.ts | 38 +++++++++++- src/lib/sidebarStatus.ts | 73 ++++++++++++++++------ 6 files changed, 252 insertions(+), 30 deletions(-) diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index d9798eeb..27729549 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -26,7 +26,7 @@ until `make e2e` is green and this file reflects it. | ✅ App shell | Renders; `__termic` exposes real store state | `app.e2e.ts` | | ✅ Navigation | Dashboard ↔ History via real clicks | `app.e2e.ts` | | ✅ Kanban view (GH #318) | The Kanban nav entry opens the overlay; untouched tasks land in Not started under their agent's lane divider (one per agent in use); submitting a prompt through the real input path runs the fake agent's busy→idle cycle and moves the card into Settled while its untouched sibling stays behind; clicking a card activates the task and closes the board; dragging a card within its same-project group reorders and persists via `task_reorder`; dragging to another column snaps back with no dialog and no write; dropping on the Archived column runs the real confirm dialog and the card lands in the Archived column; the card's column accent edge is asserted by computed style so a dropped color-mix cannot ship invisibly; under a custom archive limit the column renders exactly the N most recent archived cards, newest first, the older ones (and a task archived before them) fall off, and the badge keeps the full count; Settings → Tasks' three-way limit control (default / unlimited / custom) reveals the number input only on custom and stores a typed number as-is, with no bounds | `board.e2e.ts`, `settings.e2e.ts` | -| ✅ Sidebar status section (#298) | Off by default; the Project list options check row turns it on, open, above the PROJECTS header; tasks nobody has opened are counted under Not started, folded, and unfold into exactly that many rows in the tree's relative order; a status row carries `data-status-task-id` and none of the tree's `data-sidebar-*` attributes, so each task still has one `[data-sidebar-task-id]`; attention seeded on a background agent lists it under Needs attention with `status-work-badge` while the tree keeps its own `work-badge` and the copy adds none; clicking the row opens the task and re-expands its folded project in the tree, and the row STAYS under Needs attention (opening is not answering) carrying the active mark and a painted background, measured, until a bare digit typed into the terminal answers it and it moves to Not started; the STATUS header is a plain label like PROJECTS, not a fold; each bucket's fold is written to scoped localStorage and survives the section being turned off and on; a held open PR lookup on a worktree task with a persisted identity lists it under In review with `status-pr-badge` while the first bare `task-pr-badge` stays the tree's, and a merge moves it out; Settings > Appearance > Sidebar's switch and the menu row write the same pref; the icon rail carries no section and the hover overlay carries exactly one. Working is covered by `src/lib/sidebarStatus.test.ts` and the fan-out pins, not by racing the fake agent | `sidebar-status.e2e.ts` | +| ✅ Sidebar status section (#298) | Off by default; the Project list options check row turns it on, open, above the PROJECTS header; tasks nobody has opened are counted under Not started, folded, and unfold into exactly that many rows in the tree's relative order; a status row carries `data-status-task-id` and none of the tree's `data-sidebar-*` attributes, so each task still has one `[data-sidebar-task-id]`; attention seeded on a background agent lists it under Needs attention with `status-work-badge` while the tree keeps its own `work-badge` and the copy adds none; clicking the row opens the task and re-expands its folded project in the tree, and the row STAYS under Needs attention (opening is not answering) carrying the active mark and a painted background, measured, until a bare digit typed into the terminal answers it and it moves to Not started; the STATUS header is a plain label like PROJECTS, not a fold; each bucket's fold is written to scoped localStorage and survives the section being turned off and on; a held open PR lookup on a worktree task with a persisted identity lists it under In review with `status-pr-badge` while the first bare `task-pr-badge` stays the tree's, and a merge moves it out; a task group (founded and joined through the real IPC) sits whole under Not started, then moves as one unit under Needs attention when one member's agent asks, with the bell only on that member, its caption and rail measured in the same colour as the tree's caption, the tree's `data-task-group-id` block still the only one, and the bucket count equal to its rows; answering moves it back whole; Settings > Appearance > Sidebar's switch and the menu row write the same pref; the icon rail carries no section and the hover overlay carries exactly one. Working is covered by `src/lib/sidebarStatus.test.ts` and the fan-out pins, not by racing the fake agent | `sidebar-status.e2e.ts` | | ✅ History scrolling | The archive pane fills its overlay instead of sizing to its content, so an archive taller than the window overflows INSIDE the scroller: the last row starts out of view and scrolling brings it in. Filtering down and back keeps the pane full-height | `app.e2e.ts` | | ✅ Create (wizard) | NewTaskDialog: name + shell CLI + Main-checkout → Create → task exists | `task.e2e.ts` | | ✅ Check out an existing branch | NewTaskDialog's "Existing branch" mode: a remote-only branch picked from the list becomes a worktree task on a local branch TRACKING it (HEAD on the colleague's commit, upstream set); a branch never fetched is typed, flagged as unfetched, and fetched on create; an unknown name fails the pending task with the "no branch" error and leaves no local branch behind (the old path cut a fresh one from main); "New branch instead" restores the ordinary form; archiving the checkout with delete-branch and restoring it puts it back on the colleague's commit, not a branch cut from main. The resolution rules (local wins, remote prefix, fetch on/off, no remote, invalid names) are pinned by `checkout_existing_branch` tests in `lib.rs`; restore's branch recreation by the `ensure_restore_branch` tests; the picker rows by `existingBranch.test.ts` | `task.e2e.ts` | diff --git a/docs/ui.md b/docs/ui.md index e12400e2..ccb54134 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -519,6 +519,19 @@ task whose project left the profile is skipped exactly as the tree skips it, and a row never shuffles inside its bucket; it moves only when its bucket changes. The per-project task filter (#324) does not apply here. +**Task groups** draw the way the tree draws them: a caption in the group's +colour (with the project name, which its members then drop) and the members +behind a rail of the same colour. A group stays ONE unit, in the bucket of its +most urgent member, in bucket order, so a settled lead sits under Needs +attention while one of its workers asks something; every member keeps its own +badge, so the row that put the group there says so. This is a layout rule over +the board's buckets, not a state: each task's own bucket is still +`boardColumnFromFacts`. A bucket's count is task rows, members included. The +caption does not fold, rename or open a menu, and it carries +`data-status-group-id`, never the tree's `data-task-group-id` (the task drag +hit-tests that one). A legacy cross-project group draws as plain rows, as in +the tree. + `TaskRow` is not reused: its rename and auto-expand effects would run twice per task, and every auto-expand would be a second whole-state write. @@ -550,8 +563,9 @@ rewrite re-renders no status row. `selectorFanout.test.ts` pins all of it. ### Not built -Batches (a spawn tree via `spawned_by` drawn as one unit across projects, in -the bucket of its most urgent member), a count on the compact rail, and drops +Batches ACROSS projects (a spawn tree via `spawned_by`, drawn as one unit in +the bucket of its most urgent member: the task-group rule one level up), a +count on the compact rail, and drops as commands (`boardDropCommand` is reusable once there is a vertical hit test). Still open: whether a finished turn you have not looked at belongs in Needs attention (the title-bar pill says yes, the board says Settled), and diff --git a/e2e/specs/sidebar-status.e2e.ts b/e2e/specs/sidebar-status.e2e.ts index 6ae9ae28..9534d0b2 100644 --- a/e2e/specs/sidebar-status.e2e.ts +++ b/e2e/specs/sidebar-status.e2e.ts @@ -100,6 +100,8 @@ describe("sidebar status section", () => { let fresh = ""; let blocked = ""; let reviewed = ""; + let groupLead = ""; + let groupMember = ""; let hoverRevealWas = false; /** Every pref this spec touches, back to the shipped defaults. */ @@ -129,7 +131,7 @@ describe("sidebar status section", () => { t.useApp.getState().closeSettings(); }, hoverRevealWas); await resetPrefs(); - for (const id of [fresh, blocked, reviewed]) if (id) await archiveTask(id); + for (const id of [fresh, blocked, reviewed, groupLead, groupMember]) if (id) await archiveTask(id); }); it("is off by default, and the list options menu turns it on above PROJECTS", async () => { @@ -371,6 +373,80 @@ describe("sidebar status section", () => { await waitVisible(ROW_IN("backlog", reviewed)); }); + it("keeps a task group whole, in its colour, under its most urgent member's bucket", async () => { + groupLead = await openTask("status-group-lead", false); + groupMember = await openTask("status-group-member", false); + // SETUP through the app's own IPC: the lead founds a group, the member + // joins it, the way the task menu's Move to group does. + const groupId = await browser.execute(async (a, b) => { + const t = window.__termic!; + await t.invoke("task_group_new", { taskId: a, color: "teal" }); + await t.invoke("task_group_join", { taskId: b, targetId: a, color: null }); + await t.useApp.getState().loadAll(); + return t.useApp.getState().tasks.find((w: any) => w.id === a).group.id as string; + }, groupLead, groupMember); + const BLOCK = (bucket: string) => `${BUCKET(bucket)} [data-status-group-id="${groupId}"]`; + + // Both untouched, so the whole group is one unit under Not started. + await setBucketOpen("backlog", true); + await waitVisible(BLOCK("backlog")); + + // The member's agent asks something (SETUP on a background task): the + // group moves as a unit, the untouched lead with it. + await ensureActiveTask(groupMember); + await waitForAgentReady(groupMember); + await browser.execute(() => window.__termic!.useApp.getState().setView("dashboard")); + await browser.execute(id => { + const app = window.__termic!.useApp.getState(); + const tab = (app.tabs[id] ?? []).find((t: any) => t.type === "terminal"); + app.markAttention(id, tab.id, "attention", "needs you"); + }, groupMember); + await waitVisible(BLOCK("attention")); + expect(await present(BLOCK("backlog"))).toBe(false); + + const shape = await browser.execute((sel, gid, lead, member) => { + const block = document.querySelector(sel) as HTMLElement; + const caption = block.querySelector('[data-testid="status-group-caption"]') as HTMLElement; + const rail = block.querySelector("[data-status-group-rail]") as HTMLElement; + const tree = document.querySelector(`[data-testid="task-group-header-${gid}"]`) as HTMLElement | null; + const bucket = block.closest("[data-status-bucket]") as HTMLElement; + return { + members: [...block.querySelectorAll("[data-status-task-id]")].map(el => el.dataset.statusTaskId), + // Only the member that asked carries the bell; the lead rides along. + bells: [lead, member].map(id => + !!block.querySelector(`[data-status-task-id="${id}"] [data-testid="status-work-badge"][data-work-state="attention"]`)), + caption: getComputedStyle(caption).color, + rail: getComputedStyle(rail).borderLeftColor, + treeCaption: tree ? getComputedStyle(tree).color : null, + // Identity: the tree's group block is still the only one the task + // drag can hit-test. + treeBlocks: document.querySelectorAll(`[data-task-group-id="${gid}"]`).length, + copyHasTreeAttr: !!block.querySelector("[data-task-group-id]") || block.hasAttribute("data-task-group-id"), + count: Number(bucket.querySelector('[data-testid="status-bucket-count"]')?.textContent), + rows: bucket.querySelectorAll("[data-status-task-id]").length, + }; + }, BLOCK("attention"), groupId, groupLead, groupMember); + expect(shape.members).toEqual([groupLead, groupMember]); + expect(shape.bells).toEqual([false, true]); + // The group's own colour, measured: the caption and the rail agree, and + // match the tree's caption for the same group. + expect(shape.caption).toBe(shape.rail); + expect(shape.caption).toBe(shape.treeCaption); + expect(shape.treeBlocks).toBe(1); + expect(shape.copyHasTreeAttr).toBe(false); + // A bucket counts task rows, group members included. + expect(shape.count).toBe(shape.rows); + await snap("sidebar-status-group.png"); + + // Answered: the member has no other work evidence, so the group goes + // back to Not started, whole. + await ensureActiveTask(groupMember); + await typeIntoAgent(groupMember, "1"); + await waitGone(BLOCK("attention")); + await waitVisible(BLOCK("backlog")); + await typeIntoAgent(groupMember, "\x7f"); + }); + it("Settings > Appearance > Sidebar writes the same switch", async () => { await browser.execute(() => window.__termic!.useApp.getState().openSettings("appearance")); await waitVisible('[data-appearance-tab="interface"]'); diff --git a/src/components/sidebar/StatusSection.tsx b/src/components/sidebar/StatusSection.tsx index d954b021..4b568a6b 100644 --- a/src/components/sidebar/StatusSection.tsx +++ b/src/components/sidebar/StatusSection.tsx @@ -35,8 +35,9 @@ import { TaskPrBadge } from "@/components/TaskPrBadge"; import { cn } from "@/lib/utils"; import { taskLabel } from "@/lib/taskLabel"; import { isStatusBucketCollapsed, statusBuckets, type StatusBucket } from "@/lib/sidebarStatus"; +import { groupColorCss, groupLabel } from "@/lib/taskGroups"; import type { WorkStatePrefs } from "@/lib/taskWorkState"; -import type { Agent, Task } from "@/lib/types"; +import type { Agent, Task, TaskGroup } from "@/lib/types"; /** Literal keys, so usedKeys.test.ts can see them. The board's own labels: * a bucket and its column must not be called two different things. */ @@ -96,7 +97,7 @@ export function StatusSection() { {groups.map(g => { const open = !isStatusBucketCollapsed(g.bucket, bucketCollapsed); - const count = g.tasks.length; + const count = g.count; const countLabel = count === 1 ? t("statusBucketCount_one", { count }) : t("statusBucketCount_other", { count }); @@ -122,15 +123,33 @@ export function StatusSection() { {count} - {open && g.tasks.map(w => ( + {open && g.items.map(item => item.kind === "task" ? ( + ) : ( + + {item.tasks.map(w => ( + + ))} + ))} ); @@ -139,6 +158,41 @@ export function StatusSection() { ); } +/** A task group, drawn the way the tree draws one: a caption in the group's + * colour and its members behind a rail of the same colour, at the same + * offsets as TaskGroupBlock. Lighter than that block: no fold, no rename, no + * menu, no drag. The project name moves to the caption, since a group lives + * in one project. Carries `data-status-group-id`, never the tree's + * `data-task-group-id`, which the task drag hit-tests. */ +function StatusGroupBlock({ group, label, projectName, children }: { + group: TaskGroup; + label: string; + projectName: string; + children: React.ReactNode; +}) { + const color = groupColorCss(group); + return ( +
+
+ {/* The width of a row's agent glyph, so the caption's label lines up + with the loose rows' labels, as the tree's does behind its chevron. */} + + {label} + {projectName && ( + {projectName} + )} +
+
+
{children}
+
+
+ ); +} + /** A lighter row than the tree's TaskRow, modelled on the dashboard's: no * terminal children, no drag, no rename, no run controls, no menu. TaskRow * is NOT reused: its rename and auto-expand effects would run twice per @@ -191,8 +245,11 @@ const StatusTaskRow = memo(function StatusTaskRow({ task: w, projectName, agents {label} {/* Which project, since a bucket mixes them. Faint and shrinks first: - the task's own name is what the row is for. */} - {projectName} + the task's own name is what the row is for. A group member has it + on its caption instead. */} + {projectName && ( + {projectName} + )} diff --git a/src/lib/sidebarStatus.test.ts b/src/lib/sidebarStatus.test.ts index 7056a68b..17a11b85 100644 --- a/src/lib/sidebarStatus.test.ts +++ b/src/lib/sidebarStatus.test.ts @@ -5,6 +5,7 @@ import { parseStatusBucketCollapsed, statusBucketCollapsedByDefault, statusBuckets, + statusItemTasks, } from "./sidebarStatus"; import type { BoardTaskFacts } from "./taskBoardState"; import type { WorkStatePrefs } from "./taskWorkState"; @@ -32,7 +33,7 @@ const F = { } satisfies Record; const ids = (groups: ReturnType) => - Object.fromEntries(groups.map(g => [g.bucket, g.tasks.map(t => t.id)])); + Object.fromEntries(groups.map(g => [g.bucket, g.items.flatMap(statusItemTasks).map(t => t.id)])); describe("statusBuckets", () => { it("files each task under its board column, buckets in display order", () => { @@ -81,6 +82,41 @@ describe("statusBuckets", () => { expect(ids(statusBuckets([project("web")], tasks, facts, {}, prefsOn)).settled).toEqual(["a", "c", "b"]); }); + it("keeps a task group whole, in the bucket of its most urgent member", () => { + // One member blocked on the user, one never started, one settled: the + // group goes under Needs attention as one unit, every member with it. + const g = { id: "lead", color: "teal" }; + const tasks = [ + task("lead", "web", { group: g }), task("loose", "web"), + task("worker", "web", { group: g }), task("idle", "web", { group: g }), + ]; + const facts = { lead: F.settled, loose: F.settled, worker: F.attention, idle: F.untouched }; + const groups = statusBuckets([project("web")], tasks, facts, {}, prefsOn); + expect(ids(groups)).toEqual({ attention: ["lead", "worker", "idle"], settled: ["loose"] }); + const attention = groups.find(x => x.bucket === "attention")!; + expect(attention.items.map(i => i.kind)).toEqual(["group"]); + expect(attention.items[0].kind === "group" && attention.items[0].group.id).toBe("lead"); + }); + + it("counts task rows, group members included", () => { + const g = { id: "a" }; + const tasks = [task("a", "web", { group: g }), task("b", "web", { group: g }), task("c", "web")]; + const facts = { a: F.working, b: F.settled, c: F.working }; + const working = statusBuckets([project("web")], tasks, facts, {}, prefsOn).find(x => x.bucket === "working")!; + expect(working.items.length).toBe(2); + expect(working.count).toBe(3); + }); + + it("draws a legacy cross-project group as plain rows, as the tree does", () => { + // The same group id in two projects, one member each: the tree treats + // both as strays (crossProjectStrays) and draws them loose. + const g = { id: "x" }; + const tasks = [task("x", "web", { group: g }), task("y", "api", { group: g })]; + const facts = { x: F.settled, y: F.settled }; + const settled = statusBuckets([project("web"), project("api")], tasks, facts, {}, prefsOn)[0]; + expect(settled.items.map(i => i.kind)).toEqual(["task", "task"]); + }); + it("skips archived tasks and tasks whose project is not in the list", () => { const tasks = [ task("live", "web"), diff --git a/src/lib/sidebarStatus.ts b/src/lib/sidebarStatus.ts index dcac9316..566c7182 100644 --- a/src/lib/sidebarStatus.ts +++ b/src/lib/sidebarStatus.ts @@ -9,9 +9,9 @@ // Pure and store-free on purpose (the pr store's import chain touches the DOM // at module scope): the caller passes the PR snapshot in as plain data. -import type { Project, Task } from "./types"; +import type { Project, Task, TaskGroup } from "./types"; import { visualProjectOrder } from "./projectGroups"; -import { crossProjectStrays, flattenSegments, layoutTaskList } from "./taskGroups"; +import { crossProjectStrays, layoutTaskList } from "./taskGroups"; import { boardColumnFromFacts, type BoardPrInfo, @@ -63,17 +63,32 @@ export function parseStatusBucketCollapsed(raw: string | null | undefined): Stat * same reading the board gives `EMPTY_TABS`. */ const UNLOADED: BoardTaskFacts = Object.freeze({ attention: false, working: false, untouched: true }); +/** One thing a bucket draws: a loose task, or a task group with its members + * (the tree's own grouping, in its own colour). */ +export type StatusItem = + | { kind: "task"; task: Task } + | { kind: "group"; group: TaskGroup; tasks: Task[] }; + export interface StatusBucketGroup { bucket: StatusBucket; - tasks: Task[]; + items: StatusItem[]; + /** Task rows, group members included: what the bucket's count says. */ + count: number; } /** The section's contents: non-empty buckets in display order, each holding - * its tasks in TREE order: the sidebar's visual project order (which the - * keyboard walks too), then each project's rows as the tree lays them out, - * a task group drawn as one block at its first member's position. A row - * therefore never shuffles inside its bucket; it moves only when its - * bucket changes. + * its items in TREE order: the sidebar's visual project order (which the + * keyboard walks too), then each project's rows as the tree lays them out. + * An item never shuffles inside its bucket; it moves only when its bucket + * changes. + * + * A task group stays ONE unit, as it is in the tree: drawn whole, in the + * bucket of its most urgent member (display order is urgency order). Every + * member keeps its own badge, so the row that put the group there says so. + * This is a layout rule over the board's buckets, not a state: each task's + * own bucket is still boardColumnFromFacts, unchanged. Cross-project + * batches (a spawn tree via `spawned_by`) are the same rule one level up, + * and not built yet. * * Walks projects rather than tasks, so a task whose project is not in this * profile's list is skipped exactly as the tree skips it. */ @@ -95,17 +110,41 @@ export function statusBuckets( // cross-project group), and the same grouping for everything else. const strays = crossProjectStrays(tasks); const groupFor = (t: Task) => (strays.has(t.id) ? null : t.group ?? null); - const buckets = new Map(STATUS_BUCKETS.map(b => [b, []])); + const bucketOf = (w: Task): StatusBucket | null => { + const column = boardColumnFromFacts(w, facts[w.id] ?? UNLOADED, prByTask[w.id]?.lookup ?? null, prefs); + // Unreachable (archived tasks were skipped above), but the type allows + // it, and dropping a task is better than inventing a bucket for it. + return column === "archived" ? null : column; + }; + const rank = (b: StatusBucket) => STATUS_BUCKETS.indexOf(b); + const buckets = new Map( + STATUS_BUCKETS.map(b => [b, { items: [], count: 0 }])); + const place = (bucket: StatusBucket, item: StatusItem, rows: number) => { + const into = buckets.get(bucket)!; + into.items.push(item); + into.count += rows; + }; for (const p of visualProjectOrder(projects)) { - for (const w of flattenSegments(layoutTaskList(byProject.get(p.id) ?? [], groupFor))) { - const column = boardColumnFromFacts(w, facts[w.id] ?? UNLOADED, prByTask[w.id]?.lookup ?? null, prefs); - // Unreachable (archived tasks were skipped above), but the type allows - // it, and dropping a task is better than inventing a bucket for it. - if (column === "archived") continue; - buckets.get(column)!.push(w); + for (const seg of layoutTaskList(byProject.get(p.id) ?? [], groupFor)) { + if (seg.kind === "task") { + const b = bucketOf(seg.task); + if (b) place(b, { kind: "task", task: seg.task }, 1); + continue; + } + let most: StatusBucket | null = null; + for (const w of seg.tasks) { + const b = bucketOf(w); + if (b && (most === null || rank(b) < rank(most))) most = b; + } + if (most) place(most, { kind: "group", group: seg.group, tasks: seg.tasks }, seg.tasks.length); } } return STATUS_BUCKETS - .map(bucket => ({ bucket, tasks: buckets.get(bucket)! })) - .filter(g => g.tasks.length > 0); + .map(bucket => ({ bucket, ...buckets.get(bucket)! })) + .filter(g => g.items.length > 0); +} + +/** Every task an item draws, in order. */ +export function statusItemTasks(item: StatusItem): Task[] { + return item.kind === "task" ? [item.task] : item.tasks; } From 949bbcc7e92a58dcd1760b81b94ba38c0fe5001e Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:19:19 +0530 Subject: [PATCH 5/7] test(e2e): the task filter's bell case answers the question The case asserted that opening a task clears its attention notification, which is the behaviour d281ef1b removed on purpose: opening is not answering. It now pins both halves: the count holds while the task is open and watched, and a keystroke in the terminal (a bare digit) clears it, with the active task still listed until another is selected. Refs #298 --- docs/e2e-coverage.md | 2 +- e2e/specs/projects.e2e.ts | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index 27729549..5b3d0478 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -147,7 +147,7 @@ until `make e2e` is green and this file reflects it. | ✅ Resize drags | Sidebar edge widens + clamps at its minimum (persisted); split divider moves the ratio inside its clamp | `tabs-layout.e2e.ts` | | ✅ Sidebar project drags | Reorder two projects; drop one into a group folder; move a whole folder as one block | `projects.e2e.ts` | | ✅ Sidebar task drags | Reorder tasks inside a project (siblings keep their relative order); the new order persists to the task files, so a cold load reads it back; a task dragged at another project's row clamps to its own list instead of moving | `task.e2e.ts` | -| ✅ Sidebar task filter (GH #324) | The project row's filter icon opens a bar under the header (focused input, bell to its right) and closes it again, and lights only while text or the bell filters; typing keeps only tasks whose name or stable agent tab title matches (case and surrounding space ignored) and pins the hover bar with the pointer elsewhere; a CLI `rename` moves a task into and back out of the filtered list live; the clear button and Escape both empty it, drop the filter from the store and let the bar hide again; an empty list shows a "No matching tasks" row whose action clears it, and the row never appears under the active task the filter keeps on screen; turning a filter on expands a collapsed project, and a real header click still collapses and re-expands it with the filter on; the bell keeps only tasks with a notification (an OSC 9 attention from the fixture), and the active task stays listed after opening it clears that notification, dropping out once another task is selected. The predicate, including `liveTitle` being ignored and the bell agreeing with the tray's numeral, is unit-tested in `src/lib/taskFilter.test.ts` | `projects.e2e.ts` | +| ✅ Sidebar task filter (GH #324) | The project row's filter icon opens a bar under the header (focused input, bell to its right) and closes it again, and lights only while text or the bell filters; typing keeps only tasks whose name or stable agent tab title matches (case and surrounding space ignored) and pins the hover bar with the pointer elsewhere; a CLI `rename` moves a task into and back out of the filtered list live; the clear button and Escape both empty it, drop the filter from the store and let the bar hide again; an empty list shows a "No matching tasks" row whose action clears it, and the row never appears under the active task the filter keeps on screen; turning a filter on expands a collapsed project, and a real header click still collapses and re-expands it with the filter on; the bell keeps only tasks with a notification (an OSC 9 attention from the fixture), opening that task keeps its notification (a question is not answered by looking at it), and the active task stays listed after a keystroke answers it, dropping out once another task is selected. The predicate, including `liveTitle` being ignored and the bell agreeing with the tray's numeral, is unit-tested in `src/lib/taskFilter.test.ts` | `projects.e2e.ts` | | ✅ Task groups | `termic new` run with `$TERMIC_TASK_ID` founds a group led by the caller, more tasks and a worker's own tasks join it (flat), `--no-group` / no env / a stale id stay out; the unnamed label follows the lead's rename; rename and recolour from the caption menu; drag a row in (joins) and out (leaves); Remove from group leaves a lone lead grouped; Move to group > New group asks for a name, and moves a task between groups; compact rail; collapsing shows one of each member mark (attention and done seeded on real agent tabs), navigating to a member expands it, and a collapsed group keeps the active task's row; caption drag moves the whole block (above another group, below a loose row, persisted); caption and rail alignment and the rename input's font, measured; Ungroup tasks. MCP `task_new` groups under the caller from the `X-Termic-Task` header with no argument, and `task_group` with no `task` names the caller's group. Spawn links: a task spawned into another project is linked (`spawned_by`, CLI reply) and not grouped, its ↳ mark names the parent and goes there, a same-project child is grouped with no mark, hover draws lines to parent and children (end points measured) and only while hovered, a legacy cross-project group draws as plain rows, and the dashboard's flat rows mark every spawned child (same-group ones too, the sidebar's suppression inverted) with the mark going to the parent | `task.e2e.ts`, `mcp.e2e.ts` | | ✅ Settings reorder drags | Prompt rows reorder by their grip (and a click without movement does not); agent pills reorder within their kind | `settings.e2e.ts` | | ✅ Project default CLI vs the agent registry | The Default CLI select shows the SAVED agent id even when the registry no longer offers it (React re-points a valueless `