Dashboard: make the held-mail counter clickable, showing the held messages (from → to) - #1510
Dashboard: make the held-mail counter clickable, showing the held messages (from → to)#1510mohidmakhdoomi wants to merge 23 commits into
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rn, isolated-Tower Playwright Addresses the architect's plan review. Blocking finding confirmed: heldSummaryForWorkspace filters not_before, listHeld does not, so the badge count and the list disagree by design. Popover now groups Held (=== badge count) and Scheduled separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r (not a HOME redirect) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pover The dashboard's held-mail counter was inert text; finding out WHAT was held meant dropping to 'afx inbox'. It is now a disclosure button listing each held message as from -> to with its age and why-held reason. Server: handleInboxList gains a workspaceOverride param (override wins over ?workspace=, so a scoped call cannot be redirected), plus a workspace-scoped GET branch reusing it. The branch matches 'inbox' EXACTLY, so inbox/:id (body-bearing) and inbox/:id/dismiss (mutating) stay unreachable from the dashboard - Spec 1313's redaction rule and decision 8 hold. Held vs Scheduled: heldSummaryForWorkspace (the badge count) filters not_before while listHeld (the list) does not, so the count is a lower bound on the list length, by design. The popover groups 'Held (N)' - exactly the badge count - and 'Scheduled (M)' for pre-due --delay rows, mirroring afx inbox rather than hiding the difference. Verified in a real browser against an isolated Tower (AF_TEST_DB, port 14700): 23/23 checks, including GET .../api/inbox returning 200 rather than a 401 masquerading as an empty list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Arch (cold): the two route tables in tower-routes.ts (Tower-level ROUTES vs handleWorkspaceRoutes) and why a CLI-live endpoint can still 404 for the dashboard; the heldCount-vs-listHeld asymmetry, framed as do-not-fix. Lessons (cold, Testing): a browser assertion can pass while proving nothing - assert its precondition; networkidle never fires against an SSE-holding page, and assert the status code because auth failures render as tidy error states. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Architect integration review (claude lane, Medium tier)Architectural Fit — strongThe server change is the right seam and follows the established pattern exactly. The exact-match on Client side, Findings1. 2. 3. The 4. 5. Minor UX: refetch blanks the list. On a 6. Doc typo: 7. Evidence-script accretion. VERDICT: COMMENT
|
All findings verified against the files first; every one real except the reported HeldMessage jsdoc typo, which was already correct (reported not-reproduced). - handleInboxList: an empty workspaceOverride widened to ALL workspaces while the docstring claimed it stayed scoped. Made the code true rather than softening the comment - a scoped call with a blank override now matches no rows. Narrow export added as a test seam (the branch is unreachable via handleRequest). - Annotate the server projection as HeldMessage[] so drift fails the build. - Empty-state notice keys on heldRows, not messages, so 'nothing held but rows are scheduled' reads correctly. - Latch loadMessages in a ref: an inline lambda from a future caller would otherwise turn the refetch effect into a loop. - Keep prior rows during refetch (aria-busy carries in-flight state); errors still replace them. - Add aria-live; reword a footer that named an id the panel never renders. - arch.md: restore the paragraph break my edit swallowed. - Review file: corrected test counts (49 new, not 37) - recounted from merge-base. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PIR Review: Clickable held-mail counter with a held-messages popover
Fixes #1450
Summary
The dashboard header's held-mail counter ("2 held") was inert text, so learning what was held
meant dropping to
afx inboxin a terminal. It is now a disclosure button — dotted underline,aria-expanded/aria-controls— that opens a panel listing each held message asfrom → towith its age and why-held reason. Server-side this reuses the existing
handleInboxListbehind anew workspace-scoped
GET /api/inboxbranch; no new handler and no new projection, so themetadata-only redaction rule and CLI-only dismissal (Spec 1313 decision 8) are untouched.
The one genuinely subtle part is that the badge count and the list disagree by design — see
Things to Look At.
Files Changed
apps/web/src/components/HeldCountBadge.tsx(+219 / -…) — span → disclosure button, grouped popover, generation-guarded lazy fetchapps/web/src/index.css(+125 / -0) — underline affordance, popover panel, z-index tierapps/web/src/lib/heldMail.ts(+48 / -0) — new;formatHeldAge/formatHeldDuration/isScheduledapps/web/src/lib/api.ts(+19 / -0) —fetchInboxapps/web/src/components/App.tsx(+8 / -3) — wireloadMessagespackages/codev/src/agent-farm/servers/tower-routes.ts(+30 / -3) —workspaceOverrideparam + workspace-scoped branchpackages/types/src/api.ts(+46 / -0) —HeldMessagepackages/types/src/index.ts(+1 / -0) — export itapps/web/__tests__/HeldCountBadge.test.tsx— 32 cases total: 5 originals kept unchanged, 27 newapps/web/__tests__/heldMail.test.ts— new, 10 casespackages/codev/src/agent-farm/__tests__/inbox-routes.test.ts— 12 new route cases (14 → 26 in the file)packages/codev/scripts/issue-1450-dashboard-evidence.mts(+370 / -0) — new; real-browser evidence harnesscodev/plans/1450-dashboard-make-the-held-mail-c.md,codev/state/pir-1450_thread.md,codev/resources/arch.md,codev/resources/lessons-learned.md— artifacts and governanceCommits
b6c15774e[PIR Dashboard: make the held-mail counter clickable, showing the held messages (from → to) #1450] Plan draft934e9354e[PIR Dashboard: make the held-mail counter clickable, showing the held messages (from → to) #1450] Plan revised: Held/Scheduled split, a11y disclosure pattern, isolated-Tower Playwright5a782c0b7[PIR Dashboard: make the held-mail counter clickable, showing the held messages (from → to) #1450] Plan: use AF_TEST_DB isolation seam for the evidence Tower (not a HOME redirect)d70e3503f[PIR Dashboard: make the held-mail counter clickable, showing the held messages (from → to) #1450] feat: clickable held-mail counter with a held-messages popover7483bcce9[PIR Dashboard: make the held-mail counter clickable, showing the held messages (from → to) #1450] Review + retrospective2cb2b3623[PIR Dashboard: make the held-mail counter clickable, showing the held messages (from → to) #1450] Address 3-way consult + architect integration reviewTest Results
pnpm build: ✓ passpnpm test(@cluesmith/codev): ✓ 4917 passed, 48 skipped, 0 failuresapps/web(pnpm --filter @cluesmith/codev-web test): ✓ 33 files — 37 new web tests (27 component + 10 formatter)testscript runs only thecodevpackage; web tests need the filtered command.packages/codev/scripts/issue-1450-dashboard-evidence.mts— 23/23 checksin headless Chromium against an isolated Tower (worktree build, port 14700,
NODE_ENV=test+AF_TEST_DB). Real workspace, real shellper PTYs painted with an occupied composer, realPOST /api/sendheld by the render gate, real built SPA. Asserts the underline affordance,aria-expandedtoggling,from → torows, the Held/Scheduled grouping,GET .../api/inboxreturning 200, Held-group length === badge count, popover stacking overa live terminal, and Escape-closes-with-focus-return.
dev-approvalgate: the reviewer exercised an interactive instance ofthat same environment personally (2 held + 1 scheduled fixture) and approved.
Architecture Updates
Routed COLD only (
codev/resources/arch.md). Neither hot file was touched: both are at their10-entry cap, and nothing here is a cross-cutting invariant worth displacing an existing entry —
these are subsystem facts about Tower's routing and the mailbox, which is what the cold archive is
for.
Two additions:
dashboard."
tower-routes.tsdispatches through two independent tables — the Tower-levelROUTESmap (/api/<thing>, used by the CLI) andhandleWorkspaceRoutes(
/workspace/<b64>/api/<thing>, used by the dashboard, whosegetApiBase()returns'./').Registering in one does not register in the other, which is exactly why this issue existed:
GET /api/inboxhad backedafx inboxsince Spec 1313 while./api/inbox404'd for thedashboard. Also records why workspace-scoped handlers take a
workspaceOverridethat winsover
?workspace=, and that the exact-vs-prefix match is a security boundary.including the corollary that a scheduled-only state is invisible to the badge by design.
Lessons Learned Updates
Routed COLD only (
codev/resources/lessons-learned.md→ Testing). Both entries are aboutbrowser-test technique — useful, but not the always-injected kind, and the hot file is at cap.
test. My z-index check confirmed the popover was on top at its centre point and was green
and worthless:
querySelector('.xterm')had returned the left pane's terminal, which cannever overlap a top-right panel, so the actual hazard (xterm's WebGL canvas painting over an
unlayered element) was never exercised. The fix asserts the setup — "a terminal genuinely
overlaps this rect" — before asserting the property. Same shape as artifact-canvas: remote command channel for review navigation (Tower relay + sdk route) #1401's guard lesson.
waitUntil: 'networkidle'can neverfire (the SSE stream at
/api/eventsstays open for the page's lifetime), and a lazily-fetchedpanel must be waited on by its loaded content or assertions read the "Loading…" state. Plus:
assert the status code of the endpoint under test, because an auth failure renders as a
tidy error state that looks like a working UI.
Things to Look At During PR Review
1. The Held/Scheduled split — the part that took two attempts to get right.
My first plan asserted that pre-due
--delayrows "already inflateheldCount". That wasbackwards, and the architect's review caught it. Verified against
db/mailbox.ts:heldSummaryForWorkspace(:215-227) — the badge count — filtersnot_before IS NULL OR not_before <= now.listHeld(:113-124) — behindGET /api/inbox— has no such filter.So
heldCount <= inbox.length, always, deliberately: a scheduled send is "scheduled, not stuck"and must not raise an attention indicator. A naive popover would say "2 held" and list 3 rows. The
panel therefore groups:
Held (N)where N is exactly the badge count, above a secondaryScheduled (M)with a countdown and explanatory copy. Groups render only when non-empty, so theordinary case looks like one plain list. Pinned by a unit test (1 due + 1 pre-due at
count={1})and re-verified live in the browser run, which reproduced exactly that fixture.
2. Accepted edge case, not an oversight. With 0 due and 1 scheduled row,
heldCountis 0, thebadge does not render, and that row is unreachable from the dashboard. That is the existing
contract — the badge is an attention indicator — and
afx inboxremains the surface that seesit. Surfacing it would mean rendering a badge whose count is 0. Documented on the component, on
the
HeldMessagetype, and in arch.md; changing it is a change to what the badge counts.3. The exact-match on
'inbox'is load-bearing.inbox/:idreturns the message body andinbox/:id/dismissmutates. Both must stay unreachable under the workspace prefix for the"metadata-only, read-only" claim to hold. Three tests pin that (both 404, and the row survives a
POST) rather than leaving it to inspection.
4.
formatHeldAge, notformatDuration.apps/web/src/lib/open-files-shells-utils.ts:2-10already exports a
formatDurationwith different semantics (minute granularity,<1mfloor) andexisting callers. The new helper is second-granularity to match
afx inbox, so it is separatelynamed rather than merged — two same-named formatters with different output in one
lib/is atrap. It is a deliberate ~10-line port, not an import: the web app must not cross the
server/client isolation boundary (#1189), and
codev-typesis a types-only devDependency.5. Fetch lifecycle. Loads on open and on
countchange while open (the count is SSE-driven, soa change means the mailbox moved). Each load carries a generation counter; stale responses are
discarded — React 19 would not warn about the open→close→open race. And while the panel is open
the component stays mounted even at
count <= 0, becauseuseOverviewpolls every 2.5s and thelast row being delivered would otherwise unmount the button and drop focus to
<body>. Theclosed-at-zero contract is unchanged, so the original zero-state tests pass untouched.
6. Not a dialog. Deliberately the WAI-ARIA disclosure pattern (
aria-expanded+aria-controls+ a real<ul>), notrole="dialog"— a dialog role without moving focus in isannounced inconsistently, and this panel should not steal focus from the terminals.
How to Test Locally
pir-1450→ Review Diffreal
global.dbare never touched.from → to(a null sender renders?, matchingafx inbox), with age and reason.--delaysend in flight,Held (N)matches the badge andScheduled (M)is separate.panel does not paint over it.
afx inbox -w <workspace>row for row, and the Held group alone matches the badge count.
Post-Consultation Fixes
The 3-way consult and the architect's integration review both landed as non-blocking
(APPROVE / COMMENT / APPROVE). Every finding was verified against the files before acting; all
were real and all are fixed in
2cb2b3623(see the PR's commit list).handleInboxList's docstring claimed an emptyworkspaceOverridestays scoped, butrawWorkspace ? … : undefinedwidened it to all workspaces'', which matches no rows. The safe failure for a scoped call is zero rows, never every row.count → 0with scheduled rows left the panel with no "cleared" notice, andScheduled's "not counted above" had nothing above itheldRows.length === 0rather thanmessages.length === 0, with copy that distinguishes "cleared" from "nothing held, rows below are scheduled".packages/types/src/api.tsbefore commit.)loadMessagesprop identity drove the refetch effect — an inline lambda from a future caller would looploadnow has empty deps, so behaviour no longer depends on a caller remembering to memoize.aria-live, so asynchronously-loaded rows were never announcedaria-live="polite"+aria-busy.afx inbox dismiss <id>but no id is rendered anywhereIds and dismissal: afx inbox. Rendering a full uuid per row would dominate the row, and this surface never mutates.HeldMessagewas client-side decoration onlyconst projected: HeldMessage[], so a drifting projection (dropped field, or abodyslipping in) fails the server build.countchange while open; flashing the list away is the worst moment to do it.aria-busycarries the in-flight state; only a cold open shows the spinner. An error still replaces the rows — once a refetch fails, the old list is no longer known to be current.HeldMessagejsdoc typo/api/inbox:idGET /api/inbox/:id(packages/types/src/api.ts:597). No change made.Re-verified after the fixes:
pnpm build✓, web suite ✓ (373 passed), route suite ✓ (26 passed),and the browser evidence re-run ✓ 23/23.
Flaky Tests
None. No pre-existing failures were encountered in the full suite, so nothing was
skipped or quarantined.
Scope Note
Out of scope and unchanged, per the plan: VSCode's
mailbox-indicators.ts(the issue is titledDashboard); mobile (
MobileLayoutdoes not render the badge today); dismiss/show-body from theUI (Spec 1313 decision 8 and the redaction rule); and
codev-skeleton/(this is product code, notframework files, so there is no skeleton twin to mirror).