feat(console): Fleet Room — a ⌘K morph-view for the fleet#2129
Conversation
Turn the fleet from a page-load switcher into a co-present room summoned from the command palette (part of the ⌘K palette-UX overhaul). ⌘K → "Fleet Room" (Agents group) morphs the palette into a native view — a sibling of PaletteChat — showing every workspace agent as a presence-aware member you can Open, start/stop, or address in place, plus a one-key broadcast to everyone online. - FleetRoom.tsx: native PaletteView (roster + presence + address/broadcast), styled entirely through DS tokens so it tracks the console theme in light+dark. - api.sendToAgent(slug, msg): slug-targeted, non-streaming /api/chat send through the hub's per-agent proxy (works in the browser and desktop WKWebView alike). Broadcast fans it out to every online member. - usePaletteRegistry: register the view + a "Fleet Room" command at the top of the Agents group; opening a member routes through the palette nav chokepoint, so it forwards correctly from the frameless desktop launcher too. - e2e: ⌘K → Fleet Room lists members with presence and addresses one. Core + default, ungated. Additive — the existing quick-chat/toggle palette entries are untouched; folding them into the room is the next step of the overhaul. Deferred to v1: the live fleet-wide activity feed (aggregate each member's event bus, ADR 0039) + inline reply transcripts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (10)
WalkthroughThe PR adds a Fleet Room command-palette view with a sorted member roster, member DMs, start/stop controls, broadcast messaging, and a live activity feed. It adds member-scoped API routing and transcript persistence, plus Playwright coverage and mock chat handling. ChangesFleet Room
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant Palette
participant FleetRoom
participant FleetAPI
participant FleetMember
participant ActivityFeed
User->>Palette: Select Fleet Room
Palette->>FleetRoom: Open fleet-room view
FleetRoom->>FleetAPI: Fetch fleet state
FleetAPI-->>FleetRoom: Return roster
FleetRoom->>ActivityFeed: Start activity tracking
ActivityFeed->>FleetMember: Open SSE stream
User->>FleetRoom: Broadcast message
FleetRoom->>FleetAPI: Send message to online members
FleetAPI->>FleetMember: POST SendStreamingMessage
FleetRoom->>ActivityFeed: Push broadcast event
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Rework the Fleet Room into the Discord model: click a member to DM it (the wired
⌘K chat, retargeted to that member), and the bottom bar broadcasts to everyone
online. Replaces v0's fire-and-forget "address", which sent into a hidden session
with no visible reply — testing showed that felt broken even though it worked.
- api.streamChat: new opts.agentSlug streams the turn to a SPECIFIC member via the
hub proxy (/agents/<slug>/a2a, /api/chat fallback, desktop Tauri path included).
Adds memberPath() (sendToAgent now shares it).
- PaletteChat: optional agentSlug prop → a DM pane; paletteChatStore threads are
scoped per member (dm:<slug>) so DMing different members never crosses transcripts.
- memberDmView: registers the DM as a palette stack view, so Back/Escape return to
the roster.
- FleetRoom: a member row → ctx.enter("member-dm", {slug,name}); the composer is now
broadcast-only (fire-and-forget fan-out — the only path that stays async, since you
can't stream N replies into one pane). Also toasts the single send immediately.
- e2e: DM a running member (asserts the retargeted chat opens) + Back + broadcast toast.
Almost entirely reuse — the DM IS the existing wired chat, just pointed at the
member; the only new transport is a slug target on streamChat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
Adds a right-side popout drawer with a live, fleet-wide event feed (the "one event log" idea, on our own infra) — opened from the Fleet Room's Activity button or the ⌘K "Fleet Activity" command; slides over the console, Esc/scrim to close. Also cleans up the Fleet Room footer (keycap chips) and adds a top bar (online/total count + the Activity toggle). - FleetActivity.tsx: the drawer (portal at app root) + a small zustand store + a roster-diff capture hook. v1 sources REAL events only — member presence transitions (online/offline/joined/left, diffed from the fleet poll) and the broadcasts you send. Richer cross-member events (PRs, approvals, a member's running turn) are the next step — aggregate each member's event bus (ADR 0039) — and are deliberately NOT faked. - Mounted once at the app root (App.tsx) so the feed accumulates even while closed. - FleetRoom: a top bar (count + Activity button) and a keycap footer; broadcast pushes an event into the feed. - usePaletteRegistry: a "Fleet Activity" ⌘K command. - e2e: open the drawer from the Fleet Room + Escape to close. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
…rawer The activity feed is the RIGHT COLUMN of the Fleet Room dialog (next to the roster), matching the concept mockup — not a separate console slide-over. - FleetActivity.tsx: drop the portal drawer + open state; expose a headless FleetActivityCapture (roster-diff, runs at app root) + a FleetActivityFeed column the room renders. - FleetRoom: two-column body (roster | activity feed), broadcast bar spanning below; widened the view to fit. Removed the Activity toggle button + top bar. - App: mount the headless capture (was the drawer). usePaletteRegistry: drop the separate "Fleet Activity" command — it lives in the room now. - e2e: assert the roster + activity feed render side by side in the dialog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
The activity column now streams REAL fleet activity — not just presence + your own broadcasts. While the Fleet Room is open it opens an SSE stream per ONLINE member (/agents/<slug>/api/events via the hub proxy, ADR 0039) and maps the event-bus topics into the feed: turn.started/finished, activity.message, inbox.item, scheduler.fired, goal.achieved/failed, background.completed. So a broadcast to a member now surfaces "<member> is running a turn" → "finished a turn". - api.sseTokenFor(slug): a member-scoped SSE token (best-effort; open-mode instances connect tokenless). - FleetActivity.useFleetStreams: opens/closes EventSources as members come online / go offline; dedup by (slug, seq); an errored stream reopens on the next roster poll. mapTopic curates the useful topics and skips the noisy ones. - Capture now runs only WHILE the Fleet Room is open (was always-on at app root) — the module store keeps the log across opens, and we don't hold SSE connections idle. This also removes the always-on load that flaked unrelated e2e specs. - e2e: the feed populates from the mock's member event streams. Real events only — no faked PR/approval rows; wiring the GitHub plugin + HITL inbox as event sources is the next step for those richer types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
…y feed Diagnosed live: a live member turn only publishes `turn.usage` to its event bus at completion (started/tool frames stay on the turn's own SSE, not the bus), and the non-streaming /api/chat path — which broadcast used — publishes nothing at all. So the feed never showed members responding. - FleetActivity.mapTopic: map `turn.usage` → "finished a turn" (state=failed → "hit an error"), the real per-turn signal. (Kept turn.started/finished too.) - api.sendToAgent: fire the broadcast at the member's /a2a (SendStreamingMessage) instead of /api/chat, so the streaming turn publishes turn.usage. Fire-and-forget: the A2A task is durable, so we send then cancel the response stream and the turn finishes + emits its bus event. DMs already stream via /a2a, so they surface too. Now broadcasting or DMing a member shows "<member> finished a turn" in the feed a few seconds later — the round-trip you were missing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
There was a problem hiding this comment.
QA panel review — FAIL
code-review-structural · head 24e7242c0ec8 · formal
[review-synthesizer completed: workflow code-review-structural:report]
Prose Brief
This PR carries moderate risk due to the SSE error-recovery gap in FleetActivity.tsx: a transient stream error on a running agent kills the EventSource permanently — the onerror handler explicitly closes the connection and the useEffect won't re-fire until the roster's running-agent set changes, which could be indefinitely. Fix that first. The three minor hygiene findings (a duplicated helper, two unreferenced CSS rules) are cost-free to land alongside but shouldn't gate the PR. The panel was unanimous — all findings confirmed, none refuted. No gaps from the structural pass surfaced findings beyond the panel's. Coverage looks complete: all touched files were read and assessed.
[
{
"file": "apps/web/src/app/FleetActivity.tsx",
"line": 167,
"severity": "major",
"category": "correctness",
"claim": "SSE streams dropped on error are never reopened: the onerror handler deletes the stream from the ref map and calls es.close() (preventing browser auto-reconnect), but the useEffect depends only on sig (a stable sorted-set string of running agent slugs), so it does not re-fire on routine roster polls — the stream stays dead until the roster composition actually changes.",
"evidence": "useEffect(…, [sig]) at line 174 depends on the derived `sig` string (lines 130-133), not on `data`; es.onerror at lines 165-167 deletes from `streams.current` and calls `es.close()`. The comment on line 167 says 'the next roster poll reopens' but only a sig-changing poll triggers the effect.",
"verdict": "confirmed",
"note": "sig is derived from running-agent slugs; react-query structural sharing means data ref is stable across equal polls, so sig stays identical — the effect won't re-fire after a stream error unless a member goes on/offline or is added/removed. The error handler's es.close() also defeats EventSource auto-reconnect. Genuine gap.",
"source": "correctness"
},
{
"file": "apps/web/src/app/FleetRoom.tsx",
"line": 29,
"severity": "minor",
"category": "conventions",
"claim": "The `slugOf` helper is duplicated identically between FleetRoom.tsx and FleetActivity.tsx; FleetRoom already imports from FleetActivity and should reuse the existing definition.",
"evidence": "FleetRoom.tsx line 29: `const slugOf = (a: FleetAgent): string => (a.host ? \"host\" : a.id);` — identical to FleetActivity.tsx line 54. FleetRoom already imports `{ FleetActivityFeed, pushFleetEvent }` from `./FleetActivity`.",
"verdict": "confirmed",
"note": "Both files define identical `const slugOf = (a: FleetAgent): string => (a.host ? \"host\" : a.id);`. FleetRoom already imports from './FleetActivity' — adding `slugOf` to that import would deduplicate cleanly.",
"source": "conventions"
},
{
"file": "apps/web/src/app/fleet-room.css",
"line": 81,
"severity": "minor",
"category": "conventions",
"claim": "Dead CSS: `.flr__member.is-target` is defined but never applied to any element in FleetRoom.tsx.",
"evidence": "fleet-room.css lines 81-84 define `.flr__member.is-target { background: color-mix(...); border-color: color-mix(...); }`. FleetRoom.tsx only sets `is-down` dynamically: `className={`flr__member${a.running ? \"\" : \" is-down\"}`}` — `is-target` is never set.",
"verdict": "confirmed",
"note": "The only dynamic class on `.flr__member` in FleetRoom.tsx is `is-down`. No code in any changed TSX file sets `is-target` on a `.flr__member` element.",
"source": "conventions"
},
{
"file": "apps/web/src/app/fleet-room.css",
"line": 226,
"severity": "minor",
"category": "conventions",
"claim": "Dead CSS: `.flr__target-x` is defined but never referenced in any TSX file.",
"evidence": "fleet-room.css lines 226-229 define `.flr__target-x { opacity: 0.55; font-weight: 700; }`. A search of all changed TSX files finds no usage of `flr__target-x` or `target-x`.",
"verdict": "confirmed",
"note": "`target-x` appears only in the CSS definition. The semantically close class `.flr__target` IS used (the broadcast badge), but `.flr__target-x` is a separate, unreferenced rule — likely a vestige of a planned close/dismiss button.",
"source": "conventions"
}
]…" pill Enriches the activity feed + roster from the signals that actually flow on the member buses (no faked rows): - Live "running a turn" roster pill: autonomous member turns bracket with turn.started/turn.finished (already on the bus); a direct broadcast is marked optimistically on send and cleared by its terminal turn.usage. - More mapped topics + richer text: chat.resumed, goal.changed, plus the existing turn.usage / activity.message (with text) / inbox.item / scheduler (with name) / goal.achieved|failed / background.completed. Deferred (need NEW sources — documented, not faked): - "needs approval" pill/rows: requires a bus publish on the executor's input_required frame (a2a_impl/executor.py:517 → notify → server/a2a.py publishes "turn.input_required"). Touches the core HITL path, so it's a supervised change, not done unattended overnight. - "opened PR" rows: the GitHub plugin is out-of-tree; it would publish github.pr.* and the feed would map it. Follow-up to #2129 / #2132. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
…roval + running pills, host-scoping (#2132) * feat(console): Fleet Room composer — @-address a member (regression fix) Restores addressing a specific member from the composer — removed in the DM rework, which left it broadcast-only (you couldn't @ an agent in the input anymore). Type "@" in the composer → a member picker → an address chip; sending to that member opens its DM with the message pre-sent (the wired chat streams the reply), so you see the response instead of firing into the void. Broadcast stays the default (no chip, or ⌘↵). - FleetRoom: @-mention autocomplete + address-target chip + send routing (a member → its DM with the message; otherwise broadcast). Footer: "@ address in composer". - PaletteChat: optional `initial` prop auto-sends once on open; memberDmView threads it through from ctx.props. - e2e: @-address ava → send → ava's DM opens. Follow-up to #2129. Still real-events-only in the activity feed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d * feat(console): Fleet Room — wire real event sources + a live "running" pill Enriches the activity feed + roster from the signals that actually flow on the member buses (no faked rows): - Live "running a turn" roster pill: autonomous member turns bracket with turn.started/turn.finished (already on the bus); a direct broadcast is marked optimistically on send and cleared by its terminal turn.usage. - More mapped topics + richer text: chat.resumed, goal.changed, plus the existing turn.usage / activity.message (with text) / inbox.item / scheduler (with name) / goal.achieved|failed / background.completed. Deferred (need NEW sources — documented, not faked): - "needs approval" pill/rows: requires a bus publish on the executor's input_required frame (a2a_impl/executor.py:517 → notify → server/a2a.py publishes "turn.input_required"). Touches the core HITL path, so it's a supervised change, not done unattended overnight. - "opened PR" rows: the GitHub plugin is out-of-tree; it would publish github.pr.* and the feed would map it. Follow-up to #2129 / #2132. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d * fix(console): Fleet Room review fixes — running-pill correctness + edge cases From a self-review pass on the branch: - Broadcast send-failure now clears the optimistic "running" mark (was stuck forever and survived room reopens via the module-level store). - The running count decrements ONLY on turn.usage (not turn.finished) — autonomous turns emit both, which double-decremented and corrupted a concurrent member's count. - A member's in-flight count resets when its stream closes / it goes offline; the pill renders only on online members. - Errored SSE streams reopen on a backoff (the roster sig doesn't change, so the effect wouldn't re-run); the token-fetch continuation bails if unmounted. - Addressing a member who left the fleet no longer silently broadcasts — it toasts and resets to broadcast. - The DM initial-message auto-send skips when the member's thread has a turn still streaming (the self-heal owns that reconnect), avoiding a clobbering concurrent send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d * feat: wire the deferred Fleet Room event sources (HITL approval + GitHub PRs) Both sources the activity feed was missing are now real, end to end. HITL "needs approval" (in-tree): - a2a_impl/executor.py: the input_required branch now fires the existing best-effort progress hook with {phase: "input_required", prompt}. The hook swallows everything by contract, so a parked HITL turn can't be broken by a consumer. - server/a2a.py: _a2a_progress publishes `turn.input_required` for EVERY context (it previously early-returned on non-background), carrying task/context ids + a short prompt. - Console: maps it to an actionable "⚠ needs your approval — <what>" feed row and a roster "needs approval" pill; the pill clears when the parked turn reaches turn.usage. A member awaiting input is no longer counted as "running". GitHub PR events (github-plugin, separate PR): - The plugin emits `github.pr.opened` / `github.pr.merged` via registry.emit (ADR 0039, auto-namespaced), threaded in as a guarded optional `emit` so an older host still loads. - Console maps both to "opened PR #N · title" / "merged PR #N". Gates: pytest ✓, ruff ✓, import contracts ✓ (a2a_impl/server layering kept), tsc ✓, build ✓, e2e 21 ✓, unit 628 ✓; github-plugin 91 ✓. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d * feat(console): fold fleet controls into the Fleet Room + HITL resume hand-back Folds the two remaining standalone ⌘K fleet features into the room: - quick-chat (#1733): the per-member root commands are gone; member names ride the Fleet Room command's keywords (typing "ava" surfaces the room) and the roster row's DM/open cover the flows. fleetPalette.ts slims to the recency store. - Toggle Fleet Agent (#1769): the submorph + root command are gone; the roster row's Start/Stop is the control. Completes the HITL loop 8e0d44a opened (turn.input_required): - executor turn_started frames now carry resumed=<bool>; the server publishes turn.resumed for every context, so "needs approval" hands BACK to "running" the moment the answer lands (previously the pill sat on needs-approval until the terminal frame). Console consumes it (pill + "resumed — input received" feed row). 8 new tests cover the publisher + the executor frames — both were untested. DM polish: the member-dm header now names the member ("@ava"). PaletteView titles are registration-static, so the DM body records its target in a tiny store the title node reads — the known rough edge from the DM rework. e2e: the toggle-picker specs became room-based coverage (stop/start from the roster); new specs pin the fold (no per-member root commands; names route to the room) and walk the full HITL pill lifecycle over a slug-gated mock stream (x-e2e-hitl); the DM test asserts the "@ava" header. Gates: pytest 3956 ✓ · ruff ✓ · lint-imports ✓ · live-smoke ✓ (isolated host-config — the local box host-config.yaml binds 0.0.0.0, which trips the open-bind guard outside CI) · tsc/build ✓ · vitest 618 ✓ · e2e fleet 15 + keybindings 7 ✓ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(console): Fleet Room — a typed "@name" addresses, and clarify the broadcast count Two things that bit during QA: - Typing "@scout do the thing" and hitting ↵ BROADCAST the literal text, because the address chip was only ever set by picking from the @-picker. A typed leading "@name" is now resolved on send (exact match, else prefix, excluding this instance) and stripped from the message — the picker is a convenience, not a requirement. "@name" with no message toasts instead of silently broadcasting, and an explicit chip still wins. - The broadcast chip read "All online · 2" beside a "3 online · 3" header, which reads like a bug. It deliberately excludes the instance you're already in, so it's relabelled "Everyone else · N" with a tooltip that says exactly that. e2e: a TYPED @name (never touching the picker) opens that member's DM and does not broadcast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d * feat(console): host-scope the Fleet Room command (ADR 0042) The fleet is managed from its HOST instance. On a member window `/api/fleet` is a fleet-of-one by construction, so the room rendered an empty, misleading surface with no DM or broadcast targets. The command is now disabled there with a "host instance only" hint, reusing `fleetSettingsDisabledReason` so the room and Fleet settings gate on the exact same host/member/standalone matrix — a standalone instance stays enabled (it's where you create your first member), and a remote member reached directly stays enabled because it may legitimately run its own fleet. Disabled rather than hidden, so the command stays discoverable and explains itself. Closes the last open Fleet Room item. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Turn the fleet from a page-load switcher into a co-present room summoned from ⌘K — the first piece of the command-palette UX overhaul, and a console-native take on Block/Buzz's "workspace = members-in-a-room" idea (adapted onto our A2A + fleet-proxy substrate — no Nostr, no new infra).
⌘K → Fleet Room (Agents group) morphs the palette into a native view — a sibling of
PaletteChat— showing every workspace agent as a presence-aware member:bundle · :portmeta. Straight fromapi.fleet()./api/chatthrough the hub proxy. No navigation, no leaving the room.Why core + default
The fleet was already a first-party palette citizen (quick-chat #1733, toggle #1769) and
PaletteChatis the exact precedent for a native morph-view — so this is a small extension of existing code inusePaletteRegistry, not a plugin. Ungated, on by default.How
FleetRoom.tsx— the nativePaletteView; styled entirely through DS tokens (--pl-color-*) so it tracks the console theme in light + dark, no hand-picked colors.api.sendToAgent(slug, msg)— non-streaming/api/chatthrough/agents/<slug>/…; works in the browser and the desktop WKWebView shell alike (no SSE body to read). Broadcast is a fire-and-forget fan-out.usePaletteRegistry— registers the view + a "Fleet Room" command at the top of the Agents group; opening a member routes through the palette nav chokepoint, so it forwards correctly from the frameless desktop launcher too.Scope
Additive — the existing quick-chat/toggle palette entries are untouched; folding them into the room is the natural next step of the overhaul. Deferred to v1 (additive layers on this shell): the live fleet-wide activity feed (aggregate each member's event bus, ADR 0039) + inline reply transcripts.
Testing
tsc --noEmit,vitestunit,vite build(incl. the css-comment prebuild) — all clean.⌘K → Fleet Room lists members with presence and addresses one; fullfleet.spec.ts(12) +keybindings.spec.ts(7) green.Draft — this is a UX call
Leaving as draft, no auto-merge: the "feel" is the reviewable part, and CI can't judge it. The shipped view uses real DS tokens so it's closer to the console than the earlier standalone concept mockup (kept for reference: https://claude.ai/code/artifact/ed0bb3df-35ee-4337-a676-83449e1df8c4).
🤖 Generated with Claude Code
https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d
Summary by CodeRabbit
New Features
Tests