feat(web): a dispatch timeline lens on the fleet rail - #335
Conversation
P5.4 (docs/conductor-frontends-design.md §4). The rail gains a second lens: what was dispatched when, and what came back. The lanes answer "what needs me now" by re-sorting on urgency, which is exactly wrong for retrospection — a list that reorders itself cannot be read as history. §4 asks for both, so the two now sit behind a switch rather than one pretending to do the other's job. Built from the two streams the board already carries, tasks and events, with no new wire data. Four rules, each from a case that would otherwise read wrong: **A dispatch and its outcome routinely share a millisecond** on a fast task. Newest-first then has to render the dispatch SECOND, or the row order implies the result preceded the request. **An unknown event type is kept, not dropped.** A newer daemon naming something differently must not silently erase rows from the one view whose purpose is history. **Events outlive their tasks.** The board is capped, so a long run ages tasks out while their events remain — and "the digest for a task I can no longer see" is the most useful thing left. Such a row keeps its task id so it still correlates by eye. **Days split on LOCAL midnight.** A UTC split files an evening dispatch under tomorrow for anyone east of the meridian. The target resolves to a session NAME where one exists, degrading to a short id prefix otherwise. That came from rendering the real board rather than fixtures: every dispatch row showed a full UUID, which is noise in a 288px rail and tells the reader nothing — and a finished task has no session at all, since the dispatcher tears its worker down after the digest. Verified against a captured board of 5 tasks and 5 events: day separators land correctly, each outcome sits directly above its dispatch, and the two tasks blocked by the old daemon-collision bug show their real task_blocked digests. 13 timeline tests; 279 web tests; typecheck, lint and build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔮 Oracle Review
🎯 Start Here
web/src/components/FleetRail.tsx (~21 min) — Security changes in FleetRail.tsx
📋 PR Summary
What this PR does: Adds a dispatch timeline lens to the fleet rail that displays historical dispatch data with outcomes, separate from the existing urgency-based lanes view.
Key changes:
- Implemented timeline view using existing tasks and events streams (no new wire data)
- Added logic to handle dispatch/outcome ordering on same millisecond timestamps
- Implemented local midnight day splitting instead of UTC
- Added session name resolution with UUID prefix fallback for readability
Areas affected: Fleet rail component, Timeline display logic
Testing notes: Verified against real board data with 5 tasks and 5 events; includes 13 timeline tests as part of 279 total web tests
🔍 Code Review
This is a thoughtful implementation that solves a real UX problem—separating urgency-based workflows from historical retrospection. The four design rules show deep consideration of edge cases, and leveraging existing data streams avoids unnecessary backend changes. Strong verification against real data and comprehensive test coverage gives confidence in the quality.
What's good:
- ✨ Excellent architectural decision to use two separate lenses rather than overcomplicating a single view
- ✨ Smart approach of building from existing wire data (tasks + events) without new API endpoints
- ✨ Thorough handling of edge cases: same-millisecond ordering, unknown event types, outliving tasks, and local timezone splitting
Generated by Oracle - Highflame's AI Code Reviewer
Review on #335 asked to ensure the local-midnight intent survives into the header: `TimelineView` renders `new Date(day.day).toLocaleDateString(…)`, and a `YYYY-MM-DD` string there would be parsed as UTC midnight and shift the header a day for anyone west of the meridian. No code change is needed — `TimelineDay.day` is an epoch ms at local midnight (`new Date(y, m, d).getTime()`), so `new Date(number)` carries no string parsing and `toLocaleDateString` round-trips it. The concern is correct about the hazard and wrong about the current type. What was missing is a test of that round trip. `groupByDay`'s existing test asserts the field's exact numeric value, which a refactor to a string key would naturally rewrite alongside itself — green test, shifted header. This asserts the CONSUMER's operation instead: re-read as a local `Date`, the value lands on the calendar day the entries belong to. Local-time constructors on both sides, so it holds in every zone rather than only in CI's. Verified by mutation: swapping `day` for a `YYYY-MM-DD` string under TZ=America/Los_Angeles fails this test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔮 Oracle Review
🎯 Start Here
web/src/components/FleetRail.tsx (~21 min) — Security changes in FleetRail.tsx
📋 PR Summary
What this PR does: Adds a dispatch timeline lens to the fleet rail, providing a retrospective view of what was dispatched when and what came back, complementing the existing urgency-sorted lane view behind a switch.
Key changes:
- New timeline lens in FleetRail component with lens switch between lanes and timeline views
- Pure-function fleet-timeline module that interleaves dispatches and outcomes with same-millisecond ordering, unknown event preservation, orphaned event handling, and local-midnight day boundaries
- Target display resolution prefers session name over raw UUID, degrading gracefully to short id prefix for finished workers
Areas affected: Fleet rail UI component, Timeline computation logic, Dispatch history rendering
Testing notes: Verified against a real board with 5 tasks and 5 events; 13 timeline-specific tests added, 279 web tests total passing. Manual verification of day separators, outcome-above-dispatch ordering, and blocked task display confirmed.
🔍 Code Review
This is a thoughtful, well-structured addition that correctly identifies that a retrospectively-readable timeline and an urgency-sorted lane serve fundamentally different purposes and shouldn't be conflated. The four ordering/display rules are each motivated by real failure modes observed against actual data, and the pure-function decomposition keeps the logic testable and the component clean.
What's good:
- ✨ The architectural decision to separate timeline from lanes rather than enriching a single list shows strong product thinking — a self-reordering list is indeed useless for retrospection
- ✨ Each of the four timeline rules is grounded in a concrete edge case rather than speculative over-engineering, which is exactly the right balance
- ✨ Building from existing streams (tasks + events) with no new wire data is a clean approach that keeps the client-side change self-contained
- ✨ Using local midnight for day splits shows empathy for the actual user reading the timeline in their own timezone
Generated by Oracle - Highflame's AI Code Reviewer
… renderer Addresses the Oracle review on #335. - groupByDay keyed on the day instead of grouping consecutive entries, so unsorted input can no longer print the same date twice. The sorted-input precondition is removed rather than documented. - The lens set is one FLEET_LENSES list with a derived FleetLens type, and the switch renders from it, so a third lens is one entry. - A component test for TimelineView: an event type this client has never heard of renders under its raw name, and an event whose task has aged off the board renders by task id. Both mutation-checked against a renderer that filters rows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
P5.4 (conductor-frontends-design.md §4). The rail gains a second lens: what was dispatched when, and what came back.
Why a second lens rather than a richer list
The lanes answer "what needs me now" by re-sorting on urgency — which is exactly wrong for retrospection. A list that reorders itself cannot be read as history. §4 asks for both, so they now sit behind a switch instead of one pretending to do the other's job.
Built from the two streams the board already carries (tasks + events). No new wire data.
Four rules, each from a case that would otherwise read wrong
A dispatch and its outcome routinely share a millisecond on a fast task. Newest-first then has to render the dispatch second, or the row order implies the result preceded the request.
An unknown event type is kept, not dropped. A newer daemon naming something differently must not silently erase rows from the one view whose entire purpose is history.
Events outlive their tasks. The board is capped, so a long run ages tasks out while their events remain — and "the digest for a task I can no longer see" is the most useful thing left. Such a row keeps its task id so it still correlates by eye.
Days split on LOCAL midnight. A UTC split files an evening dispatch under tomorrow for anyone east of the meridian.
A fix that only showed up on real data
The target now resolves to a session name where one exists, degrading to a short id prefix otherwise.
That came from rendering the actual captured board rather than fixtures — every dispatch row showed a full UUID like
7bdbd557-656a-4b0c-bab1-6a7894ab3efe, which is noise in a 288px rail and tells the reader nothing. And a finished task has no session at all, since the dispatcher tears its worker down after the digest, so the fallback matters as much as the happy path.Verified against a real board
5 tasks, 5 events:
Day separators land correctly, each outcome sits directly above its dispatch, and the two tasks blocked by the old daemon-collision bug (#319) show their real
task_blockeddigests.Verification: 13 timeline tests, 279 web tests total, typecheck / lint / build clean.
What's still blocked in P5.4
The map/tree lens remains undrawable —
createdByis always the conductor (never the dispatching agent), workers get no fleet tools so sub-workers can't exist, and finished workers are torn down before they could be leaves. It needs daemon changes first; details in my earlier analysis.Routing rationale and cross-vendor review edges likewise have no wire representation yet.
🤖 Generated with Claude Code