From 9255c574a74d2b98e6333c456eed7b6f38b0b769 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 00:24:26 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(console):=20Fleet=20Room=20=E2=80=94?= =?UTF-8?q?=20a=20=E2=8C=98K=20morph-view=20for=20the=20fleet=20(ADR=20004?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --- apps/web/e2e/fleet.spec.ts | 30 +++ apps/web/e2e/mock-server.mjs | 8 + apps/web/src/app/FleetRoom.tsx | 237 +++++++++++++++++++++++ apps/web/src/app/fleet-room.css | 253 +++++++++++++++++++++++++ apps/web/src/app/usePaletteRegistry.ts | 24 +++ apps/web/src/lib/api.ts | 31 +++ 6 files changed, 583 insertions(+) create mode 100644 apps/web/src/app/FleetRoom.tsx create mode 100644 apps/web/src/app/fleet-room.css diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 2b7c3dab..7242ff79 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -274,3 +274,33 @@ test("⌘K → Toggle Fleet Agent starts a stopped member", async ({ page }) => await openToggleFleet(page); await expect(row(page, "roxy", "on")).toBeVisible(); }); + +async function openFleetRoom(page) { + await page.keyboard.press("ControlOrMeta+k"); + await expect(page.locator(".pl-cmdk__panel")).toBeVisible(); + await page.locator(".pl-cmdk__panel .pl-cmdk-commands__input").fill("Fleet Room"); + await page.getByRole("option", { name: "Fleet Room" }).click(); + await expect(page.locator(".pl-cmdk__title")).toHaveText("Fleet"); // morphed into the room +} + +test("⌘K → Fleet Room lists members with presence and addresses one", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await openFleetRoom(page); + const room = page.locator(".flr"); + + // Roster with presence: the host is tagged "this instance"; a running member and a + // stopped one both appear, encoded in the dot class (success vs the stopped default). + await expect(room.locator(".flr__member", { hasText: "main" }).locator(".flr__tag--host")).toBeVisible(); + await expect(room.locator(".flr__member", { hasText: "ava" }).locator(".flr__dot--online")).toBeVisible(); + await expect(room.locator(".flr__member", { hasText: "roxy" }).locator(".flr__dot--stopped")).toBeVisible(); + + // The composer defaults to broadcast (all online); clicking a member re-addresses to it. + await expect(room.locator(".flr__target")).toContainText("All online"); + await room.locator(".flr__member", { hasText: "ava" }).locator(".flr__who").click(); + await expect(room.locator(".flr__target")).toContainText("ava"); + + // Send lands on that member's /api/chat (through the hub slug proxy) → a success toast. + await room.locator(".flr__input").fill("ship it"); + await room.locator(".flr__send").click(); + await expect(page.locator(".pl-toast", { hasText: "Sent to ava" })).toBeVisible(); +}); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index fe88285f..7bb4d337 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -458,6 +458,14 @@ const server = createServer(async (req, res) => { // specific agent. The mock strips the /agents/ prefix and serves the same handlers. const pathname = url.pathname.replace(/^\/agents\/[^/]+/, "") || url.pathname; + // Non-streaming chat send — the Fleet Room address/broadcast (/api/chat) and the desktop + // streaming fallback. A member send arrives here too: the /agents/ prefix was already + // stripped above, so /agents/ava/api/chat proxies to this same handler. + if (pathname === "/api/chat" && req.method === "POST") { + const body = await readBody(req); + return sendJson(res, { response: `ack: ${String(body?.message ?? "").slice(0, 40)}` }); + } + if (pathname === "/a2a" && req.method === "POST") { const body = await readBody(req); // GetTask — the reconcile path (self-heal a stuck streaming turn + the cross-agent diff --git a/apps/web/src/app/FleetRoom.tsx b/apps/web/src/app/FleetRoom.tsx new file mode 100644 index 00000000..12428a74 --- /dev/null +++ b/apps/web/src/app/FleetRoom.tsx @@ -0,0 +1,237 @@ +// The Fleet Room (⌘K, ADR 0042 + the palette-UX overhaul). A native palette morph-view — +// a sibling of PaletteChat — that turns the fleet from a page-load switcher into a +// co-present room: every workspace agent as a presence-aware member you can Open, +// start/stop, or *address in place* (a slug-targeted /api/chat send), plus a one-key +// broadcast to everyone online. Entered from the palette's "Agents" group; the DS +// CommandPalette supplies the back/close chrome + footer, we render the body. +// +// v0 scope: roster + presence + address/broadcast. Deferred to v1 (additive layers on +// this shell): the live fleet-wide activity feed (aggregate each member's event bus, +// ADR 0039) and inline reply transcripts. Ungated for now — it reflects whatever +// api.fleet() returns for this window; host-scoping (cf. fleetSettingsGate) can follow. +import { useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { ExternalLink, Play, Radio, Send, Square } from "lucide-react"; +import { useToast } from "@protolabsai/ui/overlays"; +import type { PaletteContext, PaletteView } from "@protolabsai/ui/command-palette"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, currentSlug } from "../lib/api"; +import { fleetQuery, queryKeys } from "../lib/queries"; +import { errMsg } from "../lib/format"; +import type { FleetAgent } from "../lib/types"; +import "./fleet-room.css"; + +/** The routing slug for a member — the host entry is the reserved "host" (ADR 0042). */ +const slugOf = (a: FleetAgent): string => (a.host ? "host" : a.id); + +type PresenceKey = "host" | "online" | "remote" | "stopped" | "unreachable"; + +/** Presence derived from the roster: `running` IS the live reachability probe, `remote` + * distinguishes a proxied peer, `host` is the instance serving this console. */ +function presenceOf(a: FleetAgent): { key: PresenceKey; label: string } { + if (a.host) return { key: "host", label: "this instance" }; + if (a.running) return a.remote ? { key: "remote", label: "remote" } : { key: "online", label: "online" }; + return a.remote ? { key: "unreachable", label: "unreachable" } : { key: "stopped", label: "stopped" }; +} + +const clip = (s: string, n = 72): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** "broadcast" = fan out to every other online member; otherwise a specific member slug. */ +type Target = "broadcast" | string; + +function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (slug: string) => void }) { + const { data: fleet } = useQuery(fleetQuery()); + const qc = useQueryClient(); + const toast = useToast(); + const [target, setTarget] = useState("broadcast"); + const [draft, setDraft] = useState(""); + const inputRef = useRef(null); + const here = currentSlug(); + + useEffect(() => inputRef.current?.focus(), []); + + // Host first, then reachable (running) before down, then alphabetical — deterministic + // across the 3s poll (React Query structural-shares equal data, so no reorder churn). + const roster = useMemo(() => { + const agents = fleet?.agents ?? []; + return [...agents].sort( + (a, b) => + Number(!!b.host) - Number(!!a.host) || + Number(b.running) - Number(a.running) || + a.name.localeCompare(b.name), + ); + }, [fleet]); + + // Broadcast reaches every OTHER online member (never the window you're already in). + const broadcastTargets = useMemo( + () => roster.filter((a) => a.running && slugOf(a) !== here), + [roster, here], + ); + + const open = (a: FleetAgent) => { + ctx.close(); + onOpenAgent(slugOf(a)); // routed through the palette nav chokepoint (launcher-safe) + }; + + const toggle = (a: FleetAgent) => { + const on = a.running; + (on ? api.stopAgent(a.name) : api.startAgent(a.name)) + .then(() => { + qc.invalidateQueries({ queryKey: queryKeys.fleet }); + toast({ + tone: "success", + title: on ? `Stopping ${a.name}…` : `Starting ${a.name}…`, + message: on ? `${a.name} is going offline.` : `${a.name} is coming online.`, + }); + }) + .catch((e) => toast({ tone: "error", title: "Couldn't toggle agent", message: errMsg(e) })); + }; + + const send = () => { + const msg = draft.trim(); + if (!msg) return; + if (target === "broadcast") { + if (!broadcastTargets.length) { + toast({ tone: "error", title: "No one to broadcast to", message: "No other members are online." }); + return; + } + // Fire-and-forget fan-out — each member runs the turn durably on its own instance. + for (const a of broadcastTargets) { + api + .sendToAgent(slugOf(a), msg) + .catch((e) => toast({ tone: "error", title: `Couldn't reach ${a.name}`, message: errMsg(e) })); + } + toast({ + tone: "success", + title: `Broadcast to ${broadcastTargets.length} member${broadcastTargets.length > 1 ? "s" : ""}`, + message: clip(msg), + }); + } else { + const a = roster.find((x) => slugOf(x) === target); + api + .sendToAgent(target, msg) + .then(() => toast({ tone: "success", title: `Sent to ${a?.name ?? target}`, message: clip(msg) })) + .catch((e) => toast({ tone: "error", title: `Couldn't reach ${a?.name ?? target}`, message: errMsg(e) })); + } + setDraft(""); + inputRef.current?.focus(); + }; + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.metaKey || e.ctrlKey) setTarget("broadcast"); // ⌘↵ always broadcasts + send(); + } + }; + + const targetName = + target === "broadcast" + ? `All online · ${broadcastTargets.length}` + : (roster.find((a) => slugOf(a) === target)?.name ?? target); + + return ( +
+
+ {roster.length === 0 &&
No members yet — add one from Settings ▸ Agents.
} + {roster.map((a) => { + const slug = slugOf(a); + const p = presenceOf(a); + const isTarget = target === slug; + const local = !a.host && !a.remote; // only a local process can be started/stopped here + return ( +
+ + +
+ {local && ( + + )} + +
+
+ ); + })} +
+ +
+ + setDraft(e.target.value)} + onKeyDown={onKeyDown} + placeholder={target === "broadcast" ? "Message everyone online…" : `Message ${targetName}…`} + aria-label="Message" + /> + +
+
+ ); +} + +/** The palette view (registered by usePaletteRegistry; entered by the "Fleet Room" + * command). Kept as a factory so the JSX lives here and usePaletteRegistry stays .ts. + * `onOpenAgent` routes through the registry's nav chokepoint so it also works forwarded + * from the frameless desktop launcher window (ADR 0057). */ +export function fleetRoomView(opts: { onOpenAgent: (slug: string) => void }): PaletteView { + return { + id: "fleet-room", + title: "Fleet", + width: 680, + footerHint: ( + + send · click a member to address · ⌘↵ broadcast to all online + + ), + render: (ctx) => , + }; +} diff --git a/apps/web/src/app/fleet-room.css b/apps/web/src/app/fleet-room.css new file mode 100644 index 00000000..9cf39b65 --- /dev/null +++ b/apps/web/src/app/fleet-room.css @@ -0,0 +1,253 @@ +/* Fleet Room morph-view (⌘K). Styled entirely through DS tokens (--pl-color-*) so it + * tracks the console theme in light + dark with no hand-picked colors. Presence uses the + * DS status palette; the accent is the brand lavender the rest of the palette uses. */ + +.flr { + display: flex; + flex-direction: column; + height: 460px; + min-height: 0; +} + +.flr__list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} +.flr__empty { + padding: 28px 12px; + text-align: center; + color: var(--pl-color-fg-muted); + font-size: 13px; +} + +.flr__member { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 11px; + padding: 8px 10px; + border-radius: 9px; + border: 1px solid transparent; +} +.flr__member:hover { + background: var(--pl-color-bg-hover); +} +.flr__member.is-target { + background: color-mix(in srgb, var(--pl-color-accent) 12%, transparent); + border-color: color-mix(in srgb, var(--pl-color-accent) 45%, transparent); +} +.flr__member.is-down { + opacity: 0.6; +} + +/* presence dot — color from the DS status palette */ +.flr__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--pl-color-fg-subtle); + position: relative; + flex: none; +} +.flr__dot--online { + background: var(--pl-color-status-success); +} +.flr__dot--host { + background: var(--pl-color-accent); +} +.flr__dot--remote { + background: var(--pl-color-status-info); +} +.flr__dot--unreachable { + background: var(--pl-color-status-error); +} +.flr__dot--online::after, +.flr__dot--host::after { + content: ""; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1.5px solid currentColor; + opacity: 0.5; + animation: flr-pulse 2s ease-out infinite; +} +.flr__dot--online::after { + color: var(--pl-color-status-success); +} +.flr__dot--host::after { + color: var(--pl-color-accent); +} +@keyframes flr-pulse { + 0% { + transform: scale(0.6); + opacity: 0.55; + } + 100% { + transform: scale(1.5); + opacity: 0; + } +} + +.flr__who { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + text-align: left; + background: none; + border: 0; + padding: 0; + cursor: pointer; + font: inherit; +} +.flr__name { + display: flex; + align-items: center; + gap: 7px; + font-size: 14px; + font-weight: 550; + color: var(--pl-color-fg); +} +.flr__tag { + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 5px; + background: var(--pl-color-bg-inset); + color: var(--pl-color-fg-muted); +} +.flr__tag--host { + color: var(--pl-color-accent); + background: color-mix(in srgb, var(--pl-color-accent) 14%, transparent); +} +.flr__tag--remote { + color: var(--pl-color-status-info); + background: color-mix(in srgb, var(--pl-color-status-info) 15%, transparent); +} +.flr__meta { + font-family: var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); + font-size: 11.5px; + color: var(--pl-color-fg-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flr__actions { + display: flex; + align-items: center; + gap: 2px; +} +.flr__icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 7px; + border: 0; + background: none; + color: var(--pl-color-fg-muted); + cursor: pointer; +} +.flr__icon:hover:not(:disabled) { + background: var(--pl-color-bg-inset); + color: var(--pl-color-fg); +} +.flr__icon:disabled { + opacity: 0.4; + cursor: default; +} + +/* composer */ +.flr__composer { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--pl-color-border); +} +.flr__target { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 8px; + border: 1px solid transparent; + background: color-mix(in srgb, var(--pl-color-accent) 12%, transparent); + color: var(--pl-color-accent); + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} +.flr__target:not(.is-cast) { + background: var(--pl-color-bg-inset); + color: var(--pl-color-fg); +} +.flr__target-x { + opacity: 0.55; + font-weight: 700; +} +.flr__input { + flex: 1; + min-width: 0; + border: 0; + background: none; + outline: none; + color: var(--pl-color-fg); + font-family: inherit; /* inputs don't inherit the family by default */ + font-size: 14px; +} +.flr__input::placeholder { + color: var(--pl-color-fg-subtle); +} +.flr__send { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 8px; + border: 0; + background: var(--pl-color-accent); + color: var(--pl-color-fg-on-accent, var(--pl-color-accent-fg)); + cursor: pointer; + flex: none; +} +.flr__send:hover:not(:disabled) { + background: var(--pl-color-accent-hover); +} +.flr__send:disabled { + opacity: 0.45; + cursor: default; +} + +/* focus visibility (keyboard) */ +.flr__who:focus-visible, +.flr__icon:focus-visible, +.flr__target:focus-visible, +.flr__send:focus-visible, +.flr__input:focus-visible { + outline: 2px solid var(--pl-color-focus, var(--pl-color-accent)); + outline-offset: 1px; + border-radius: 7px; +} + +.flr__hint b { + font-weight: 600; + color: var(--pl-color-fg-muted); +} + +@media (prefers-reduced-motion: reduce) { + .flr__dot--online::after, + .flr__dot--host::after { + animation: none; + display: none; + } +} diff --git a/apps/web/src/app/usePaletteRegistry.ts b/apps/web/src/app/usePaletteRegistry.ts index 8b22c437..8fabee67 100644 --- a/apps/web/src/app/usePaletteRegistry.ts +++ b/apps/web/src/app/usePaletteRegistry.ts @@ -22,6 +22,7 @@ import { agentHref, api, currentSlug } from "../lib/api"; import { errMsg } from "../lib/format"; import { fleetQuery, queryKeys } from "../lib/queries"; import { fleetPaletteEntries, markAgentOpened, readAgentRecency, togglableFleetAgents } from "./fleetPalette"; +import { fleetRoomView } from "./FleetRoom"; /** Optional inline chat with the focused agent (ADR 0057). App builds the native chat * PaletteView (it needs JSX + the focused agent name); the adapter registers it + a @@ -233,6 +234,16 @@ export function usePaletteRegistry( }), ); if (chat) vs.push(chat.view); + // The ⌘K Fleet Room morph-view (sibling of the chat view). Opening a member routes + // through the shared nav chokepoint so it forwards from the launcher window too. + vs.push( + fleetRoomView({ + onOpenAgent: (slug) => { + markAgentOpened(slug); + navigate({ kind: "agent", slug }); + }, + }), + ); vs.push({ ...commandsView({ commands: openSurfaceCommands, placeholder: "Open a surface…" }), id: "open", @@ -259,6 +270,18 @@ export function usePaletteRegistry( }, ]) : undefined; + // Fleet Room (⌘K palette overhaul) — the co-present roster + address/broadcast, opened + // as a morph-view. Top of the Agents group, right under "Chat with ". + const offFleetRoom = registry.registerCommands([ + { + id: "fleet-room", + label: "Fleet Room", + hint: "members · address · broadcast", + group: "Agents", + keywords: ["fleet", "room", "members", "agents", "team", "crew", "broadcast", "address", "roster"], + run: (c) => c.enter("fleet-room"), + }, + ]); // Fleet quick-chat (#1733): every OTHER agent, in the "Agents" group beneath "Chat with // ". Picking one navigates to its slug-routed console via a serializable `agent` // NavIntent (so it also works forwarded from the launcher window). A down agent is listed @@ -370,6 +393,7 @@ export function usePaletteRegistry( ]); return () => { offChat?.(); + offFleetRoom(); offFleet?.(); offPlugins?.(); offToggleView(); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c758b1b5..85fb785c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1477,6 +1477,37 @@ export const api = { fleetDown() { return request<{ ok: boolean; stopped: string[] }>("/api/fleet/down", { method: "POST" }); }, + // Address a specific fleet member from anywhere (Fleet Room, ⌘K). Delivers `message` + // to `slug`'s agent through the hub's per-agent proxy (/agents//api/chat) — the + // NON-streaming chat endpoint, so it works in the browser and the desktop WKWebView + // shell alike (no SSE body to read). `slug` is the member's id, "host" for this + // instance. The turn runs durably on the member and its reply lands in that member's + // own chat session (`session_id`); callers fire-and-forget for a broadcast, or await + // the promise for the single-send reply text. Bypasses request()/apiUrl() on purpose: + // apiUrl routes to the CURRENT window's slug, and this targets an arbitrary member. + sendToAgent(slug: string, message: string, sessionId?: string): Promise<{ response?: string }> { + const rel = "/api/chat"; + const path = slug === "host" ? rel : `/agents/${encodeURIComponent(slug)}${rel}`; + const base = defaultApiBase(); + const url = base ? `${base}${path}` : path; + return fetch(url, { + method: "POST", + headers: applyAuth(new Headers({ "Content-Type": "application/json" })), + body: JSON.stringify({ message, session_id: sessionId ?? `fleet-room-${Date.now()}` }), + }).then(async (res) => { + if (!res.ok) { + let detail = `${res.status} ${res.statusText}`; + try { + const p = (await res.json()) as { detail?: string }; + if (p?.detail) detail = p.detail; + } catch { + /* keep status text */ + } + throw new Error(detail); + } + return (await res.json()) as { response?: string }; + }); + }, // Per-agent theme (ADR 0042). The blob is opaque — the DS ThemePanel owns its schema; the // server just round-trips JSON. These auto-route to the focused agent via the active prefix From c3658b78e7f051ec9baef397ef757f5a7fef6cde Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 01:11:45 -0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(console):=20Fleet=20Room=20DMs=20?= =?UTF-8?q?=E2=80=94=20click=20a=20member=20to=20chat,=20broadcast=20to=20?= =?UTF-8?q?all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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:) 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) Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --- apps/web/e2e/fleet.spec.ts | 16 ++-- apps/web/src/app/FleetRoom.tsx | 120 +++++++++++-------------- apps/web/src/app/PaletteChat.tsx | 41 +++++++-- apps/web/src/app/paletteChatStore.ts | 36 +++++--- apps/web/src/app/usePaletteRegistry.ts | 2 + apps/web/src/lib/api.ts | 32 +++++-- 6 files changed, 144 insertions(+), 103 deletions(-) diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 7242ff79..d573ce9d 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -283,7 +283,7 @@ async function openFleetRoom(page) { await expect(page.locator(".pl-cmdk__title")).toHaveText("Fleet"); // morphed into the room } -test("⌘K → Fleet Room lists members with presence and addresses one", async ({ page }) => { +test("⌘K → Fleet Room: presence, DM a member (the wired chat), broadcast", async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); await openFleetRoom(page); const room = page.locator(".flr"); @@ -294,13 +294,15 @@ test("⌘K → Fleet Room lists members with presence and addresses one", async await expect(room.locator(".flr__member", { hasText: "ava" }).locator(".flr__dot--online")).toBeVisible(); await expect(room.locator(".flr__member", { hasText: "roxy" }).locator(".flr__dot--stopped")).toBeVisible(); - // The composer defaults to broadcast (all online); clicking a member re-addresses to it. - await expect(room.locator(".flr__target")).toContainText("All online"); + // DM a running member — clicking it morphs into the wired chat, pointed at that member + // (placeholder names them). Back returns to the roster. await room.locator(".flr__member", { hasText: "ava" }).locator(".flr__who").click(); - await expect(room.locator(".flr__target")).toContainText("ava"); + await expect(page.getByPlaceholder(/Message ava/i)).toBeVisible(); + await page.locator(".pl-cmdk__back").click(); + await expect(room.locator(".flr__composer")).toBeVisible(); - // Send lands on that member's /api/chat (through the hub slug proxy) → a success toast. - await room.locator(".flr__input").fill("ship it"); + // The bottom bar broadcasts to everyone online → a success toast. + await room.locator(".flr__input").fill("standup in 5"); await room.locator(".flr__send").click(); - await expect(page.locator(".pl-toast", { hasText: "Sent to ava" })).toBeVisible(); + await expect(page.locator(".pl-toast", { hasText: /Broadcast to \d+ member/ })).toBeVisible(); }); diff --git a/apps/web/src/app/FleetRoom.tsx b/apps/web/src/app/FleetRoom.tsx index 12428a74..32b8324e 100644 --- a/apps/web/src/app/FleetRoom.tsx +++ b/apps/web/src/app/FleetRoom.tsx @@ -1,14 +1,14 @@ -// The Fleet Room (⌘K, ADR 0042 + the palette-UX overhaul). A native palette morph-view — -// a sibling of PaletteChat — that turns the fleet from a page-load switcher into a -// co-present room: every workspace agent as a presence-aware member you can Open, -// start/stop, or *address in place* (a slug-targeted /api/chat send), plus a one-key -// broadcast to everyone online. Entered from the palette's "Agents" group; the DS -// CommandPalette supplies the back/close chrome + footer, we render the body. +// The Fleet Room (⌘K, ADR 0042 + the palette-UX overhaul). A native palette morph-view +// that makes the fleet feel like a Discord room: every workspace agent is a presence-aware +// MEMBER. Click one to DM it — that's the wired ⌘K chat (PaletteChat) pointed at the +// member (`ctx.enter("member-dm", …)`), streaming through the hub proxy, with Back to the +// roster. The bottom bar broadcasts to everyone online (the @everyone announce — the only +// fire-and-forget path, since you can't stream N replies into one pane). // -// v0 scope: roster + presence + address/broadcast. Deferred to v1 (additive layers on -// this shell): the live fleet-wide activity feed (aggregate each member's event bus, -// ADR 0039) and inline reply transcripts. Ungated for now — it reflects whatever -// api.fleet() returns for this window; host-scoping (cf. fleetSettingsGate) can follow. +// Entered from the palette's "Agents" group; the DS CommandPalette supplies the +// back/close chrome + footer. Ungated for now — it reflects whatever api.fleet() returns +// for this window; host-scoping (cf. fleetSettingsGate) can follow. Deferred: a live +// fleet-wide activity feed (aggregate each member's event bus, ADR 0039). import { useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { ExternalLink, Play, Radio, Send, Square } from "lucide-react"; @@ -36,14 +36,10 @@ function presenceOf(a: FleetAgent): { key: PresenceKey; label: string } { const clip = (s: string, n = 72): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); -/** "broadcast" = fan out to every other online member; otherwise a specific member slug. */ -type Target = "broadcast" | string; - function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (slug: string) => void }) { const { data: fleet } = useQuery(fleetQuery()); const qc = useQueryClient(); const toast = useToast(); - const [target, setTarget] = useState("broadcast"); const [draft, setDraft] = useState(""); const inputRef = useRef(null); const here = currentSlug(); @@ -68,6 +64,13 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl [roster, here], ); + // DM a member = the wired chat, retargeted. Push it on the palette stack so Back/Escape + // return here. Only running members are reachable. + const dm = (a: FleetAgent) => { + if (!a.running) return; + ctx.enter("member-dm", { slug: slugOf(a), name: a.name }); + }; + const open = (a: FleetAgent) => { ctx.close(); onOpenAgent(slugOf(a)); // routed through the palette nav chokepoint (launcher-safe) @@ -87,32 +90,24 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl .catch((e) => toast({ tone: "error", title: "Couldn't toggle agent", message: errMsg(e) })); }; - const send = () => { + const broadcast = () => { const msg = draft.trim(); if (!msg) return; - if (target === "broadcast") { - if (!broadcastTargets.length) { - toast({ tone: "error", title: "No one to broadcast to", message: "No other members are online." }); - return; - } - // Fire-and-forget fan-out — each member runs the turn durably on its own instance. - for (const a of broadcastTargets) { - api - .sendToAgent(slugOf(a), msg) - .catch((e) => toast({ tone: "error", title: `Couldn't reach ${a.name}`, message: errMsg(e) })); - } - toast({ - tone: "success", - title: `Broadcast to ${broadcastTargets.length} member${broadcastTargets.length > 1 ? "s" : ""}`, - message: clip(msg), - }); - } else { - const a = roster.find((x) => slugOf(x) === target); + if (!broadcastTargets.length) { + toast({ tone: "error", title: "No one to broadcast to", message: "No other members are online." }); + return; + } + // Fire-and-forget fan-out — each member runs the turn durably on its own instance. + for (const a of broadcastTargets) { api - .sendToAgent(target, msg) - .then(() => toast({ tone: "success", title: `Sent to ${a?.name ?? target}`, message: clip(msg) })) - .catch((e) => toast({ tone: "error", title: `Couldn't reach ${a?.name ?? target}`, message: errMsg(e) })); + .sendToAgent(slugOf(a), msg) + .catch((e) => toast({ tone: "error", title: `Couldn't reach ${a.name}`, message: errMsg(e) })); } + toast({ + tone: "success", + title: `Broadcast to ${broadcastTargets.length} member${broadcastTargets.length > 1 ? "s" : ""}`, + message: clip(msg), + }); setDraft(""); inputRef.current?.focus(); }; @@ -120,16 +115,10 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl const onKeyDown = (e: ReactKeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - if (e.metaKey || e.ctrlKey) setTarget("broadcast"); // ⌘↵ always broadcasts - send(); + broadcast(); } }; - const targetName = - target === "broadcast" - ? `All online · ${broadcastTargets.length}` - : (roster.find((a) => slugOf(a) === target)?.name ?? target); - return (
@@ -137,17 +126,16 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl {roster.map((a) => { const slug = slugOf(a); const p = presenceOf(a); - const isTarget = target === slug; const local = !a.host && !a.remote; // only a local process can be started/stopped here return ( -
+
- + + + All online · {broadcastTargets.length} + setDraft(e.target.value)} onKeyDown={onKeyDown} - placeholder={target === "broadcast" ? "Message everyone online…" : `Message ${targetName}…`} - aria-label="Message" + placeholder="Message everyone online…" + aria-label="Broadcast message" /> -
@@ -226,10 +210,10 @@ export function fleetRoomView(opts: { onOpenAgent: (slug: string) => void }): Pa return { id: "fleet-room", title: "Fleet", - width: 680, + width: 620, footerHint: ( - send · click a member to address · ⌘↵ broadcast to all online + click a member to DM · broadcasts to all online ), render: (ctx) => , diff --git a/apps/web/src/app/PaletteChat.tsx b/apps/web/src/app/PaletteChat.tsx index 560b2422..c598dc78 100644 --- a/apps/web/src/app/PaletteChat.tsx +++ b/apps/web/src/app/PaletteChat.tsx @@ -11,6 +11,7 @@ import { api } from "../lib/api"; import { chatStore, effectiveReasoningEffort } from "../chat/chat-store"; import type { ChatMessage, ToolCall, ToolEvent } from "../lib/types"; import { freshPaletteThread, loadPaletteThread, savePaletteThread } from "./paletteChatStore"; +import type { PaletteView } from "@protolabsai/ui/command-palette"; import "../chat/chat.css"; // .markdown / .tool-calls / .chat-user-text / .slash-menu styles // Upsert a streaming tool event onto a message's toolCalls AND its ordered `parts` @@ -59,8 +60,12 @@ function finalize(message: ChatMessage): ChatMessage { // Deterministic, client-side. `/clear` wipes the thread; typed or picked from the menu. const SLASH = [{ name: "clear", description: "Wipe this chat + its history" }]; -export function PaletteChat({ agentName }: { agentName: string }) { - const [boot] = useState(loadPaletteThread); // run once +export function PaletteChat({ agentName, agentSlug }: { agentName: string; agentSlug?: string }) { + // A Fleet Room DM streams to a SPECIFIC member (agentSlug) and keeps its own thread, + // scoped so DMing different members never crosses transcripts. No slug = the normal + // ⌘K chat with this window's agent (unchanged). + const scope = agentSlug ? `dm:${agentSlug}` : undefined; + const [boot] = useState(() => loadPaletteThread(scope)); // run once const [messages, setMessages] = useState(boot.messages); const [draft, setDraft] = useState(""); const [streaming, setStreaming] = useState(false); @@ -80,15 +85,16 @@ export function PaletteChat({ agentName }: { agentName: string }) { // self-heal below reconnects to it on reopen. Abort last (it can't race the flush). useEffect( () => () => { - savePaletteThread({ contextId: contextRef.current, messages: messagesRef.current }, true); + savePaletteThread({ contextId: contextRef.current, messages: messagesRef.current }, true, scope); abortRef.current?.abort(); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [], ); // Preserve the thread (debounced) — survives close/reopen and reload. useEffect(() => { - savePaletteThread({ contextId: contextRef.current, messages }); - }, [messages]); + savePaletteThread({ contextId: contextRef.current, messages }, false, scope); + }, [messages, scope]); // Reconnect an interrupted turn (ADR 0057 durability). Runs once per open: if the last // assistant message is stuck "streaming" with a durable taskId — the palette was closed @@ -141,8 +147,10 @@ export function PaletteChat({ agentName }: { agentName: string }) { // `/clear` — wipe the server checkpoint for the current thread (no attachments on a // palette chat, so the full retire is harmless) + start a fresh local thread. const clearThread = () => { - void api.deleteChatSession(contextRef.current, false).catch(() => {}); - contextRef.current = freshPaletteThread().contextId; + // For a DM, api.deleteChatSession would target THIS window's agent, not the member — + // so skip the server-side wipe there and just mint a fresh local thread. + if (!agentSlug) void api.deleteChatSession(contextRef.current, false).catch(() => {}); + contextRef.current = freshPaletteThread(scope).contextId; setMessages([]); setDraft(""); inputRef.current?.focus(); @@ -192,7 +200,7 @@ export function PaletteChat({ agentName }: { agentName: string }) { onFailed: (detail) => update((m) => ({ ...m, content: m.content || detail, status: "error" })), onDone: () => update(finalize), }, - { model, reasoningEffort: effectiveReasoningEffort(sess) }, + { model, reasoningEffort: effectiveReasoningEffort(sess), agentSlug }, ); } catch (e) { if (!controller.signal.aborted) { @@ -261,3 +269,20 @@ export function PaletteChat({ agentName }: { agentName: string }) {
); } + +/** The Fleet Room DM view — the SAME wired ⌘K chat (PaletteChat), pointed at a specific + * member. A member row does `ctx.enter("member-dm", { slug, name })`; the palette pushes + * it on the stack (so Back/Escape return to the roster) and the turn streams to that + * member via the hub proxy. Keyed by slug so switching members remounts a clean pane + * bound to that member's own thread. */ +export function memberDmView(): PaletteView { + return { + id: "member-dm", + title: "Direct message", + width: 620, + render: (ctx) => { + const p = (ctx.props ?? {}) as { slug?: string; name?: string }; + return ; + }, + }; +} diff --git a/apps/web/src/app/paletteChatStore.ts b/apps/web/src/app/paletteChatStore.ts index cb907826..3f9a9b60 100644 --- a/apps/web/src/app/paletteChatStore.ts +++ b/apps/web/src/app/paletteChatStore.ts @@ -7,7 +7,7 @@ import type { ChatMessage } from "../lib/types"; // Per-agent key (ADR 0042 slug routing) — a window on /agent// keeps its own // palette thread; host (no slug) uses the bare key. Fixed per page load. -const KEY = (() => { +const baseKey = (() => { try { const m = window.location.pathname.match(/\/agent\/([^/?#]+)/); return m ? `protoagent.palette.chat:${decodeURIComponent(m[1])}` : "protoagent.palette.chat"; @@ -16,6 +16,12 @@ const KEY = (() => { } })(); +// A Fleet Room DM keeps its OWN thread per member (`scope = "dm:"`), so DMing +// different members doesn't cross-contaminate; the plain per-window chat passes no scope. +function keyFor(scope?: string): string { + return scope ? `${baseKey}:${scope}` : baseKey; +} + export type PaletteThread = { contextId: string; messages: ChatMessage[] }; function newContextId(): string { @@ -40,9 +46,9 @@ function sanitize(messages: unknown): ChatMessage[] { .map((m) => (m.status === "streaming" && !m.taskId ? { ...m, status: "done" as const } : m)); } -export function loadPaletteThread(): PaletteThread { +export function loadPaletteThread(scope?: string): PaletteThread { try { - const raw = window.localStorage.getItem(KEY); + const raw = window.localStorage.getItem(keyFor(scope)); if (raw) { const p = JSON.parse(raw) as Partial; if (p && typeof p.contextId === "string") { @@ -56,36 +62,42 @@ export function loadPaletteThread(): PaletteThread { } let saveTimer: ReturnType | null = null; -function write(thread: PaletteThread) { +let pending: { thread: PaletteThread; scope?: string } | null = null; +function write(thread: PaletteThread, scope?: string) { try { - window.localStorage.setItem(KEY, JSON.stringify(thread)); + window.localStorage.setItem(keyFor(scope), JSON.stringify(thread)); } catch { // storage can be unavailable (hardened contexts) } } -/** Persist the thread. Streaming frames coalesce on a trailing 300ms timer; pass - * `immediate` for structural changes (send start / clear). */ -export function savePaletteThread(thread: PaletteThread, immediate = false): void { +/** Persist the thread (optionally scoped to a DM target). Streaming frames coalesce on a + * trailing 300ms timer; pass `immediate` for structural changes (send start / clear). + * Only one palette chat is open at a time, so the single trailing timer flushes the + * latest thread+scope (`pending`). */ +export function savePaletteThread(thread: PaletteThread, immediate = false, scope?: string): void { + pending = { thread, scope }; if (immediate) { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } - write(thread); + write(thread, scope); + pending = null; return; } if (saveTimer) return; // trailing write already scheduled saveTimer = setTimeout(() => { saveTimer = null; - write(thread); + if (pending) write(pending.thread, pending.scope); + pending = null; }, 300); } /** A fresh, empty thread (new contextId) — persisted immediately. The caller wipes the * OLD contextId's server checkpoints separately (api.deleteChatSession). */ -export function freshPaletteThread(): PaletteThread { +export function freshPaletteThread(scope?: string): PaletteThread { const next: PaletteThread = { contextId: newContextId(), messages: [] }; - savePaletteThread(next, true); + savePaletteThread(next, true, scope); return next; } diff --git a/apps/web/src/app/usePaletteRegistry.ts b/apps/web/src/app/usePaletteRegistry.ts index 8fabee67..ec0f6fe2 100644 --- a/apps/web/src/app/usePaletteRegistry.ts +++ b/apps/web/src/app/usePaletteRegistry.ts @@ -23,6 +23,7 @@ import { errMsg } from "../lib/format"; import { fleetQuery, queryKeys } from "../lib/queries"; import { fleetPaletteEntries, markAgentOpened, readAgentRecency, togglableFleetAgents } from "./fleetPalette"; import { fleetRoomView } from "./FleetRoom"; +import { memberDmView } from "./PaletteChat"; /** Optional inline chat with the focused agent (ADR 0057). App builds the native chat * PaletteView (it needs JSX + the focused agent name); the adapter registers it + a @@ -244,6 +245,7 @@ export function usePaletteRegistry( }, }), ); + vs.push(memberDmView()); // Fleet Room → DM a member (the wired chat, retargeted) vs.push({ ...commandsView({ commands: openSurfaceCommands, placeholder: "Open a surface…" }), id: "open", diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 85fb785c..fb85e30f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -258,6 +258,16 @@ export function apiUrl(path: string, opts?: { host?: boolean }) { return base ? `${base}${p.startsWith("/") ? p : `/${p}`}` : p; } +/** Absolute URL for `rel` on a SPECIFIC fleet member, via the hub's per-agent proxy — + * independent of which window is focused. `slug` is the member id, or "host" for this + * instance. apiUrl() routes to the CURRENT window's slug; this targets an arbitrary + * member (Fleet Room DMs + broadcast). */ +export function memberPath(slug: string, rel: string): string { + const base = defaultApiBase(); + const p = slug === "host" ? rel : `/agents/${encodeURIComponent(slug)}${rel}`; + return base ? `${base}${p.startsWith("/") ? p : `/${p}`}` : p; +} + /** True inside the desktop (Tauri/WKWebView) shell. WKWebView does NOT deliver a * `text/event-stream` body through `fetch()` — neither via `body.getReader()` nor * a buffered `clone().text()` (both come back empty) — so the streaming chat turn @@ -1486,10 +1496,7 @@ export const api = { // the promise for the single-send reply text. Bypasses request()/apiUrl() on purpose: // apiUrl routes to the CURRENT window's slug, and this targets an arbitrary member. sendToAgent(slug: string, message: string, sessionId?: string): Promise<{ response?: string }> { - const rel = "/api/chat"; - const path = slug === "host" ? rel : `/agents/${encodeURIComponent(slug)}${rel}`; - const base = defaultApiBase(); - const url = base ? `${base}${path}` : path; + const url = memberPath(slug, "/api/chat"); return fetch(url, { method: "POST", headers: applyAuth(new Headers({ "Content-Type": "application/json" })), @@ -1615,8 +1622,16 @@ export const api = { // fresh turn. Unmarked messages sent while a form is pending are held server-side // until the form resolves. hitlResume?: boolean; + // Stream to a SPECIFIC fleet member (Fleet Room DM) instead of THIS window's agent: + // the turn runs on that member via the hub proxy (/agents//a2a). "host" = this + // instance. Omitted → normal chat with the focused agent (apiUrl slug-routing). + agentSlug?: string; } = {}, ) { + // DM target (opts.agentSlug) streams to that member through the hub proxy; a normal + // chat routes to THIS window's agent via apiUrl(). + const a2aTarget = opts.agentSlug ? memberPath(opts.agentSlug, "/a2a") : apiUrl("/a2a"); + const chatTarget = opts.agentSlug ? memberPath(opts.agentSlug, "/api/chat") : apiUrl("/api/chat"); // One A2A SendStreamingMessage body + one frame dispatcher, shared by the desktop // (Tauri-relayed) and browser (fetch SSE) paths so both decode turns identically. const rpcId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -1717,7 +1732,7 @@ export const api = { }; const tok = authToken(); await core.invoke("chat_stream", { - url: apiUrl("/a2a"), + url: a2aTarget, body: buildBody(), auth: tok ? `Bearer ${tok}` : null, onEvent: channel, @@ -1728,7 +1743,7 @@ export const api = { console.warn("[desktop] native chat stream failed; falling back to /api/chat:", err); } try { - const res = await fetch(apiUrl("/api/chat"), { + const res = await fetch(chatTarget, { method: "POST", headers: applyAuth(new Headers({ "Content-Type": "application/json" })), signal: handlers.signal, @@ -1767,7 +1782,7 @@ export const api = { return; } - const response = await fetch(apiUrl("/a2a"), { + const response = await fetch(a2aTarget, { method: "POST", headers: applyAuth(new Headers({ "Content-Type": "application/json", "A2A-Version": "1.0" })), signal: handlers.signal, @@ -1780,7 +1795,8 @@ export const api = { if (!response.ok) { // token-gated chat turn (#873) — but a member-scoped 401 is the focused remote's bad // token, not the hub's, so don't hijack the hub AuthGate (the boot gate owns that). - if (response.status === 401 && !isMemberScoped("/a2a")) notifyAuthRequired(); + // A DM (opts.agentSlug) is member-scoped by construction. + if (response.status === 401 && !opts.agentSlug && !isMemberScoped("/a2a")) notifyAuthRequired(); throw new Error(`${response.status} ${response.statusText}`); } From 5b61e85a44027f0cc9e2c7837db33e9519a25d3a Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 01:30:21 -0700 Subject: [PATCH 3/6] feat(console): Fleet Activity popout drawer + cleaner Fleet Room footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --- apps/web/e2e/fleet.spec.ts | 13 ++ apps/web/src/app/App.tsx | 4 + apps/web/src/app/FleetActivity.tsx | 138 ++++++++++++++++++++ apps/web/src/app/FleetRoom.tsx | 29 ++++- apps/web/src/app/fleet-activity.css | 168 +++++++++++++++++++++++++ apps/web/src/app/fleet-room.css | 53 +++++++- apps/web/src/app/usePaletteRegistry.ts | 19 ++- 7 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/FleetActivity.tsx create mode 100644 apps/web/src/app/fleet-activity.css diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index d573ce9d..3528bb97 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -306,3 +306,16 @@ test("⌘K → Fleet Room: presence, DM a member (the wired chat), broadcast", a await room.locator(".flr__send").click(); await expect(page.locator(".pl-toast", { hasText: /Broadcast to \d+ member/ })).toBeVisible(); }); + +test("⌘K → Fleet Room → Activity opens the live feed drawer", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await openFleetRoom(page); + // The Activity button closes the palette and pops the drawer over the console. + await page.locator(".flr__activity").click(); + await expect(page.locator(".flact-root.is-open")).toHaveCount(1); + await expect(page.getByText("Fleet activity", { exact: true })).toBeVisible(); + await expect(page.locator(".flact__empty")).toBeVisible(); // no events until presence changes + // Escape closes it. + await page.keyboard.press("Escape"); + await expect(page.locator(".flact-root.is-open")).toHaveCount(0); +}); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index b8123e61..e97a0730 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -130,6 +130,7 @@ import { buildViews } from "../lib/viewRegistry"; import { applyNavIntent, openView, usePaletteRegistry } from "./usePaletteRegistry"; import type { NavIntent } from "./usePaletteRegistry"; import { PaletteChat } from "./PaletteChat"; +import { FleetActivityDrawer } from "./FleetActivity"; import { CORE_SURFACES } from "./coreSurfaces"; import { listen } from "../lib/desktop"; @@ -827,6 +828,9 @@ export function App() { {/* Command palette (⌘K, ADR 0057) — portals over the shell; the same component backs the desktop quick-command (step 4). */} + {/* Fleet Activity — a right-side popout drawer with the live fleet-wide event feed + (opened from the Fleet Room or the ⌘K "Fleet Activity" command). */} +
{/* protoLabs.studio brand bumper — DS Splash (@protolabsai/ui/splash). Holds 2.5s then hands off via the View Transitions API cross-fade (the diff --git a/apps/web/src/app/FleetActivity.tsx b/apps/web/src/app/FleetActivity.tsx new file mode 100644 index 00000000..5f880d40 --- /dev/null +++ b/apps/web/src/app/FleetActivity.tsx @@ -0,0 +1,138 @@ +// Fleet Activity — a right-side popout drawer showing a live, fleet-wide event feed +// (the "one event log" idea from Buzz, on our own infra). Opened from the Fleet Room's +// header or the ⌘K "Fleet Activity" command; slides over the console, independent of the +// palette so it stays ambient. +// +// v1 sources REAL events only: member presence transitions (online/offline/added/removed, +// diffed from the roster poll) + broadcasts you send. Richer cross-member events (PRs, +// approvals, tool runs) are the next step — aggregate each member's event bus (ADR 0039) +// through the hub proxy — and are deliberately NOT faked here. +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { create } from "zustand"; +import { Radio, X } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { fleetQuery } from "../lib/queries"; +import type { FleetAgent } from "../lib/types"; +import "./fleet-activity.css"; + +export type FleetEventKind = "online" | "offline" | "added" | "removed" | "broadcast"; + +export type FleetEvent = { + id: string; + ts: number; + source: string; + text: string; + kind: FleetEventKind; +}; + +type FleetActivityState = { + open: boolean; + events: FleetEvent[]; + setOpen: (open: boolean) => void; + push: (e: Omit) => void; +}; + +let seq = 0; +const MAX = 60; + +const useFleetActivity = create((set) => ({ + open: false, + events: [], + setOpen: (open) => set({ open }), + push: (e) => + set((s) => ({ + events: [{ ...e, id: `flev-${(seq += 1)}`, ts: Date.now() }, ...s.events].slice(0, MAX), + })), +})); + +/** Open/close the drawer + append events from anywhere (Fleet Room, palette command). */ +export const openFleetActivity = () => useFleetActivity.getState().setOpen(true); +export const pushFleetEvent = (e: Omit) => useFleetActivity.getState().push(e); + +const slugOf = (a: FleetAgent): string => (a.host ? "host" : a.id); +const hhmm = (ts: number): string => + new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); + +/** Diff consecutive roster polls into presence events. Runs whenever the drawer is + * mounted (app root), so the log accumulates even while the drawer is closed. */ +function useRosterCapture() { + const { data } = useQuery(fleetQuery()); + const prev = useRef | null>(null); + useEffect(() => { + if (!data) return; // wait for the first real roster (undefined→data isn't a diff) + const cur = new Map(data.agents.map((a) => [slugOf(a), a])); + const before = prev.current; + prev.current = cur; + if (!before) return; // first real roster seeds the baseline — don't emit a burst + const push = useFleetActivity.getState().push; + for (const [slug, a] of cur) { + const was = before.get(slug); + if (!was) push({ source: a.name, text: "joined the fleet", kind: "added" }); + else if (was.running !== a.running) + a.running + ? push({ source: a.name, text: "came online", kind: "online" }) + : push({ source: a.name, text: "went offline", kind: "offline" }); + } + for (const [slug, a] of before) if (!cur.has(slug)) push({ source: a.name, text: "left the fleet", kind: "removed" }); + }, [data]); +} + +/** Mounted once at the app root: captures the live feed continuously and renders the + * drawer when open. */ +export function FleetActivityDrawer() { + const open = useFleetActivity((s) => s.open); + const events = useFleetActivity((s) => s.events); + const setOpen = useFleetActivity((s) => s.setOpen); + useRosterCapture(); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, setOpen]); + + if (typeof document === "undefined") return null; + + return createPortal( +
+ + +
+ {events.length === 0 && ( +
+ No activity yet. Start or stop a member, or broadcast from the Fleet Room, and it shows up here. +
+ )} + {events.map((e) => ( +
+ +
+ + {e.kind === "broadcast" && } + {e.source} + +

{e.text}

+
+
+ ))} +
+ +
, + document.body, + ); +} diff --git a/apps/web/src/app/FleetRoom.tsx b/apps/web/src/app/FleetRoom.tsx index 32b8324e..a238114d 100644 --- a/apps/web/src/app/FleetRoom.tsx +++ b/apps/web/src/app/FleetRoom.tsx @@ -11,7 +11,7 @@ // fleet-wide activity feed (aggregate each member's event bus, ADR 0039). import { useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { ExternalLink, Play, Radio, Send, Square } from "lucide-react"; +import { Activity, ExternalLink, Play, Radio, Send, Square } from "lucide-react"; import { useToast } from "@protolabsai/ui/overlays"; import type { PaletteContext, PaletteView } from "@protolabsai/ui/command-palette"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -19,6 +19,7 @@ import { api, currentSlug } from "../lib/api"; import { fleetQuery, queryKeys } from "../lib/queries"; import { errMsg } from "../lib/format"; import type { FleetAgent } from "../lib/types"; +import { openFleetActivity, pushFleetEvent } from "./FleetActivity"; import "./fleet-room.css"; /** The routing slug for a member — the host entry is the reserved "host" (ADR 0042). */ @@ -63,6 +64,7 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl () => roster.filter((a) => a.running && slugOf(a) !== here), [roster, here], ); + const onlineCount = roster.filter((a) => a.running).length; // DM a member = the wired chat, retargeted. Push it on the palette stack so Back/Escape // return here. Only running members are reachable. @@ -76,6 +78,11 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl onOpenAgent(slugOf(a)); // routed through the palette nav chokepoint (launcher-safe) }; + const activity = () => { + ctx.close(); + openFleetActivity(); // the drawer overlays the console once the palette closes + }; + const toggle = (a: FleetAgent) => { const on = a.running; (on ? api.stopAgent(a.name) : api.startAgent(a.name)) @@ -108,6 +115,7 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl title: `Broadcast to ${broadcastTargets.length} member${broadcastTargets.length > 1 ? "s" : ""}`, message: clip(msg), }); + pushFleetEvent({ source: "you", text: `broadcast to ${broadcastTargets.length}: “${clip(msg, 48)}”`, kind: "broadcast" }); setDraft(""); inputRef.current?.focus(); }; @@ -121,6 +129,15 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl return (
+
+ + {onlineCount} online · {roster.length} member{roster.length === 1 ? "" : "s"} + + +
{roster.length === 0 &&
No members yet — add one from Settings ▸ Agents.
} {roster.map((a) => { @@ -213,7 +230,15 @@ export function fleetRoomView(opts: { onOpenAgent: (slug: string) => void }): Pa width: 620, footerHint: ( - click a member to DM · broadcasts to all online + + click DM a member + + + broadcast + + + esc close + ), render: (ctx) => , diff --git a/apps/web/src/app/fleet-activity.css b/apps/web/src/app/fleet-activity.css new file mode 100644 index 00000000..5f930fda --- /dev/null +++ b/apps/web/src/app/fleet-activity.css @@ -0,0 +1,168 @@ +/* Fleet Activity popout drawer. Right-side slide-over, styled through DS tokens so it + * tracks the console theme. Feed layout mirrors the concept mockup: time · source · text. */ + +.flact-root { + position: fixed; + inset: 0; + z-index: 60; + pointer-events: none; +} +.flact-root.is-open { + pointer-events: auto; +} + +.flact-scrim { + position: absolute; + inset: 0; + border: 0; + padding: 0; + background: var(--pl-color-overlay, rgba(10, 8, 20, 0.4)); + opacity: 0; + transition: opacity 180ms ease; + cursor: default; +} +.flact-root.is-open .flact-scrim { + opacity: 1; +} + +.flact { + position: absolute; + top: 0; + right: 0; + height: 100%; + width: min(360px, 92vw); + display: flex; + flex-direction: column; + background: var(--pl-color-bg-raised, var(--pl-color-bg)); + border-left: 1px solid var(--pl-color-border-strong, var(--pl-color-border)); + box-shadow: -18px 0 48px -24px rgba(0, 0, 0, 0.5); + transform: translateX(100%); + transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1); +} +.flact-root.is-open .flact { + transform: none; +} + +.flact__head { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 14px; + border-bottom: 1px solid var(--pl-color-border); +} +.flact__title { + font-size: 13px; + font-weight: 650; + color: var(--pl-color-fg); +} +.flact__live { + display: inline-flex; + align-items: center; + gap: 5px; + font: 600 10.5px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--pl-color-status-success); +} +.flact__livedot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--pl-color-status-success); + box-shadow: 0 0 0 0 var(--pl-color-status-success); + animation: flact-live 1.8s ease-out infinite; +} +@keyframes flact-live { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pl-color-status-success) 55%, transparent); + } + 100% { + box-shadow: 0 0 0 7px transparent; + } +} +.flact__spacer { + flex: 1; +} +.flact__close { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 7px; + border: 0; + background: none; + color: var(--pl-color-fg-muted); + cursor: pointer; +} +.flact__close:hover { + background: var(--pl-color-bg-inset); + color: var(--pl-color-fg); +} + +.flact__feed { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 14px 14px; +} +.flact__empty { + padding: 28px 6px; + color: var(--pl-color-fg-muted); + font-size: 12.5px; + line-height: 1.5; +} + +.flact__event { + display: grid; + grid-template-columns: auto 1fr; + gap: 11px; + padding: 9px 0; +} +.flact__event + .flact__event { + border-top: 1px solid var(--pl-color-border); +} +.flact__time { + font: 400 11px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); + color: var(--pl-color-fg-subtle); + padding-top: 1px; +} +.flact__body { + min-width: 0; +} +.flact__src { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12.5px; + font-weight: 650; + color: var(--pl-color-fg); +} +.flact__src--online { + color: var(--pl-color-status-success); +} +.flact__src--offline, +.flact__src--removed { + color: var(--pl-color-fg-muted); +} +.flact__src--added { + color: var(--pl-color-status-info); +} +.flact__src--broadcast { + color: var(--pl-color-accent); +} +.flact__text { + margin: 2px 0 0; + font-size: 12.5px; + line-height: 1.4; + color: var(--pl-color-fg-muted); +} + +@media (prefers-reduced-motion: reduce) { + .flact, + .flact-scrim { + transition: none; + } + .flact__livedot { + animation: none; + } +} diff --git a/apps/web/src/app/fleet-room.css b/apps/web/src/app/fleet-room.css index 9cf39b65..c2a06a41 100644 --- a/apps/web/src/app/fleet-room.css +++ b/apps/web/src/app/fleet-room.css @@ -239,10 +239,59 @@ border-radius: 7px; } -.flr__hint b { - font-weight: 600; +/* top bar: member count + the Activity drawer toggle */ +.flr__topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 12px 8px; + border-bottom: 1px solid var(--pl-color-border); +} +.flr__count { + font: 500 11.5px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); color: var(--pl-color-fg-muted); } +.flr__activity { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: 8px; + border: 1px solid var(--pl-color-border); + background: var(--pl-color-bg-subtle); + color: var(--pl-color-fg-muted); + font: 600 12px inherit; + cursor: pointer; +} +.flr__activity:hover { + color: var(--pl-color-accent); + border-color: color-mix(in srgb, var(--pl-color-accent) 45%, transparent); +} + +/* footer keycap hints */ +.flr__hint { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + align-items: center; +} +.flr__hint > span { + display: inline-flex; + align-items: center; + gap: 6px; +} +.flr__kbd { + font: 500 10.5px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); + color: var(--pl-color-fg-muted); + background: var(--pl-color-bg-subtle); + border: 1px solid var(--pl-color-border); + border-bottom-width: 2px; + border-radius: 5px; + padding: 1px 5px; + min-width: 16px; + text-align: center; +} @media (prefers-reduced-motion: reduce) { .flr__dot--online::after, diff --git a/apps/web/src/app/usePaletteRegistry.ts b/apps/web/src/app/usePaletteRegistry.ts index ec0f6fe2..a0cbe5a3 100644 --- a/apps/web/src/app/usePaletteRegistry.ts +++ b/apps/web/src/app/usePaletteRegistry.ts @@ -24,6 +24,7 @@ import { fleetQuery, queryKeys } from "../lib/queries"; import { fleetPaletteEntries, markAgentOpened, readAgentRecency, togglableFleetAgents } from "./fleetPalette"; import { fleetRoomView } from "./FleetRoom"; import { memberDmView } from "./PaletteChat"; +import { openFleetActivity } from "./FleetActivity"; /** Optional inline chat with the focused agent (ADR 0057). App builds the native chat * PaletteView (it needs JSX + the focused agent name); the adapter registers it + a @@ -278,12 +279,25 @@ export function usePaletteRegistry( { id: "fleet-room", label: "Fleet Room", - hint: "members · address · broadcast", + hint: "members · DM · broadcast", group: "Agents", - keywords: ["fleet", "room", "members", "agents", "team", "crew", "broadcast", "address", "roster"], + keywords: ["fleet", "room", "members", "agents", "team", "crew", "broadcast", "dm", "roster"], run: (c) => c.enter("fleet-room"), }, ]); + const offFleetActivity = registry.registerCommands([ + { + id: "fleet-activity", + label: "Fleet Activity", + hint: "live event feed", + group: "Agents", + keywords: ["fleet", "activity", "feed", "events", "log", "presence", "drawer"], + run: (c) => { + c.close(); + openFleetActivity(); + }, + }, + ]); // Fleet quick-chat (#1733): every OTHER agent, in the "Agents" group beneath "Chat with // ". Picking one navigates to its slug-routed console via a serializable `agent` // NavIntent (so it also works forwarded from the launcher window). A down agent is listed @@ -396,6 +410,7 @@ export function usePaletteRegistry( return () => { offChat?.(); offFleetRoom(); + offFleetActivity(); offFleet?.(); offPlugins?.(); offToggleView(); From 3c086d1dae262601eee35553155deea654a24575 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 02:26:09 -0700 Subject: [PATCH 4/6] fix(console): Fleet Activity belongs IN the dialog, not an app-side drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --- apps/web/e2e/fleet.spec.ts | 16 ++- apps/web/src/app/App.tsx | 8 +- apps/web/src/app/FleetActivity.tsx | 113 ++++++++----------- apps/web/src/app/FleetRoom.tsx | 142 ++++++++++++------------ apps/web/src/app/fleet-activity.css | 147 ++++++++----------------- apps/web/src/app/fleet-room.css | 65 +++++------ apps/web/src/app/usePaletteRegistry.ts | 15 --- 7 files changed, 206 insertions(+), 300 deletions(-) diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 3528bb97..551e09c2 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -307,15 +307,13 @@ test("⌘K → Fleet Room: presence, DM a member (the wired chat), broadcast", a await expect(page.locator(".pl-toast", { hasText: /Broadcast to \d+ member/ })).toBeVisible(); }); -test("⌘K → Fleet Room → Activity opens the live feed drawer", async ({ page }) => { +test("⌘K → Fleet Room shows the roster + live activity feed side by side", async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); await openFleetRoom(page); - // The Activity button closes the palette and pops the drawer over the console. - await page.locator(".flr__activity").click(); - await expect(page.locator(".flact-root.is-open")).toHaveCount(1); - await expect(page.getByText("Fleet activity", { exact: true })).toBeVisible(); - await expect(page.locator(".flact__empty")).toBeVisible(); // no events until presence changes - // Escape closes it. - await page.keyboard.press("Escape"); - await expect(page.locator(".flact-root.is-open")).toHaveCount(0); + const room = page.locator(".flr"); + // Two columns inside the dialog: roster on the left, the activity feed on the right. + await expect(room.locator(".flr__roster")).toBeVisible(); + await expect(room.locator(".flr__activity")).toBeVisible(); + await expect(room.getByText("Fleet activity", { exact: true })).toBeVisible(); + await expect(room.locator(".flr-feed__empty")).toBeVisible(); // no events until presence changes }); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index e97a0730..1e6ce1fa 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -130,7 +130,7 @@ import { buildViews } from "../lib/viewRegistry"; import { applyNavIntent, openView, usePaletteRegistry } from "./usePaletteRegistry"; import type { NavIntent } from "./usePaletteRegistry"; import { PaletteChat } from "./PaletteChat"; -import { FleetActivityDrawer } from "./FleetActivity"; +import { FleetActivityCapture } from "./FleetActivity"; import { CORE_SURFACES } from "./coreSurfaces"; import { listen } from "../lib/desktop"; @@ -828,9 +828,9 @@ export function App() { {/* Command palette (⌘K, ADR 0057) — portals over the shell; the same component backs the desktop quick-command (step 4). */} - {/* Fleet Activity — a right-side popout drawer with the live fleet-wide event feed - (opened from the Fleet Room or the ⌘K "Fleet Activity" command). */} - + {/* Headless: keeps the fleet activity feed capturing continuously so the Fleet Room's + activity column has history even when the room is closed. */} +
{/* protoLabs.studio brand bumper — DS Splash (@protolabsai/ui/splash). Holds 2.5s then hands off via the View Transitions API cross-fade (the diff --git a/apps/web/src/app/FleetActivity.tsx b/apps/web/src/app/FleetActivity.tsx index 5f880d40..439c8ba3 100644 --- a/apps/web/src/app/FleetActivity.tsx +++ b/apps/web/src/app/FleetActivity.tsx @@ -1,16 +1,15 @@ -// Fleet Activity — a right-side popout drawer showing a live, fleet-wide event feed -// (the "one event log" idea from Buzz, on our own infra). Opened from the Fleet Room's -// header or the ⌘K "Fleet Activity" command; slides over the console, independent of the -// palette so it stays ambient. +// Fleet Activity — the live, fleet-wide event feed that renders as the RIGHT COLUMN of +// the Fleet Room dialog (next to the roster), matching the concept mockup. The capture +// runs headless at the app root so the log accumulates even while the room is closed; +// FleetActivityFeed is the column the Fleet Room renders. // // v1 sources REAL events only: member presence transitions (online/offline/added/removed, // diffed from the roster poll) + broadcasts you send. Richer cross-member events (PRs, -// approvals, tool runs) are the next step — aggregate each member's event bus (ADR 0039) -// through the hub proxy — and are deliberately NOT faked here. +// approvals, a member's running turn) come next — aggregate each member's event bus +// (ADR 0039) through the hub proxy — and are deliberately NOT faked here. import { useEffect, useRef } from "react"; -import { createPortal } from "react-dom"; import { create } from "zustand"; -import { Radio, X } from "lucide-react"; +import { Radio } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fleetQuery } from "../lib/queries"; import type { FleetAgent } from "../lib/types"; @@ -27,9 +26,7 @@ export type FleetEvent = { }; type FleetActivityState = { - open: boolean; events: FleetEvent[]; - setOpen: (open: boolean) => void; push: (e: Omit) => void; }; @@ -37,25 +34,21 @@ let seq = 0; const MAX = 60; const useFleetActivity = create((set) => ({ - open: false, events: [], - setOpen: (open) => set({ open }), push: (e) => set((s) => ({ events: [{ ...e, id: `flev-${(seq += 1)}`, ts: Date.now() }, ...s.events].slice(0, MAX), })), })); -/** Open/close the drawer + append events from anywhere (Fleet Room, palette command). */ -export const openFleetActivity = () => useFleetActivity.getState().setOpen(true); +/** Append an event from anywhere (e.g. the Fleet Room broadcast). */ export const pushFleetEvent = (e: Omit) => useFleetActivity.getState().push(e); const slugOf = (a: FleetAgent): string => (a.host ? "host" : a.id); const hhmm = (ts: number): string => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); -/** Diff consecutive roster polls into presence events. Runs whenever the drawer is - * mounted (app root), so the log accumulates even while the drawer is closed. */ +/** Diff consecutive roster polls into presence events. */ function useRosterCapture() { const { data } = useQuery(fleetQuery()); const prev = useRef | null>(null); @@ -78,61 +71,43 @@ function useRosterCapture() { }, [data]); } -/** Mounted once at the app root: captures the live feed continuously and renders the - * drawer when open. */ -export function FleetActivityDrawer() { - const open = useFleetActivity((s) => s.open); - const events = useFleetActivity((s) => s.events); - const setOpen = useFleetActivity((s) => s.setOpen); +/** Mounted once at the app root: keeps the feed capturing continuously (headless). */ +export function FleetActivityCapture() { useRosterCapture(); + return null; +} - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, setOpen]); - - if (typeof document === "undefined") return null; - - return createPortal( -
- - -
- {events.length === 0 && ( -
- No activity yet. Start or stop a member, or broadcast from the Fleet Room, and it shows up here. -
- )} - {events.map((e) => ( -
- -
- - {e.kind === "broadcast" && } - {e.source} - -

{e.text}

-
+/** The activity column rendered inside the Fleet Room dialog (right of the roster). */ +export function FleetActivityFeed() { + const events = useFleetActivity((s) => s.events); + return ( +
+
+

Fleet activity

+ + + live + +
+
+ {events.length === 0 && ( +
+ No activity yet. Start/stop a member or broadcast, and it shows up here. +
+ )} + {events.map((e) => ( +
+ +
+ + {e.kind === "broadcast" && } + {e.source} + +

{e.text}

- ))} -
- -
, - document.body, +
+ ))} +
+
); } diff --git a/apps/web/src/app/FleetRoom.tsx b/apps/web/src/app/FleetRoom.tsx index a238114d..a8519504 100644 --- a/apps/web/src/app/FleetRoom.tsx +++ b/apps/web/src/app/FleetRoom.tsx @@ -1,17 +1,17 @@ // The Fleet Room (⌘K, ADR 0042 + the palette-UX overhaul). A native palette morph-view -// that makes the fleet feel like a Discord room: every workspace agent is a presence-aware -// MEMBER. Click one to DM it — that's the wired ⌘K chat (PaletteChat) pointed at the -// member (`ctx.enter("member-dm", …)`), streaming through the hub proxy, with Back to the -// roster. The bottom bar broadcasts to everyone online (the @everyone announce — the only +// that makes the fleet feel like a Discord room: a roster of presence-aware MEMBERS on the +// left, the live fleet activity feed on the right, a broadcast bar below. Click a member +// to DM it — that's the wired ⌘K chat (PaletteChat) pointed at the member +// (`ctx.enter("member-dm", …)`), streaming through the hub proxy, with Back to the roster. +// The bottom bar broadcasts to everyone online (the @everyone announce — the only // fire-and-forget path, since you can't stream N replies into one pane). // // Entered from the palette's "Agents" group; the DS CommandPalette supplies the // back/close chrome + footer. Ungated for now — it reflects whatever api.fleet() returns -// for this window; host-scoping (cf. fleetSettingsGate) can follow. Deferred: a live -// fleet-wide activity feed (aggregate each member's event bus, ADR 0039). +// for this window; host-scoping (cf. fleetSettingsGate) can follow. import { useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { Activity, ExternalLink, Play, Radio, Send, Square } from "lucide-react"; +import { ExternalLink, Play, Radio, Send, Square } from "lucide-react"; import { useToast } from "@protolabsai/ui/overlays"; import type { PaletteContext, PaletteView } from "@protolabsai/ui/command-palette"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -19,7 +19,7 @@ import { api, currentSlug } from "../lib/api"; import { fleetQuery, queryKeys } from "../lib/queries"; import { errMsg } from "../lib/format"; import type { FleetAgent } from "../lib/types"; -import { openFleetActivity, pushFleetEvent } from "./FleetActivity"; +import { FleetActivityFeed, pushFleetEvent } from "./FleetActivity"; import "./fleet-room.css"; /** The routing slug for a member — the host entry is the reserved "host" (ADR 0042). */ @@ -78,11 +78,6 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl onOpenAgent(slugOf(a)); // routed through the palette nav chokepoint (launcher-safe) }; - const activity = () => { - ctx.close(); - openFleetActivity(); // the drawer overlays the console once the palette closes - }; - const toggle = (a: FleetAgent) => { const on = a.running; (on ? api.stopAgent(a.name) : api.startAgent(a.name)) @@ -129,66 +124,73 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl return (
-
- - {onlineCount} online · {roster.length} member{roster.length === 1 ? "" : "s"} - - -
-
- {roster.length === 0 &&
No members yet — add one from Settings ▸ Agents.
} - {roster.map((a) => { - const slug = slugOf(a); - const p = presenceOf(a); - const local = !a.host && !a.remote; // only a local process can be started/stopped here - return ( -
- - -
- {local && ( +
+
+
+

Members

+ + {onlineCount} online · {roster.length} + +
+
+ {roster.length === 0 && ( +
No members yet — add one from Settings ▸ Agents.
+ )} + {roster.map((a) => { + const slug = slugOf(a); + const p = presenceOf(a); + const local = !a.host && !a.remote; // only a local process can be started/stopped here + return ( +
+ - )} - -
-
- ); - })} +
+ {local && ( + + )} + +
+
+ ); + })} +
+
+ +
+ +
@@ -227,7 +229,7 @@ export function fleetRoomView(opts: { onOpenAgent: (slug: string) => void }): Pa return { id: "fleet-room", title: "Fleet", - width: 620, + width: 780, footerHint: ( diff --git a/apps/web/src/app/fleet-activity.css b/apps/web/src/app/fleet-activity.css index 5f930fda..03a82165 100644 --- a/apps/web/src/app/fleet-activity.css +++ b/apps/web/src/app/fleet-activity.css @@ -1,135 +1,82 @@ -/* Fleet Activity popout drawer. Right-side slide-over, styled through DS tokens so it - * tracks the console theme. Feed layout mirrors the concept mockup: time · source · text. */ +/* Fleet Activity — the right column of the Fleet Room dialog. Styled through DS tokens; + * feed layout mirrors the concept mockup: time · source · text. */ -.flact-root { - position: fixed; - inset: 0; - z-index: 60; - pointer-events: none; -} -.flact-root.is-open { - pointer-events: auto; -} - -.flact-scrim { - position: absolute; - inset: 0; - border: 0; - padding: 0; - background: var(--pl-color-overlay, rgba(10, 8, 20, 0.4)); - opacity: 0; - transition: opacity 180ms ease; - cursor: default; -} -.flact-root.is-open .flact-scrim { - opacity: 1; -} - -.flact { - position: absolute; - top: 0; - right: 0; - height: 100%; - width: min(360px, 92vw); +.flr-feed { display: flex; flex-direction: column; - background: var(--pl-color-bg-raised, var(--pl-color-bg)); - border-left: 1px solid var(--pl-color-border-strong, var(--pl-color-border)); - box-shadow: -18px 0 48px -24px rgba(0, 0, 0, 0.5); - transform: translateX(100%); - transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1); -} -.flact-root.is-open .flact { - transform: none; + min-height: 0; + background: color-mix(in srgb, var(--pl-color-bg-subtle) 55%, var(--pl-color-bg)); } -.flact__head { +.flr-feed__head { display: flex; align-items: center; - gap: 10px; - padding: 13px 14px; - border-bottom: 1px solid var(--pl-color-border); + justify-content: space-between; + padding: 12px 14px 8px; } -.flact__title { - font-size: 13px; - font-weight: 650; - color: var(--pl-color-fg); +.flr-feed__head h2 { + margin: 0; + font: 600 10.5px inherit; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--pl-color-fg-muted); } -.flact__live { +.flr-feed__live { display: inline-flex; align-items: center; gap: 5px; - font: 600 10.5px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); + font: 600 10px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.07em; color: var(--pl-color-status-success); } -.flact__livedot { - width: 7px; - height: 7px; +.flr-feed__livedot { + width: 6px; + height: 6px; border-radius: 50%; background: var(--pl-color-status-success); - box-shadow: 0 0 0 0 var(--pl-color-status-success); - animation: flact-live 1.8s ease-out infinite; + animation: flr-feed-live 1.8s ease-out infinite; } -@keyframes flact-live { +@keyframes flr-feed-live { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--pl-color-status-success) 55%, transparent); } 100% { - box-shadow: 0 0 0 7px transparent; + box-shadow: 0 0 0 6px transparent; } } -.flact__spacer { - flex: 1; -} -.flact__close { - display: grid; - place-items: center; - width: 28px; - height: 28px; - border-radius: 7px; - border: 0; - background: none; - color: var(--pl-color-fg-muted); - cursor: pointer; -} -.flact__close:hover { - background: var(--pl-color-bg-inset); - color: var(--pl-color-fg); -} -.flact__feed { +.flr-feed__list { flex: 1; min-height: 0; overflow-y: auto; - padding: 4px 14px 14px; + padding: 0 14px 12px; } -.flact__empty { - padding: 28px 6px; - color: var(--pl-color-fg-muted); - font-size: 12.5px; +.flr-feed__empty { + padding: 20px 2px; + color: var(--pl-color-fg-subtle); + font-size: 12px; line-height: 1.5; } -.flact__event { +.flr-feed__event { display: grid; grid-template-columns: auto 1fr; - gap: 11px; - padding: 9px 0; + gap: 10px; + padding: 8px 0; } -.flact__event + .flact__event { +.flr-feed__event + .flr-feed__event { border-top: 1px solid var(--pl-color-border); } -.flact__time { +.flr-feed__time { font: 400 11px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); color: var(--pl-color-fg-subtle); padding-top: 1px; } -.flact__body { +.flr-feed__body { min-width: 0; } -.flact__src { +.flr-feed__src { display: inline-flex; align-items: center; gap: 5px; @@ -137,20 +84,20 @@ font-weight: 650; color: var(--pl-color-fg); } -.flact__src--online { +.flr-feed__src--online { color: var(--pl-color-status-success); } -.flact__src--offline, -.flact__src--removed { - color: var(--pl-color-fg-muted); -} -.flact__src--added { +.flr-feed__src--added { color: var(--pl-color-status-info); } -.flact__src--broadcast { +.flr-feed__src--offline, +.flr-feed__src--removed { + color: var(--pl-color-fg-muted); +} +.flr-feed__src--broadcast { color: var(--pl-color-accent); } -.flact__text { +.flr-feed__text { margin: 2px 0 0; font-size: 12.5px; line-height: 1.4; @@ -158,11 +105,7 @@ } @media (prefers-reduced-motion: reduce) { - .flact, - .flact-scrim { - transition: none; - } - .flact__livedot { + .flr-feed__livedot { animation: none; } } diff --git a/apps/web/src/app/fleet-room.css b/apps/web/src/app/fleet-room.css index c2a06a41..acaf4e54 100644 --- a/apps/web/src/app/fleet-room.css +++ b/apps/web/src/app/fleet-room.css @@ -5,10 +5,43 @@ .flr { display: flex; flex-direction: column; - height: 460px; + height: 480px; min-height: 0; } +/* two-column body: roster (left) + live activity feed (right) */ +.flr__cols { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1.35fr 1fr; +} +.flr__col { + display: flex; + flex-direction: column; + min-height: 0; +} +.flr__roster { + border-right: 1px solid var(--pl-color-border); +} +.flr__colhead { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px 8px; +} +.flr__colhead h2 { + margin: 0; + font: 600 10.5px inherit; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--pl-color-fg-muted); +} +.flr__count { + font: 500 11px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); + color: var(--pl-color-fg-subtle); +} + .flr__list { flex: 1; min-height: 0; @@ -239,36 +272,6 @@ border-radius: 7px; } -/* top bar: member count + the Activity drawer toggle */ -.flr__topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 9px 12px 8px; - border-bottom: 1px solid var(--pl-color-border); -} -.flr__count { - font: 500 11.5px var(--pl-font-mono, ui-monospace, "SF Mono", Menlo, monospace); - color: var(--pl-color-fg-muted); -} -.flr__activity { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 9px; - border-radius: 8px; - border: 1px solid var(--pl-color-border); - background: var(--pl-color-bg-subtle); - color: var(--pl-color-fg-muted); - font: 600 12px inherit; - cursor: pointer; -} -.flr__activity:hover { - color: var(--pl-color-accent); - border-color: color-mix(in srgb, var(--pl-color-accent) 45%, transparent); -} - /* footer keycap hints */ .flr__hint { display: flex; diff --git a/apps/web/src/app/usePaletteRegistry.ts b/apps/web/src/app/usePaletteRegistry.ts index a0cbe5a3..2b85b538 100644 --- a/apps/web/src/app/usePaletteRegistry.ts +++ b/apps/web/src/app/usePaletteRegistry.ts @@ -24,7 +24,6 @@ import { fleetQuery, queryKeys } from "../lib/queries"; import { fleetPaletteEntries, markAgentOpened, readAgentRecency, togglableFleetAgents } from "./fleetPalette"; import { fleetRoomView } from "./FleetRoom"; import { memberDmView } from "./PaletteChat"; -import { openFleetActivity } from "./FleetActivity"; /** Optional inline chat with the focused agent (ADR 0057). App builds the native chat * PaletteView (it needs JSX + the focused agent name); the adapter registers it + a @@ -285,19 +284,6 @@ export function usePaletteRegistry( run: (c) => c.enter("fleet-room"), }, ]); - const offFleetActivity = registry.registerCommands([ - { - id: "fleet-activity", - label: "Fleet Activity", - hint: "live event feed", - group: "Agents", - keywords: ["fleet", "activity", "feed", "events", "log", "presence", "drawer"], - run: (c) => { - c.close(); - openFleetActivity(); - }, - }, - ]); // Fleet quick-chat (#1733): every OTHER agent, in the "Agents" group beneath "Chat with // ". Picking one navigates to its slug-routed console via a serializable `agent` // NavIntent (so it also works forwarded from the launcher window). A down agent is listed @@ -410,7 +396,6 @@ export function usePaletteRegistry( return () => { offChat?.(); offFleetRoom(); - offFleetActivity(); offFleet?.(); offPlugins?.(); offToggleView(); From 4939e04d5a03e26dc848f27421bd6c6919b87334 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 02:42:26 -0700 Subject: [PATCH 5/6] feat(console): wire the Fleet Activity feed to each member's event bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 " 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) Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --- apps/web/e2e/fleet.spec.ts | 4 +- apps/web/src/app/App.tsx | 4 - apps/web/src/app/FleetActivity.tsx | 126 ++++++++++++++++++++++++++-- apps/web/src/app/fleet-activity.css | 6 +- apps/web/src/lib/api.ts | 8 ++ 5 files changed, 136 insertions(+), 12 deletions(-) diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 551e09c2..ecc93aef 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -315,5 +315,7 @@ test("⌘K → Fleet Room shows the roster + live activity feed side by side", a await expect(room.locator(".flr__roster")).toBeVisible(); await expect(room.locator(".flr__activity")).toBeVisible(); await expect(room.getByText("Fleet activity", { exact: true })).toBeVisible(); - await expect(room.locator(".flr-feed__empty")).toBeVisible(); // no events until presence changes + // The feed streams each online member's event bus (/agents//api/events) — the mock + // pushes activity/inbox/goal frames, so a mapped event lands in the column. + await expect(room.locator(".flr-feed__event").first()).toBeVisible({ timeout: 6000 }); }); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 1e6ce1fa..b8123e61 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -130,7 +130,6 @@ import { buildViews } from "../lib/viewRegistry"; import { applyNavIntent, openView, usePaletteRegistry } from "./usePaletteRegistry"; import type { NavIntent } from "./usePaletteRegistry"; import { PaletteChat } from "./PaletteChat"; -import { FleetActivityCapture } from "./FleetActivity"; import { CORE_SURFACES } from "./coreSurfaces"; import { listen } from "../lib/desktop"; @@ -828,9 +827,6 @@ export function App() { {/* Command palette (⌘K, ADR 0057) — portals over the shell; the same component backs the desktop quick-command (step 4). */} - {/* Headless: keeps the fleet activity feed capturing continuously so the Fleet Room's - activity column has history even when the room is closed. */} -
{/* protoLabs.studio brand bumper — DS Splash (@protolabsai/ui/splash). Holds 2.5s then hands off via the View Transitions API cross-fade (the diff --git a/apps/web/src/app/FleetActivity.tsx b/apps/web/src/app/FleetActivity.tsx index 439c8ba3..bf6484ca 100644 --- a/apps/web/src/app/FleetActivity.tsx +++ b/apps/web/src/app/FleetActivity.tsx @@ -12,10 +12,12 @@ import { create } from "zustand"; import { Radio } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fleetQuery } from "../lib/queries"; +import { api, memberPath } from "../lib/api"; +import { buildEventsUrl } from "../lib/events"; import type { FleetAgent } from "../lib/types"; import "./fleet-activity.css"; -export type FleetEventKind = "online" | "offline" | "added" | "removed" | "broadcast"; +export type FleetEventKind = "online" | "offline" | "added" | "removed" | "broadcast" | "turn" | "activity"; export type FleetEvent = { id: string; @@ -71,14 +73,126 @@ function useRosterCapture() { }, [data]); } -/** Mounted once at the app root: keeps the feed capturing continuously (headless). */ -export function FleetActivityCapture() { - useRosterCapture(); - return null; +const shorten = (s: string, n = 60): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** Curated map of a member's event-bus topics → feed items. Noisy topics + * (goal.iteration, task.changed, background.progress, watch.*) are skipped. */ +function mapTopic(topic: string, data: Record): { text: string; kind: FleetEventKind } | null { + const str = (v: unknown) => (typeof v === "string" ? v : ""); + switch (topic) { + case "turn.started": + return { text: "is running a turn", kind: "turn" }; + case "turn.finished": + return { text: "finished a turn", kind: "turn" }; + case "activity.message": { + const t = str(data.text) || str(data.message); + return { text: t ? `“${shorten(t)}”` : "posted to Activity", kind: "activity" }; + } + case "inbox.item": + return { text: "received an inbox item", kind: "activity" }; + case "scheduler.fired": { + const n = str(data.name) || str(data.title); + return { text: n ? `ran a scheduled task: ${n}` : "ran a scheduled task", kind: "activity" }; + } + case "goal.achieved": + return { text: "achieved a goal", kind: "activity" }; + case "goal.failed": + return { text: "a goal failed", kind: "offline" }; + case "background.completed": + return { text: "finished background work", kind: "activity" }; + default: + return null; + } +} + +/** Open an SSE stream per ONLINE member (/agents//api/events, via the hub proxy) + * and map its event-bus topics into the feed — this is the fleet-wide "one event log" + * (ADR 0039). Streams open/close as members come online/go offline; a stream that errors + * is dropped and reopened (with a fresh token) on the next roster poll. */ +function useFleetStreams() { + const { data } = useQuery(fleetQuery()); + const streams = useRef>(new Map()); + const opening = useRef>(new Set()); + const seen = useRef>(new Set()); + const nameBySlug = useRef>(new Map()); + + const agents = data?.agents ?? []; + const sig = agents + .filter((a) => a.running) + .map(slugOf) + .sort() + .join(","); + + useEffect(() => { + nameBySlug.current = new Map(agents.map((a) => [slugOf(a), a.name])); + const want = new Set(agents.filter((a) => a.running).map(slugOf)); + + for (const [slug, es] of streams.current) { + if (!want.has(slug)) { + es.close(); + streams.current.delete(slug); + } + } + + const handle = (slug: string, raw: string) => { + let frame: { topic?: string; data?: Record; seq?: number }; + try { + frame = JSON.parse(raw || "{}"); + } catch { + return; + } + if (!frame.topic) return; + if (typeof frame.seq === "number") { + const key = `${slug}:${frame.seq}`; + if (seen.current.has(key)) return; + seen.current.add(key); + if (seen.current.size > 1500) seen.current.clear(); + } + const m = mapTopic(frame.topic, frame.data ?? {}); + if (!m) return; + pushFleetEvent({ source: nameBySlug.current.get(slug) ?? slug, text: m.text, kind: m.kind }); + }; + + const openStream = async (slug: string) => { + if (streams.current.has(slug) || opening.current.has(slug)) return; + opening.current.add(slug); + let token = ""; + try { + token = (await api.sseTokenFor(slug)).token || ""; + } catch { + /* open mode → tokenless */ + } + opening.current.delete(slug); + if (streams.current.has(slug) || typeof EventSource === "undefined") return; + const es = new EventSource(buildEventsUrl(memberPath(slug, "/api/events"), token, null)); + es.onmessage = (e) => handle(slug, (e as MessageEvent).data); + es.onerror = () => { + es.close(); + streams.current.delete(slug); // the next roster poll reopens with a fresh token + }; + streams.current.set(slug, es); + }; + + for (const slug of want) void openStream(slug); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sig]); + + useEffect(() => { + const map = streams.current; + return () => { + for (const es of map.values()) es.close(); + map.clear(); + }; + }, []); } -/** The activity column rendered inside the Fleet Room dialog (right of the roster). */ +/** The activity column rendered inside the Fleet Room dialog (right of the roster). + * Captures WHILE MOUNTED (i.e. while the room is open) — presence transitions + each + * online member's event-bus stream. The module-level store keeps the log across opens, + * and closing the room tears the streams down so we don't hold SSE connections idle. */ export function FleetActivityFeed() { + useRosterCapture(); + useFleetStreams(); const events = useFleetActivity((s) => s.events); return (
diff --git a/apps/web/src/app/fleet-activity.css b/apps/web/src/app/fleet-activity.css index 03a82165..af683ad5 100644 --- a/apps/web/src/app/fleet-activity.css +++ b/apps/web/src/app/fleet-activity.css @@ -94,9 +94,13 @@ .flr-feed__src--removed { color: var(--pl-color-fg-muted); } -.flr-feed__src--broadcast { +.flr-feed__src--broadcast, +.flr-feed__src--turn { color: var(--pl-color-accent); } +.flr-feed__src--activity { + color: var(--pl-color-fg); +} .flr-feed__text { margin: 2px 0 0; font-size: 12.5px; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index fb85e30f..60cabf2a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -778,6 +778,14 @@ export const api = { sseToken() { return request<{ token: string }>("/api/sse-token", { host: true }); }, + // SSE token for a SPECIFIC fleet member (Fleet Activity streams each member's + // /agents//api/events). Best-effort: open-mode instances need none, so a + // failure resolves to "" and the caller connects tokenless. + sseTokenFor(slug: string): Promise<{ token: string }> { + return fetch(memberPath(slug, "/api/sse-token"), { headers: applyAuth(new Headers()) }) + .then((r) => (r.ok ? (r.json() as Promise<{ token: string }>) : { token: "" })) + .catch(() => ({ token: "" })); + }, // Gracefully restart the server process (POST /api/restart) — the server drains and // re-execs; the console reconnects via the boot gate. Always targets the HOST (the From 24e7242c0ec84d3f3d592503cf579297f9ec0dde Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 03:16:31 -0700 Subject: [PATCH 6/6] fix(console): make member turns actually surface in the Fleet Activity feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) Claude-Session: https://claude.ai/code/session_01LyQuxgkFpMUJAQbVXnqz2d --- apps/web/src/app/FleetActivity.tsx | 9 +++++ apps/web/src/lib/api.ts | 53 ++++++++++++++++-------------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/FleetActivity.tsx b/apps/web/src/app/FleetActivity.tsx index bf6484ca..da16683e 100644 --- a/apps/web/src/app/FleetActivity.tsx +++ b/apps/web/src/app/FleetActivity.tsx @@ -80,6 +80,15 @@ const shorten = (s: string, n = 60): string => (s.length > n ? `${s.slice(0, n - function mapTopic(topic: string, data: Record): { text: string; kind: FleetEventKind } | null { const str = (v: unknown) => (typeof v === "string" ? v : ""); switch (topic) { + // A live member turn only bus-pushes `turn.usage` at completion (started/tool frames + // stay on the turn's own SSE, not the event bus) — so this is the "member responded" + // signal for a DM or broadcast. + case "turn.usage": { + const state = str(data.state); + if (state === "failed") return { text: "hit an error on a turn", kind: "offline" }; + if (state === "canceled" || state === "cancelled") return null; + return { text: "finished a turn", kind: "turn" }; + } case "turn.started": return { text: "is running a turn", kind: "turn" }; case "turn.finished": diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 60cabf2a..28b682c9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1495,32 +1495,35 @@ export const api = { fleetDown() { return request<{ ok: boolean; stopped: string[] }>("/api/fleet/down", { method: "POST" }); }, - // Address a specific fleet member from anywhere (Fleet Room, ⌘K). Delivers `message` - // to `slug`'s agent through the hub's per-agent proxy (/agents//api/chat) — the - // NON-streaming chat endpoint, so it works in the browser and the desktop WKWebView - // shell alike (no SSE body to read). `slug` is the member's id, "host" for this - // instance. The turn runs durably on the member and its reply lands in that member's - // own chat session (`session_id`); callers fire-and-forget for a broadcast, or await - // the promise for the single-send reply text. Bypasses request()/apiUrl() on purpose: - // apiUrl routes to the CURRENT window's slug, and this targets an arbitrary member. - sendToAgent(slug: string, message: string, sessionId?: string): Promise<{ response?: string }> { - const url = memberPath(slug, "/api/chat"); - return fetch(url, { + // Fire a one-shot message at a specific fleet member (Fleet Room broadcast). Goes to the + // member's A2A endpoint through the hub proxy (/agents//a2a) — NOT /api/chat — + // because the streaming A2A turn publishes `turn.usage` to the member's event bus, which + // is what surfaces " finished a turn" in the activity feed (the non-streaming + // /api/chat path publishes nothing). Fire-and-forget: the A2A task is durable, so we send + // the request, then cancel the response stream — the turn keeps running server-side and + // emits its bus event. `slug` is the member id, "host" for this instance. + sendToAgent(slug: string, message: string, sessionId?: string): Promise { + const rpcId = `flr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const body = { + jsonrpc: "2.0", + id: rpcId, + method: "SendStreamingMessage", + params: { + message: { + role: "ROLE_USER", + parts: [{ text: message }], + messageId: rpcId, + contextId: sessionId ?? `fleet-room-${Date.now()}`, + }, + }, + }; + return fetch(memberPath(slug, "/a2a"), { method: "POST", - headers: applyAuth(new Headers({ "Content-Type": "application/json" })), - body: JSON.stringify({ message, session_id: sessionId ?? `fleet-room-${Date.now()}` }), - }).then(async (res) => { - if (!res.ok) { - let detail = `${res.status} ${res.statusText}`; - try { - const p = (await res.json()) as { detail?: string }; - if (p?.detail) detail = p.detail; - } catch { - /* keep status text */ - } - throw new Error(detail); - } - return (await res.json()) as { response?: string }; + headers: applyAuth(new Headers({ "Content-Type": "application/json", "A2A-Version": "1.0" })), + body: JSON.stringify(body), + }).then((res) => { + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + void res.body?.cancel().catch(() => {}); // durable task runs on + emits turn.usage }); },