Add collapsed-project status badge to the sidebar#2340
Conversation
|
| Filename | Overview |
|---|---|
| packages/app/src/components/sidebar/project-leading-visual.tsx | New file: extracts the project row's leading icon into a clean module with a shared 14pt badge shell for all statuses; uses getProjectStatusBadgeContent to keep rendering logic separate from selection logic. |
| packages/app/src/utils/sidebar-agent-state.ts | Adds aggregateSidebarStateBuckets with a single priority array (needs_input > failed > running > attention > done); handles unknown buckets via indexOf guard; well-tested. |
| packages/app/src/utils/project-status-badge-content.ts | New pure-function utility mapping a SidebarStateBucket to a typed badge descriptor (loader |
| packages/app/src/hooks/sidebar-workspaces-view-model.ts | Adds deriveProjectStatusBucket and ProjectStatusSession interface; builds one agent-activity index per server (not per workspace), then delegates aggregation to aggregateSidebarStateBuckets. |
| packages/app/src/hooks/use-sidebar-workspaces-list.ts | Adds useSidebarProjectStatusBucket hook returning a primitive string, so status churn only re-renders the project row when the aggregate actually changes; disabled while the project is expanded. |
| packages/app/src/components/sidebar-workspace-list.tsx | Cleans up the old inline ProjectLeadingVisual, ProjectIcon, ProjectLeadingVisualStatus, StatusDotOverlay, and ProjectInlineChevron definitions; integrates useSidebarProjectStatusBucket in ProjectBlock. |
| packages/app/e2e/project-status-badge-gallery.spec.ts | New on-demand capture spec; two tests still have if/continue conditionals in assertion loops (not fixed by the earlier split), and the archiving test is left as test.skip with an unresolved bug note. |
| packages/app/src/utils/project-status-badge-content.test.ts | Plain vitest unit test for getProjectStatusBadgeContent; no mocks, no JSDOM, covers all five buckets and null. |
| packages/app/src/hooks/sidebar-workspaces-view-model.test.ts | Adds thorough unit tests for deriveProjectStatusBucket covering cross-server aggregation, partial hydration, agent-lift, archived/sub-agent exclusion, and all priority-order pairs. |
| packages/app/src/utils/sidebar-agent-state.test.ts | Extends existing tests with full aggregateSidebarStateBuckets coverage including order-independence and all adjacent-priority pairs. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
PB["ProjectBlock\n(collapsed=true)"]
HOOK["useSidebarProjectStatusBucket\n(workspaces, enabled)"]
STORE["SessionStore\n(sessions, agents)"]
DERIVE["deriveProjectStatusBucket\n(workspaces, sessions)"]
AGG["aggregateSidebarStateBuckets\n(buckets[])"]
BUCKET["SidebarStateBucket\n(needs_input | failed | running | attention | done | null)"]
CONTENT["getProjectStatusBadgeContent\n(statusBucket)"]
PLV["ProjectLeadingVisual"]
PSI["ProjectStatusIndicator"]
PSB["ProjectStatusBadge"]
RENDER["Render: loader / alert / dot / nothing"]
PB -->|"project.workspaces"| HOOK
HOOK -->|"selector"| STORE
STORE -->|"state.sessions"| DERIVE
DERIVE -->|"per-workspace bucket"| AGG
AGG --> BUCKET
BUCKET --> HOOK
HOOK -->|"statusBucket"| PLV
PLV --> PSI
PSI --> PSB
PSB --> CONTENT
CONTENT --> RENDER
Reviews (5): Last reviewed commit: "Add higher-quality mp4 of the status pro..." | Re-trigger Greptile
Collapsed project rows now surface the most urgent hidden workspace's status as a corner badge on the project icon, instead of giving no signal at all: a loader for running, an alert for needs_input, a dot for attention/failed, nothing for done. Priority order (needs_input > failed > running > attention > done) means a working project keeps its loader even if another workspace is also awaiting review. Every surfaced status shares one badge shell (size, ring, offset) so the loader, alert, and dot never draw a differently-sized circle - previously the loader/alert used a 12pt ring while the dot used a 7-9pt one with a different corner offset. The running loader is also nudged half a point left so its visual center lands on the circle's center rather than reading as shifted right. Adds an e2e capture spec (run on demand, not part of CI) that seeds every state and edge case through the mock provider and dumps DOM geometry, for re-verifying the badge shell stays uniform.
Temporary evidence assets for pull request review, embedded via blob URLs in the PR description. Safe to delete after merge.
This feature never shipped on main, so there is no real "before" to compare against for a reviewer — the earlier before/after images were an artifact of local dev iteration, not a regression fix. Replaced with one clean still per status.
Lucide's circle-alert only paints ~83% of its nominal size, so the prior 10pt glyph read as smaller than the loader inside the same 14pt shell. Sized both up so all three glyphs draw the same visual footprint against the shared ring.
Greptile flagged getStatusDotColorStyle's needs_input/running cases as dead (unreachable — shouldRenderSyncedStatusLoader and an explicit check already intercept those buckets earlier). Extracting the selection logic into a plain-TS descriptor function (getProjectStatusBadgeContent) makes that structural: the "dot" variant is typed to failed|attention only, so there's no switch left with unreachable branches. That extraction also resolves the JSDOM component test Greptile flagged (project-leading-visual.test.tsx used vi.hoisted/vi.mock/ createRoot mounting, all banned by docs/testing.md's two test categories) — the pure selector is now tested directly with a plain vitest file, no mounting needed. Dropped that test file and use-sidebar-project-status-bucket.test.tsx (same banned pattern); the real coverage was already redundant with sidebar-workspaces-view-model .test.ts's existing priority-order assertions and the e2e capture spec's real-app gating checks. Also split a conditional branch in the e2e capture spec's assertion loop into two deterministic loops (badge-expected vs no-badge cases) per Greptile's third note.
aad0ad1 to
135541e
Compare
The badge shell's interior is 12pt (14pt minus the 1pt ring on each side). A centered glyph of size N sits at a (12 - N) / 2 offset, which is fractional for odd N and gets snapped to a device-pixel boundary, rendering visibly off-center (~1.5 device px at 3x). Drop the dot to 8pt and the alert to 10pt so both divide the interior into whole pixels and land dead center; Lucide's circle-alert paints ~83% of nominal, so 10pt draws a ~8.3pt disc that matches the 8pt dot. Also extends the on-demand capture spec to cover the failed bucket, the priority aggregation cases, dark mode, hover, a compact viewport, and a recorded running-to-expand transition, dumping DOM geometry that proves every shell shares one size/offset and every even glyph centers at 0px.
Recaptures every state at the approved 8pt-dot / 10pt-alert sizing, adds the failed bucket to the per-status set, a crosshair sizing/ centering proof, dark-mode and compact-form-factor shots, and replaces the soft upscaled spinner GIF with a crisp one assembled from native 3x-scale frames. Drops the stale pre-sizing images.
90afdfc to
4328146
Compare
Seeds three collapsed projects, each with several same-directory workspaces in different buckets, so each resolves to a different highest-priority winner (needs_input / failed / running). Films the collapse and expand so the aggregation is provable as motion, not a single frame.
Linked issue
Closes #
Type of change
What does this PR do
Collapsing a project in the sidebar hides its workspaces and, on
main, leaves no signalabout what's happening inside them — a project could have a workspace waiting on your input or
one that just failed, and the collapsed row looks identical to an idle one. This PR surfaces the
single most urgent hidden workspace status as a small corner badge on the project icon, so a
collapsed project still tells you when it needs attention.
Every surfaced status renders inside the same badge shell — a 14pt circle with a 1pt
sidebar-colored ring, fixed at a −7/−7 offset on the icon's bottom-right corner. Only the glyph
inside changes; the loader, the alert, and the dot never draw a differently-sized or
differently-positioned circle. Priority order for the collapsed aggregate is
needs_input > failed > running > attention > done— a working project keeps its loader even ifanother of its workspaces is also awaiting review. An expanded project shows no badge at all;
the individual workspace rows speak for themselves.
What changed
components/sidebar/project-leading-visual.tsx(new) — the project row's leading icon,now rendering
ProjectStatusIndicator/ProjectStatusBadge: one shared badge shell for everystatus. Glyph sizes are even (8pt dot, 10pt alert) so they center in the 12pt shell interior
without fractional-pixel correction (see the sizing proof below). The running loader is nudged by
STATUS_BADGE_LOADER_NUDGE_Xso its visual center lands on the circle center.utils/project-status-badge-content.ts(new) — the pure glyph-selection function that mapsa status bucket to badge content, extracted so it's unit-testable without a DOM.
hooks/sidebar-workspaces-view-model.ts,hooks/use-sidebar-workspaces-list.ts,utils/sidebar-agent-state.ts,components/sidebar-workspace-list.tsx— the aggregationthe badge is driven by:
deriveProjectStatusBucket,useSidebarProjectStatusBucket, and thebucket rollup, with the list component slimmed down as logic moved into the hook/util layer.
docs/glossary.md— new authoritative term "Project status bucket."e2e/project-status-badge-gallery.spec.ts(new) — an on-demand capture/measurement spec(details under How did you verify it).
.gitignore— ignores ad-hoc verification capture output (**/.verification/)..github/pr-assets/collapsed-project-status-badge/— the images embedded below; safe todelete after merge.
How did you verify it
All states, one shared circle
running→ amber loader,needs_input→ amber alert,failed→ red dot,attention→ greendot — all four in the identical ringed circle.
donehas no active work, so the project iconcarries no badge at all.
Per status
Sizing & centering — why the glyphs are even
The shell's interior is 12pt (14pt minus the 1pt ring on each side). A centered glyph of size
Nsits at a
(12 − N) / 2offset — fractional for oddN, which the browser snaps to adevice-pixel boundary and renders visibly off-center (an odd size measures ~1.5 device px right
and down at 3×, with asymmetric ring gaps between opposite sides). Even sizes divide the interior
into whole pixels and land dead center with no correction.
So the glyphs are sized even: the dot is 8pt and the alert 10pt (Lucide's
circle-alertpaints ~83% of its nominal size, so a 10pt alert draws a ~8.3pt disc that matches the 8pt dot — the
two read as the same diameter). The red crosshair marks each glyph's center; the ring gap is
symmetric on all sides, and DOM geometry confirms a
0.00pxoffset from the shell center for both.Spinner, in the shell
The running state's dot-comet loader, nudged left by
STATUS_BADGE_LOADER_NUDGE_X(−0.67pt) so itsvisual center lands on the circle center — the play-button-triangle trick. Assembled from native
deviceScaleFactor: 3frames, not an upscaled video crop.Priority aggregation — statuses resolving up to the project badge
The collapsed row must reduce every hidden workspace down to one badge. This recording seeds three
projects, each with several same-directory workspaces in different states, and shows each resolving
to a different winner —
needs_inputbeating running+attention,failedbeating running,runningbeating attention — then expands two of them to reveal the constituent workspace rows thatrolled up:
(Crisp — assembled from
deviceScaleFactor: 3frames, not upscaled video.▶ higher-quality mp4.)
The same rules, captured statically per-row (each row proves one rule, including both
failedcases:
needs_input+failed → alert,failed+running → red dot,running+attention → running,attention+done → attention,all done → no badge), plus a control project left expanded:The expanded control is the inverse of everything else — an expanded project shows no badge on
the project row; status rides the individual workspace rows instead:
Dark mode
The ring is sidebar-colored, so the badge separates cleanly from the icon underneath in both themes.
Compact (narrow) width
The same shell, offset, and sizing at a 390px-wide (mobile web) viewport.
Automated checks & the capture spec
utils/project-status-badge-content.test.ts(pure glyph selection perbucket),
hooks/sidebar-workspaces-view-model.test.ts(aggregation / priority rollup), andutils/sidebar-agent-state.test.ts(bucket derivation).e2e/project-status-badge-gallery.spec.tsis an on-demand capture/measurement spec, not partof CI — it drives the real running app via the mock provider, asserts each row's
testidbeforecapturing, and dumps DOM geometry proving every badge shell shares the same size/offset and every
even-size glyph centers at a
0.00pxoffset. It produced every image above (including the newpropagation recording). The archiving spinner is a transient state that could not be caught
deterministically, so its capture is intentionally
test.skip'd with an inline investigationnote rather than left flaky.
npm run typecheck,npm run lint, andnpm run formatare all clean (also enforced by therepo's pre-commit hook on every commit here).
Checklist
npm run typecheckpassesnpm run lintpassesnpm run formatran (Biome)compact widths); native iOS/Android and Electron desktop were not tested (see the platform note
above)