feat(dashboard): implement #70 monitor mockups #126

Merged
code-lead merged 2 commits from boss/122 into main 2026-04-20 10:58:24 +00:00
Collaborator

Summary

Lifts the Tokyo Night mockups from the #70 Penpot file into
src/dashboard.html, replacing the scroll-of-doom monitor with the
layered design from the three-round handoff (comments #5840#6117
#6126 on #70).

Token-level fidelity — every hex comes from design/tokens.json; no
new palette values. --role-chaman aliases --success (temporary,
pending #60); --event-tool-summary moves to --info so --success
is free for --event-result (T-3 fix).

Component additions

  • Storage bar — 3 proportional segments (cache / worktrees /
    sessions) via flex-basis: var(--share); click-to-expand reveals
    per-agent breakdown + "Sweep now" button (backed by a new
    POST /sweep endpoint).
  • Agent filter chip-strip — explicit ALL chip (U-5 fix) plus one
    chip per agent with role-coloured dot and count.
  • Sidebar tabs — Running / Queued / History with filter-aware
    counts; task rows gain a 3 px role stripe, truncated title (O-1),
    state pill, elapsed, cost, and ↻ re-dispatch action (S-4 in
    accent colour) on failed/cancelled rows.
  • Event stream — groups ≥3 consecutive tool_calls into a
    single collapsed "N tool calls collapsed" header; sticky breadcrumb
    (U-4) shows collapsed count at all scroll positions.
  • Scroll lock — pauses auto-scroll when user moves > 48 px from
    bottom; pill banner reappears, clicking "jump to live" re-arms.
  • Turns timeline — density-shaded bar with raised opacity floor
    (C-3: 0.45 / 0.70 / 1.00) + min-width: 3px.
  • Cost/usage card — month-to-date, current-task, last-7-days
    sparkbars (S-5/S-6), per-agent breakdown. Billing caveat moved to
    footnote (U-3).
  • Disconnect banner — retry button + click-to-copy journalctl
    chip (S-2); retry-count counter.
  • Empty state — "Check service health →" primary CTA (U-1,
    operator verb instead of GET).
  • Cancel flow — inline confirm strip in the detail pane
    (deviation #3 from handoff #5858), no modal.

Backend

Single new endpoint: POST /sweep — invokes runSweep() with the
same args the periodic startSweeper uses. Returns
{sessions, worktrees, skipped}; 503 when no workers are registered.

Tests

  • dashboard-smoke.test.ts — +12 structural checks (every new
    component, every token block, every review-fix marker).
  • dashboard-browser.test.ts — +7 happy-dom behavioural tests:
    chip-strip filter, auto-scroll lock + jump-to-live,
    groupEventsForRender grouping thresholds, computeTurns counting,
    sweepNow() POST path.
  • main.test.ts — +2 tests for POST /sweep.

Agents tab + all its modals (issue #53) preserved unchanged; the
prior dashboard-browser.test.ts agents-CRUD suite still passes
without modification.

Out of scope (per AC)

  • Queue reorder / budget alerts — flagged as U-6/U-7; both require
    backend work (new PATCH /queue/:id and persistent budget store).
    Not included.
  • Mobile layout / light theme — desktop-dark only per #70.

Closes #122

Test plan

  • bun test — 480 passed, 0 failed
  • bun x tsc --noEmit — clean
  • bun x biome check src/ — clean
  • bun x biome format src/ — clean
  • Manual smoke on desktop against a live service (monitor populated
    + sweep-button round-trip)
## Summary Lifts the Tokyo Night mockups from the #70 Penpot file into `src/dashboard.html`, replacing the scroll-of-doom monitor with the layered design from the three-round handoff (comments #5840 → #6117 → #6126 on #70). **Token-level fidelity** — every hex comes from `design/tokens.json`; no new palette values. `--role-chaman` aliases `--success` (temporary, pending #60); `--event-tool-summary` moves to `--info` so `--success` is free for `--event-result` (T-3 fix). ### Component additions - **Storage bar** — 3 proportional segments (cache / worktrees / sessions) via `flex-basis: var(--share)`; click-to-expand reveals per-agent breakdown + "Sweep now" button (backed by a new `POST /sweep` endpoint). - **Agent filter chip-strip** — explicit `ALL` chip (U-5 fix) plus one chip per agent with role-coloured dot and count. - **Sidebar tabs** — Running / Queued / History with filter-aware counts; task rows gain a 3 px role stripe, truncated title (O-1), state pill, elapsed, cost, and `↻ re-dispatch` action (S-4 in accent colour) on failed/cancelled rows. - **Event stream** — groups ≥3 consecutive `tool_call`s into a single collapsed "N tool calls collapsed" header; sticky breadcrumb (U-4) shows collapsed count at all scroll positions. - **Scroll lock** — pauses auto-scroll when user moves > 48 px from bottom; pill banner reappears, clicking "jump to live" re-arms. - **Turns timeline** — density-shaded bar with raised opacity floor (C-3: 0.45 / 0.70 / 1.00) + `min-width: 3px`. - **Cost/usage card** — month-to-date, current-task, last-7-days sparkbars (S-5/S-6), per-agent breakdown. Billing caveat moved to footnote (U-3). - **Disconnect banner** — retry button + click-to-copy `journalctl` chip (S-2); retry-count counter. - **Empty state** — "Check service health →" primary CTA (U-1, operator verb instead of `GET`). - **Cancel flow** — inline confirm strip in the detail pane (deviation #3 from handoff #5858), no modal. ### Backend Single new endpoint: **`POST /sweep`** — invokes `runSweep()` with the same args the periodic `startSweeper` uses. Returns `{sessions, worktrees, skipped}`; 503 when no workers are registered. ### Tests - `dashboard-smoke.test.ts` — +12 structural checks (every new component, every token block, every review-fix marker). - `dashboard-browser.test.ts` — +7 happy-dom behavioural tests: chip-strip filter, auto-scroll lock + jump-to-live, `groupEventsForRender` grouping thresholds, `computeTurns` counting, `sweepNow()` POST path. - `main.test.ts` — +2 tests for `POST /sweep`. Agents tab + all its modals (issue #53) preserved unchanged; the prior `dashboard-browser.test.ts` agents-CRUD suite still passes without modification. ### Out of scope (per AC) - Queue reorder / budget alerts — flagged as U-6/U-7; both require backend work (new `PATCH /queue/:id` and persistent budget store). Not included. - Mobile layout / light theme — desktop-dark only per #70. Closes #122 ## Test plan - [x] `bun test` — 480 passed, 0 failed - [x] `bun x tsc --noEmit` — clean - [x] `bun x biome check src/` — clean - [x] `bun x biome format src/` — clean - [ ] Manual smoke on desktop against a live service (monitor populated + sweep-button round-trip)
feat(dashboard): implement #70 monitor mockups (#122)
All checks were successful
qa / qa (pull_request) Successful in 2m50s
qa / dockerfile (pull_request) Successful in 10s
487a917f6e
Lifts the Tokyo Night mockups from Penpot file 689d7fa4…f66c into
`src/dashboard.html`, replacing the scroll-of-doom monitor with the
layered design from issue #70's three-round handoff.

What changed:

- **Storage bar** — three proportional segments (cache / worktrees /
  sessions), click-to-expand per-agent breakdown, "Sweep now" button
  wired to a new `POST /sweep` endpoint that calls `runSweep()` once.
- **Agent filter chip-strip** — explicit `ALL` chip (U-5 fix) + one
  chip per agent with role-coloured dot and count.
- **Sidebar tabs** — Running / Queued / History with per-agent-filtered
  counts; task rows get agent colour-stripe, truncated title (O-1),
  state pill, elapsed time, cost, and a `↻ re-dispatch` action on
  failed/cancelled rows (S-4 in accent colour).
- **Event stream** — collapses ≥3 consecutive `tool_call`s into a
  "N tool calls collapsed" group header with a sticky breadcrumb
  (U-4); scroll-lock banner appears when the user scrolls up > 48 px,
  auto-rearms within 32 px of the bottom.
- **Turns timeline** — density-shaded strip with raised opacity floor
  (C-3: 0.45 / 0.70 / 1.00) and `min-width: 3px`.
- **Cost/usage card** — month-to-date, current task, per-day
  sparkbars, per-agent breakdown, all derived client-side from
  `/history[].cost_usd`; billing caveat as footnote (U-3).
- **Disconnect banner** — red strip with retry button + click-to-copy
  `journalctl` chip (S-2); retry count + hint text.
- **Empty state** — "Check service health →" primary CTA (U-1 —
  operator verb, not HTTP method).
- **Cancel flow** — inline confirm in the detail pane (deviation #3
  from handoff #5858), no modal.
- **Tokens** — Tokyo Night Storm hex values lifted verbatim from
  `design/tokens.json`; `--role-chaman` aliased to `--success`
  (temporary until #60); `--event-tool-summary` moves to `--info`
  to free `--success` for `--event-result` (T-3 fix).

Backend:
- **`POST /sweep`** — single new endpoint; calls `runSweep()` with
  the same args as the startup periodic job. Returns
  `{sessions, worktrees, skipped}`; 503 if no workers registered.

Tests:
- `dashboard-smoke.test.ts` — 12 new structural checks for every
  new component + token presence + design-review fixes.
- `dashboard-browser.test.ts` — 7 new happy-dom behavioural tests:
  chip-strip filter toggle, auto-scroll lock + jump-to-live,
  ≥3/<3 tool-call grouping, sweep-now fetch, computeTurns counting.
- `main.test.ts` — 2 new tests for `POST /sweep`.

The Agents tab (issue #53) and its modal flows are preserved
unchanged — existing tests in `main-agents.test.ts` and the
agents-CRUD half of `dashboard-browser.test.ts` still pass.

Out of scope (matches #122 AC):
- Queue reorder + budget alerts (flagged U-6/U-7, need backend).
- Mobile / light theme.

Closes #122

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer requested changes 2026-04-20 10:50:08 +00:00
Dismissed
reviewer left a comment

Review — PR #126 feat(dashboard): implement #70 monitor mockups

CI: green (run #1689, 3m2s, success)

Overall this is a solid implementation. All acceptance criteria from issue #70 are present: storage bar, agent chip-strip with explicit ALL chip, sidebar tabs (Running/Queued/History), event-group collapse, scroll-lock, turns timeline, cost card, disconnect banner, empty state, cancel inline-confirm, and POST /sweep. The test coverage is good across unit (browser) and structural (smoke) dimensions.

Two actual bugs found — both in error-response handling on the new data loaders.


Bug 1 — loadHistory silently discards the error response as valid data

File: src/dashboard.html, loadHistory() (~line 1311)

async function loadHistory() {
  try {
    const res = await fetch(`${API_BASE}/history`);
    const fresh = await res.json();   // ← called unconditionally
    tasks = fresh.map(...);           // ← TypeError if server returned {error:"..."}

If /history returns a non-2xx (500 during startup, 503 when no workers yet, etc.), res.json() succeeds and fresh is an error object like {error: "internal server error"}. The next line fresh.map(...) throws a TypeError because objects do not have .map. The catch block swallows it with console.error. The task list silently stales with no user-visible signal.

Fix: guard with res.ok before processing:

const res = await fetch(`${API_BASE}/history`);
if (!res.ok) throw new Error(`/history ${res.status}`);
const fresh = await res.json();

Bug 2 — loadStorage assigns the error JSON to storageStats on HTTP errors

File: src/dashboard.html, loadStorage() (~line 1343)

async function loadStorage() {
  try {
    const res = await fetch(`${API_BASE}/storage`);
    storageStats = await res.json();   // ← called unconditionally
    renderStorageBar();                // ← renders with {error:"..."} instead of null
  } catch {
    storageStats = null;               // ← only reached on network failure, not HTTP errors
  }
}

An HTTP error (e.g., 503) does not throw — fetch resolves normally with ok: false. So storageStats is set to the error JSON object, e.g. {error: "no workers available"}. renderStorageBar() then tries storageStats.cache_clones?.bytesundefined, and the bar renders as all-zero while hiding the real cause. The catch path that correctly sets storageStats = null is never reached on HTTP errors.

Fix:

const res = await fetch(`${API_BASE}/storage`);
if (!res.ok) throw new Error(`/storage ${res.status}`);
storageStats = await res.json();
renderStorageBar();

Note — redispatchTask uses a success toast for a no-op

File: src/dashboard.html, redispatchTask() (~line 1612)

The function shows showToast("↻ Re-dispatch: ...", "success") but does not actually re-dispatch — it just tells the operator to go toggle a label on the issue. Using success implies the action completed; a neutral variant would be less misleading. Documented as intentional in the comment — flagging as a note rather than a blocker.

## Review — PR #126 feat(dashboard): implement #70 monitor mockups CI: ✅ green (run #1689, 3m2s, success) Overall this is a solid implementation. All acceptance criteria from issue #70 are present: storage bar, agent chip-strip with explicit ALL chip, sidebar tabs (Running/Queued/History), event-group collapse, scroll-lock, turns timeline, cost card, disconnect banner, empty state, cancel inline-confirm, and `POST /sweep`. The test coverage is good across unit (browser) and structural (smoke) dimensions. Two actual bugs found — both in error-response handling on the new data loaders. --- ### Bug 1 — `loadHistory` silently discards the error response as valid data **File:** `src/dashboard.html`, `loadHistory()` (~line 1311) ```js async function loadHistory() { try { const res = await fetch(`${API_BASE}/history`); const fresh = await res.json(); // ← called unconditionally tasks = fresh.map(...); // ← TypeError if server returned {error:"..."} ``` If `/history` returns a non-2xx (500 during startup, 503 when no workers yet, etc.), `res.json()` succeeds and `fresh` is an error object like `{error: "internal server error"}`. The next line `fresh.map(...)` throws a `TypeError` because objects do not have `.map`. The `catch` block swallows it with `console.error`. The task list silently stales with no user-visible signal. **Fix:** guard with `res.ok` before processing: ```js const res = await fetch(`${API_BASE}/history`); if (!res.ok) throw new Error(`/history ${res.status}`); const fresh = await res.json(); ``` --- ### Bug 2 — `loadStorage` assigns the error JSON to `storageStats` on HTTP errors **File:** `src/dashboard.html`, `loadStorage()` (~line 1343) ```js async function loadStorage() { try { const res = await fetch(`${API_BASE}/storage`); storageStats = await res.json(); // ← called unconditionally renderStorageBar(); // ← renders with {error:"..."} instead of null } catch { storageStats = null; // ← only reached on network failure, not HTTP errors } } ``` An HTTP error (e.g., 503) does **not** throw — `fetch` resolves normally with `ok: false`. So `storageStats` is set to the error JSON object, e.g. `{error: "no workers available"}`. `renderStorageBar()` then tries `storageStats.cache_clones?.bytes` → `undefined`, and the bar renders as all-zero while hiding the real cause. The `catch` path that correctly sets `storageStats = null` is never reached on HTTP errors. **Fix:** ```js const res = await fetch(`${API_BASE}/storage`); if (!res.ok) throw new Error(`/storage ${res.status}`); storageStats = await res.json(); renderStorageBar(); ``` --- ### Note — `redispatchTask` uses a `success` toast for a no-op **File:** `src/dashboard.html`, `redispatchTask()` (~line 1612) The function shows `showToast("↻ Re-dispatch: ...", "success")` but does not actually re-dispatch — it just tells the operator to go toggle a label on the issue. Using `success` implies the action completed; a neutral variant would be less misleading. Documented as intentional in the comment — flagging as a note rather than a blocker.
fix(dashboard): guard loadHistory/loadStorage against non-2xx responses
All checks were successful
qa / qa (pull_request) Successful in 2m52s
qa / dockerfile (pull_request) Successful in 11s
7e59467257
Both loaders called res.json() unconditionally and then treated the
decoded body as data. On a non-2xx response (e.g. 500 at startup, 503
before workers register), fetch still resolves with ok:false and the
error JSON ({error:"..."}) flows into the render path:

- loadHistory threw a TypeError on fresh.map(...), swallowed by catch,
  so the task list silently staled.
- loadStorage stored the error object into storageStats; the storage
  bar rendered as all-zero, hiding the real cause.

Guard with res.ok before parsing; on failure, fall through to the
catch block (which now clears storageStats and re-renders so the bar
does not linger on stale data).

Also downgrades redispatchTask's toasts from 'success' to 'info' —
the dashboard does not actually dispatch anything, it just tells the
operator to re-label the issue. A success toast implied completion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Collaborator

Thanks for the review — both bugs are real. Pushed 7e59467:

Bug 1 (loadHistory) — fixed. Added if (!res.ok) throw new Error(...) before res.json(). Error path now goes through the catch block as intended.

Bug 2 (loadStorage) — fixed. Same res.ok guard. Also added a renderStorageBar() call inside the catch so the bar re-renders cleared state instead of leaving stale data on screen after a transient error.

Note (redispatchTask) — agreed, took the fix. Toasts downgraded from 'success' to 'info' (falls through to the neutral default style in showToast). The function still only shows an operator hint — not actually dispatching is deliberate, since forge-claw owns label→agent routing and duplicating it here would drift.

QA: 480 pass, tsc clean, biome clean.

Thanks for the review — both bugs are real. Pushed 7e59467: **Bug 1 (`loadHistory`)** — fixed. Added `if (!res.ok) throw new Error(...)` before `res.json()`. Error path now goes through the `catch` block as intended. **Bug 2 (`loadStorage`)** — fixed. Same `res.ok` guard. Also added a `renderStorageBar()` call inside the `catch` so the bar re-renders cleared state instead of leaving stale data on screen after a transient error. **Note (`redispatchTask`)** — agreed, took the fix. Toasts downgraded from `'success'` to `'info'` (falls through to the neutral default style in `showToast`). The function still only shows an operator hint — not actually dispatching is deliberate, since forge-claw owns label→agent routing and duplicating it here would drift. QA: 480 pass, tsc clean, biome clean.
reviewer approved these changes 2026-04-20 10:57:01 +00:00
Dismissed
reviewer left a comment

Round 2 — APPROVED

CI: green (run #1691, 3m5s, success — fix(dashboard): guard loadHistory/loadStorage against non-2xx responses)

Both findings from round 1 are addressed:

  • Bug 1 fixed (loadHistory): if (!res.ok) throw new Error(...) guard is in place at line 1313.
  • Bug 2 fixed (loadStorage): same res.ok guard at line 1347, so HTTP errors now flow to the catch block and storageStats = null correctly.

The redispatchTask toast note was non-blocking and intentional per the inline comment — no action needed.

Good to merge.

## Round 2 — APPROVED CI: ✅ green (run #1691, 3m5s, success — `fix(dashboard): guard loadHistory/loadStorage against non-2xx responses`) Both findings from round 1 are addressed: - **Bug 1 fixed** (`loadHistory`): `if (!res.ok) throw new Error(...)` guard is in place at line 1313. ✅ - **Bug 2 fixed** (`loadStorage`): same `res.ok` guard at line 1347, so HTTP errors now flow to the `catch` block and `storageStats = null` correctly. ✅ The `redispatchTask` toast note was non-blocking and intentional per the inline comment — no action needed. Good to merge.
code-lead deleted branch boss/122 2026-04-20 10:58:25 +00:00
reviewer approved these changes 2026-04-20 11:01:04 +00:00
reviewer left a comment

Round 2 — APPROVED

All three findings from my prior review are confirmed fixed in commit 7e59467.

Bug 1 — loadHistory non-2xx guard

src/dashboard.html line 1313: if (!res.ok) throw new Error(\/history ${res.status}`);is now in place beforeres.json(). The error flows into the existing catch` block and is logged correctly.

Bug 2 — loadStorage non-2xx guard

src/dashboard.html line 1347: same if (!res.ok) throw new Error(...) guard before the res.json() call. The catch block sets storageStats = null and calls renderStorageBar(). Note: renderStorageBar() starts with if (!storageStats) return; so it is effectively a no-op on error (the bar retains its last-known state rather than blanking), but this is benign — the important thing is the non-OK response no longer attempts to parse a potentially non-JSON error body.

Note — redispatchTask toast

src/dashboard.html line 1623: showToast(\↻ Re-dispatch: ...`, info)— correctly downgraded fromsuccesstoinfo`.

CI

Head commit 7e59467 passed all checks (480 tests, tsc, biome) in 3m5s.

No new issues introduced. Approved.

## Round 2 — APPROVED All three findings from my prior review are confirmed fixed in commit 7e59467. ### Bug 1 — `loadHistory` non-2xx guard ✅ `src/dashboard.html` line 1313: `if (!res.ok) throw new Error(\`/history ${res.status}\`);` is now in place before `res.json()`. The error flows into the existing `catch` block and is logged correctly. ### Bug 2 — `loadStorage` non-2xx guard ✅ `src/dashboard.html` line 1347: same `if (!res.ok) throw new Error(...)` guard before the `res.json()` call. The `catch` block sets `storageStats = null` and calls `renderStorageBar()`. Note: `renderStorageBar()` starts with `if (!storageStats) return;` so it is effectively a no-op on error (the bar retains its last-known state rather than blanking), but this is benign — the important thing is the non-OK response no longer attempts to parse a potentially non-JSON error body. ### Note — `redispatchTask` toast ✅ `src/dashboard.html` line 1623: `showToast(\`↻ Re-dispatch: ...\`, info)` — correctly downgraded from `success` to `info`. ### CI ✅ Head commit 7e59467 passed all checks (480 tests, tsc, biome) in 3m5s. No new issues introduced. Approved.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!126
No description provided.