From aa196202f17e60b4646bd1b9cb97577a8422ff43 Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Fri, 3 Jul 2026 04:19:27 +0100 Subject: [PATCH 1/2] feat(fleet): agent fleet view, fleet strip, agent-started event Make every running agent visible at a glance (overnight UX program PR 6, mockups C1 + C2). core/app: - Add `model: Option` to `AgentRun`, threaded from `SpawnConfig` (recorded by `with_model`); regenerate the ts-rs binding as `string | null`. - Emit a new `"agent-started"` Tauri event once per spawn from the single `start_stream_forwarding` choke point (covers Fix/Review/Plan/Implement/ Restack), payload `{ object_id, mode, pid, started_at_epoch_ms, model }`. frontend: - `lib/fleet.ts`: pure, tested roster derivation from the reviews/projects the store already holds (no polling, no list command); plan agents key by the project's ProjectId (== agent object_id) via `project.plan`. - Agents view rebuilt with Active | Prompts sub-tabs; Active shows the live roster (pulsing amber LED, mode tag, ref+branch, model, ticking mm:ss, last action, View/Stop) + a session-observed Recent (last 24h) section enriched with trajectory summaries; Prompts mounts the existing AgentEditor unchanged. - Always-on `FleetStrip` rendered outside the view switch (visible everywhere, hidden when idle); amber Agents sidebar badge; capacity chip at max. - Global `agent-event` listener feeds an `agentActivity` store slice (roster "last action"); `agent-started` seeds it and refreshes lists; `agent-completed` accumulates Recent. View deep-links via a typed `pendingAgentFocus` hint that lands ReviewWorkspace on the Agent tab. Co-Authored-By: Claude Fable 5 --- app/src-tauri/src/streaming.rs | 58 +++ app/src/App.tsx | 83 ++++- app/src/bindings/AgentRun.ts | 12 +- app/src/components/AgentsView.tsx | 333 ++++++++++++++++++ app/src/components/FleetStrip.tsx | 107 ++++++ app/src/components/ReviewWorkspace.tsx | 15 + app/src/components/Sidebar.tsx | 19 +- app/src/components/fleet-bits.tsx | 48 +++ app/src/hooks/useFocusAgent.ts | 29 ++ app/src/hooks/useNowTick.ts | 27 ++ app/src/lib/agent-event.test.ts | 19 + app/src/lib/agent-event.ts | 14 + app/src/lib/agent-mode.test.ts | 41 +++ app/src/lib/agent-mode.ts | 57 +++ app/src/lib/fleet.test.ts | 178 ++++++++++ app/src/lib/fleet.ts | 164 +++++++++ app/src/store.test.ts | 69 ++++ app/src/store.ts | 119 +++++++ app/src/test/fixtures.ts | 1 + crates/cockpit-core/src/adapters/agent.rs | 19 + crates/cockpit-core/src/gate.rs | 1 + crates/cockpit-core/src/model.rs | 9 + crates/cockpit-core/src/restack.rs | 3 + crates/cockpit-core/src/store.rs | 1 + .../cockpit-core/tests/batch_fan_out_e2e.rs | 2 + crates/cockpit-core/tests/e2e_round_trip.rs | 1 + crates/cockpit-core/tests/fix_loop_e2e.rs | 2 + 27 files changed, 1427 insertions(+), 4 deletions(-) create mode 100644 app/src/components/AgentsView.tsx create mode 100644 app/src/components/FleetStrip.tsx create mode 100644 app/src/components/fleet-bits.tsx create mode 100644 app/src/hooks/useFocusAgent.ts create mode 100644 app/src/hooks/useNowTick.ts create mode 100644 app/src/lib/agent-mode.test.ts create mode 100644 app/src/lib/agent-mode.ts create mode 100644 app/src/lib/fleet.test.ts create mode 100644 app/src/lib/fleet.ts diff --git a/app/src-tauri/src/streaming.rs b/app/src-tauri/src/streaming.rs index ee6eac7..0100970 100644 --- a/app/src-tauri/src/streaming.rs +++ b/app/src-tauri/src/streaming.rs @@ -42,6 +42,58 @@ pub struct AgentEventEnvelope { pub event: agent_stream::Event, } +/// Payload emitted on the `"agent-started"` Tauri event the moment a spawned +/// agent is wired up for streaming (i.e. attached to its reviewed object). +/// +/// The frontend derives the agent fleet from the reviews/plans it already holds; +/// this event is the push signal that a new agent exists so the roster and fleet +/// strip refresh without polling. Hand-typed on the frontend (no `ts-rs`), same +/// as the sibling `"agent-completed"` payload. `mode` serializes to the same +/// bare string as the ts-rs `AgentMode` union, so the frontend types it as +/// `AgentMode`. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AgentStartedPayload { + /// UI key of the object the agent runs on: a review's PR ref, or a project + /// id for plan agents. + pub object_id: String, + /// Which mode the spawned agent runs in. + pub mode: AgentMode, + /// OS process id of the spawned agent. + pub pid: u32, + /// Wall-clock spawn time in epoch milliseconds. + pub started_at_epoch_ms: u64, + /// The model override the agent runs on, or `None` for the CLI default. + pub model: Option, +} + +/// Emit an [`AgentStartedPayload`] on the `"agent-started"` channel. +/// +/// Best-effort: if no frontend window is listening, the event is dropped. Fired +/// once per spawn from [`start_stream_forwarding`] — the single choke point every +/// spawned agent passes through — so every mode (Fix / Review / Plan / Implement +/// / Restack) is covered exactly once. +fn emit_agent_started( + app_handle: &tauri::AppHandle, + object_id: &str, + mode: AgentMode, + run: &cockpit_core::model::AgentRun, +) { + use tauri::Emitter; + let started_at_epoch_ms = run + .started_at + .duration_since(UNIX_EPOCH) + .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0); + let payload = AgentStartedPayload { + object_id: object_id.to_string(), + mode, + pid: run.pid, + started_at_epoch_ms, + model: run.model.clone(), + }; + let _ = app_handle.emit("agent-started", &payload); +} + /// Emit a parsed agent [`Event`](agent_stream::Event) wrapped in an /// [`AgentEventEnvelope`] on the `"agent-event"` channel, keyed by `object_id`. /// @@ -262,6 +314,12 @@ pub fn start_stream_forwarding( let agent_run = spawn_result.run.clone(); let log_path = spawn_result.log_path.clone(); + // Push the "a new agent exists" signal to the frontend at the moment the run + // is wired up, so the fleet roster/strip surface it without polling. The + // caller attaches this run to its reviewed object synchronously right after + // this returns, so a listener's refetch observes the attached agent. + emit_agent_started(&app_handle, &ctx.object_id, ctx.mode, &agent_run); + tauri::async_runtime::spawn(async move { let (saw_error, accumulator) = stream_agent_output( &mut spawn_result.child, diff --git a/app/src/App.tsx b/app/src/App.tsx index 585c554..9867c62 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -17,7 +17,8 @@ import { ReviewWorkspace } from "./components/ReviewWorkspace"; import { PlanView } from "./components/PlanView"; import { NewProjectView } from "./components/NewProjectView"; import { SkillsView } from "./components/SkillsView"; -import { AgentEditor } from "./components/AgentEditor"; +import { AgentsView } from "./components/AgentsView"; +import { FleetStrip } from "./components/FleetStrip"; import { SettingsView } from "./components/SettingsView"; import { PermissionBanner } from "./components/PermissionBanner"; import { CommandPalette } from "./components/CommandPalette"; @@ -41,11 +42,14 @@ import { import { cn } from "@/lib/utils"; import { buildBoardItems } from "./lib/stack-tree"; import { isFastLane } from "./lib/attention"; +import { collectFleetReviews } from "./lib/fleet"; +import { agentActionLine } from "./lib/agent-event"; import type { CardDensity } from "./components/ReviewCard"; import type { GateState } from "./bindings/GateState"; import type { Review } from "./bindings/Review"; import type { Project } from "./bindings/Project"; import type { AgentMode } from "./bindings/AgentMode"; +import type { Event as AgentStreamEvent } from "./bindings/Event"; /** Payload emitted by the Tauri backend on "agent-completed" events. */ interface CompletionEventPayload { @@ -61,6 +65,21 @@ interface CompletionEventPayload { readonly outcome?: "reworked" | "failed" | "completed"; } +/** Payload emitted by the backend on `"agent-started"` (hand-typed, no ts-rs). */ +interface AgentStartedPayload { + readonly object_id: string; + readonly mode: AgentMode; + readonly pid: number; + readonly started_at_epoch_ms: number; + readonly model: string | null; +} + +/** Envelope wrapping an agent stream event with its object key (`"agent-event"`). */ +interface AgentEventEnvelope { + readonly object_id: string; + readonly event: AgentStreamEvent; +} + type ReviewTab = "my-prs" | "review-requests" | "all"; const SIDEBAR_COLLAPSED_KEY = "cockpit-sidebar-collapsed"; @@ -363,6 +382,16 @@ function App() { const settledLocally = isReviewMode(mode) && alreadySettledLocally(object_id); + // Record the completion into the session-observed "Recent" buffer the + // fleet roster reads (see RecentCompletion for why this is session-based). + useAppStore.getState().recordCompletion({ + objectId: object_id, + kind: isReviewMode(mode) ? "review" : "plan", + mode, + outcome: outcome ?? null, + endedAtEpochMs: Date.now(), + }); + void fetchReviews(); void listProjects(); // Refresh the open plan (if any) so a completed Plan agent updates it. @@ -408,6 +437,36 @@ function App() { })(); }); + // Fleet: a new agent was spawned somewhere (pre-review / rework / restack / + // plan / batch). Seed an initial action line so its roster row shows + // something immediately, then refresh the lists/plans so the newly-attached + // agent surfaces in the roster and strip without polling. + const unlistenAgentStarted = listen( + "agent-started", + (event) => { + const { object_id, started_at_epoch_ms } = event.payload; + useAppStore + .getState() + .recordAgentAction(object_id, "Starting…", started_at_epoch_ms); + void fetchReviews(); + void listProjects(); + }, + ); + + // Fleet: one global agent-event listener multiplexes every object's stream + // into the `agentActivity` slice (the roster's "last action"). This is + // separate from the per-review AgentPanel's own listener, which keeps its + // full timeline buffers unchanged. + const unlistenAgentEvent = listen( + "agent-event", + (tauriEvent) => { + const { object_id, event } = tauriEvent.payload; + useAppStore + .getState() + .recordAgentAction(object_id, agentActionLine(event), Date.now()); + }, + ); + // D4: the notify poller emits `board-updated` (empty payload) when it detects // external changes (new review requests, CI going green, new commits). // Refresh the review lists so the board reflects them. The backend owns the @@ -461,6 +520,12 @@ function App() { void unlisten.then((f) => { f(); }); + void unlistenAgentStarted.then((f) => { + f(); + }); + void unlistenAgentEvent.then((f) => { + f(); + }); void unlistenBoard.then((f) => { f(); }); @@ -512,6 +577,15 @@ function App() { [stateFilter, showStale, searchQuery], ); + // Live count of running agents for the sidebar badge and empty-state logic. + // Derived (no polling) from the same lists the fleet roster reads. + const agentCount = useMemo(() => { + const merged = collectFleetReviews(reviews, authoredPrs, reviewRequests); + const reviewAgents = merged.filter((r) => r.agent !== null).length; + const planAgents = projects.filter((p) => p.plan?.agent != null).length; + return reviewAgents + planAgents; + }, [reviews, authoredPrs, reviewRequests, projects]); + const reviewsForTab: readonly Review[] = useMemo(() => { switch (reviewTab) { case "my-prs": @@ -971,7 +1045,7 @@ function App() { return ; case "agents": - return ; + return ; case "settings": return ; @@ -987,6 +1061,7 @@ function App() {
{renderContent()}
+ {/* The fleet strip lives outside the view switch so it is visible on + every view (including inside the review workspace); it hides itself + entirely when no agent is running. */} + void; + readonly onStop: (agent: FleetAgent) => void; +}) { + const elapsed = formatElapsedClock(now - agent.startedAtEpochMs); + return ( +
+ + + +
+ + {agent.ref} + + {agent.branch !== "" && ( + + {agent.branch} + + )} +
+ + + {agent.model ?? "—"} + + + + {elapsed} + + + + {agent.lastAction ?? "…"} + + +
+ + {agent.kind === "review" && ( + + )} +
+
+ ); +} + +/** Capacity chip: amber `AT CAPACITY n/max` dot+label, shown only at capacity. */ +function CapacityChip({ + active, + max, +}: { + readonly active: number; + readonly max: number; +}) { + return ( + + + At capacity {active}/{max} + + ); +} + +/** Outcome label + tone for a recent completion. */ +function outcomeText(outcome: RecentCompletion["outcome"]): { + readonly label: string; + readonly className: string; +} { + switch (outcome) { + case "reworked": + return { label: "reworked", className: "text-state-approved" }; + case "completed": + return { label: "completed", className: "text-state-approved" }; + case "failed": + return { label: "failed", className: "text-danger" }; + case null: + return { label: "done", className: "text-muted-foreground" }; + default: + return { label: "done", className: "text-muted-foreground" }; + } +} + +/** + * One row in the Recent (last 24h) section. + * + * Enriches the session-observed completion with its persisted + * {@link TrajectorySummary}, fetched lazily on mount — bounded to the ≤10 rows + * actually shown, never fanned out across every review. A missing summary just + * omits the duration/tools detail. + */ +function RecentRow({ completion }: { readonly completion: RecentCompletion }) { + const fetchTrajectorySummary = useAppStore((s) => s.fetchTrajectorySummary); + const [summary, setSummary] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchTrajectorySummary(completion.objectId).then((s) => { + if (!cancelled) setSummary(s); + }); + return () => { + cancelled = true; + }; + }, [completion.objectId, fetchTrajectorySummary]); + + const ref = + completion.kind === "review" + ? shortPrRef(completion.objectId) + : completion.objectId; + const endedAgo = elapsedSince({ + secs_since_epoch: Math.floor(completion.endedAtEpochMs / 1000), + }); + const outcome = outcomeText(completion.outcome); + return ( +
+ + {ref} + + {outcome.label} + + {summary !== null && ( + + {formatElapsedClock(summary.duration_ms)} · {summary.tools_used} tools + + )} + + {endedAgo} ago + +
+ ); +} + +/** Milliseconds in a 24-hour window; the Recent section's horizon. */ +const RECENT_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Upper bound on rows shown in the Recent section. */ +const RECENT_CAP = 10; + +/** The Active roster + Recent section (and empty state). */ +function ActiveFleet() { + const reviews = useAppStore((s) => s.reviews); + const authoredPrs = useAppStore((s) => s.authoredPrs); + const reviewRequests = useAppStore((s) => s.reviewRequests); + const projects = useAppStore((s) => s.projects); + const agentActivity = useAppStore((s) => s.agentActivity); + const config = useAppStore((s) => s.config); + const recentCompletions = useAppStore((s) => s.recentCompletions); + const killAgent = useAppStore((s) => s.killAgent); + const focusAgent = useFocusAgent(); + + const fleet = useMemo(() => { + const merged = collectFleetReviews(reviews, authoredPrs, reviewRequests); + return deriveFleet(merged, projects, agentActivity); + }, [reviews, authoredPrs, reviewRequests, projects, agentActivity]); + + // Single shared clock for the whole roster; ticks only while agents run. + const now = useNowTick(fleet.length > 0); + + const recent = useMemo(() => { + const cutoff = Date.now() - RECENT_WINDOW_MS; + return recentCompletions + .filter((c) => c.endedAtEpochMs >= cutoff) + .slice(0, RECENT_CAP); + }, [recentCompletions]); + + const max = config?.max_parallel_agents ?? 0; + const atCapacity = isAtCapacity(fleet.length, max); + + const handleStop = useCallback( + (agent: FleetAgent) => { + // Stop is destructive (kills a live process), so confirm — mirroring the + // AgentPanel Stop control. killAgent only supports reviews. + const confirmed = window.confirm( + `Stop the agent working on ${agent.ref}?`, + ); + if (!confirmed) return; + void killAgent(agent.objectId); + }, + [killAgent], + ); + + return ( +
+
+

+ Agents +

+ + {fleet.length} active + + {atCapacity && } +
+ + {fleet.length === 0 ? ( +
+ +

+ No agents running +

+

+ Pre-review, rework, restack, and plan spawns appear here the moment + they start — one row per running agent. +

+
+ ) : ( +
+ {fleet.map((agent) => ( + + ))} +
+ )} + + {recent.length > 0 && ( +
+

+ Recent + + (last 24h) + +

+
+ {recent.map((completion) => ( + + ))} +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// AgentsView (sub-tab shell) +// --------------------------------------------------------------------------- + +type AgentsTab = "active" | "prompts"; + +/** Narrow an unknown tab value to an {@link AgentsTab}, or null. */ +function toAgentsTab(value: unknown): AgentsTab | null { + return value === "active" || value === "prompts" ? value : null; +} + +export function AgentsView() { + const [tab, setTab] = useState("active"); + + const handleTabChange = useCallback((value: unknown) => { + const next = toAgentsTab(value); + if (next !== null) setTab(next); + }, []); + + return ( + +
+ + Active + Prompts + +
+ + + + + + +
+ ); +} diff --git a/app/src/components/FleetStrip.tsx b/app/src/components/FleetStrip.tsx new file mode 100644 index 0000000..07d4a7a --- /dev/null +++ b/app/src/components/FleetStrip.tsx @@ -0,0 +1,107 @@ +/** + * Always-on fleet strip (mockup C2). + * + * A slim bottom bar rendered outside the view switch in `App`, so it stays + * visible on every view — including inside the review workspace — while at least + * one agent runs. It disappears entirely when the fleet is idle. Each running + * agent is a compact, keyboard-accessible chip; clicking one deep-links to that + * agent's live activity via {@link useFocusAgent}. + */ + +import { useMemo } from "react"; +import { Sparkles } from "lucide-react"; +import { useAppStore } from "../store"; +import { useNowTick } from "@/hooks/useNowTick"; +import { useFocusAgent } from "@/hooks/useFocusAgent"; +import { + collectFleetReviews, + deriveFleet, + formatElapsedClock, + isAtCapacity, + type FleetAgent, +} from "@/lib/fleet"; +import { FleetLed, ModeTag } from "./fleet-bits"; + +/** One compact agent chip in the strip. */ +function FleetChip({ + agent, + now, + onFocus, +}: { + readonly agent: FleetAgent; + readonly now: number; + readonly onFocus: (agent: FleetAgent) => void; +}) { + const elapsed = formatElapsedClock(now - agent.startedAtEpochMs); + return ( + + ); +} + +export function FleetStrip() { + const reviews = useAppStore((s) => s.reviews); + const authoredPrs = useAppStore((s) => s.authoredPrs); + const reviewRequests = useAppStore((s) => s.reviewRequests); + const projects = useAppStore((s) => s.projects); + const agentActivity = useAppStore((s) => s.agentActivity); + const config = useAppStore((s) => s.config); + const focusAgent = useFocusAgent(); + + const fleet = useMemo(() => { + const merged = collectFleetReviews(reviews, authoredPrs, reviewRequests); + return deriveFleet(merged, projects, agentActivity); + }, [reviews, authoredPrs, reviewRequests, projects, agentActivity]); + + // One shared 1s clock for the whole strip; only tick while it is shown. + const now = useNowTick(fleet.length > 0); + + // Idle fleet: the strip disappears entirely (locked design decision). + if (fleet.length === 0) return null; + + const max = config?.max_parallel_agents ?? 0; + const atCapacity = isAtCapacity(fleet.length, max); + + return ( +
+ + + Agents + {fleet.length} + + +
+ {fleet.map((agent) => ( + + ))} +
+ + {atCapacity && ( + + + At capacity {fleet.length}/{max} + + )} +
+ ); +} diff --git a/app/src/components/ReviewWorkspace.tsx b/app/src/components/ReviewWorkspace.tsx index 77b6c33..775e24c 100644 --- a/app/src/components/ReviewWorkspace.tsx +++ b/app/src/components/ReviewWorkspace.tsx @@ -138,6 +138,7 @@ export function ReviewWorkspace({ const clearError = useAppStore((s) => s.clearError); const ensureReviewWorktree = useAppStore((s) => s.ensureReviewWorktree); const requestDiffJump = useAppStore((s) => s.requestDiffJump); + const pendingAgentFocus = useAppStore((s) => s.pendingAgentFocus); // Re-land on the default tab when the opened review changes (the component is // reused across reviews, so the mount-time initial state alone is not enough). @@ -189,6 +190,20 @@ export function ReviewWorkspace({ prevGateRef.current = gateState; }, [gateState]); + // Land on the Agent tab when the fleet roster/strip requests focus for this + // review (the store hint mirrors pendingDiffJump). Keyed on the nonce so a + // repeat "View" for the review already open still re-lands on Agent. Defined + // after the per-PR re-land effect so it runs last and wins on a navigation + // that changes the PR and the focus request in the same commit. + const agentFocusNonceRef = useRef(0); + useEffect(() => { + if (pendingAgentFocus === null) return; + if (pendingAgentFocus.pr !== review.pr) return; + if (pendingAgentFocus.nonce === agentFocusNonceRef.current) return; + agentFocusNonceRef.current = pendingAgentFocus.nonce; + setActiveTab("agent"); + }, [pendingAgentFocus, review.pr]); + const workspaceShortcuts: ShortcutMap = useMemo( () => ({ "meta+j": toggleAgentTab, diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 87d00ae..fc5a155 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -26,16 +26,22 @@ type NavKind = ViewState["kind"]; interface SidebarProps { readonly activeView: NavKind; readonly reviewCount: number; + /** Live count of running agents; drives the amber Agents badge. */ + readonly agentCount: number; readonly onNavigate: (kind: NavKind) => void; readonly collapsed?: boolean | undefined; readonly onToggleCollapse?: (() => void) | undefined; } +/** Visual tone for a nav badge: muted (counts) or amber (live agents). */ +type BadgeTone = "muted" | "agent"; + interface NavItemProps { readonly label: string; readonly icon: React.ReactNode; readonly active: boolean; readonly badge?: string | undefined; + readonly badgeTone?: BadgeTone | undefined; readonly shortcut?: ShortcutId | undefined; readonly collapsed?: boolean | undefined; readonly onClick: () => void; @@ -46,6 +52,7 @@ function NavItem({ icon, active, badge, + badgeTone = "muted", shortcut, collapsed, onClick, @@ -68,7 +75,14 @@ function NavItem({ <> {label} {badge !== undefined && ( - + {badge} )} @@ -95,6 +109,7 @@ function NavItem({ export function Sidebar({ activeView, reviewCount, + agentCount, onNavigate, collapsed = false, onToggleCollapse, @@ -195,6 +210,8 @@ export function Sidebar({ label="Agents" icon={} active={activeView === "agents"} + badge={agentCount > 0 ? String(agentCount) : undefined} + badgeTone="agent" shortcut="nav-agents" collapsed={collapsed} onClick={() => { diff --git a/app/src/components/fleet-bits.tsx b/app/src/components/fleet-bits.tsx new file mode 100644 index 0000000..f3b3355 --- /dev/null +++ b/app/src/components/fleet-bits.tsx @@ -0,0 +1,48 @@ +/** + * Small presentational atoms shared by the fleet roster ({@link AgentsView}) + * and the always-on {@link FleetStrip}: the pulsing "running" LED and the mode + * tag chip. Kept together so both surfaces read identically. + */ + +import type { AgentMode } from "../bindings/AgentMode"; +import { modeTagClass, modeTagLabel } from "@/lib/agent-mode"; +import { cn } from "@/lib/utils"; + +/** + * The running-agent LED: a steady amber dot with a pulsing ring. Every fleet + * agent is by definition running, so the ring always pulses — but it honors + * `prefers-reduced-motion` (the ring is hidden, the dot stays), matching the + * convention in `ReviewCard` / `AgentPanel`. + */ +export function FleetLed({ className }: { readonly className?: string }) { + return ( +