From 41915b1a3325657efc4f3080527411b3ff7f095d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:10:09 +0000 Subject: [PATCH] feat(app): headless run event stream on the agent detail screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Events tab between Checkmark and Log: while the detail (or answer) screen is open the store polls the cursor-paged /agents/{name}/events endpoint every 3s and renders the stream-json events the way the web dashboard does — assistant text as prose, tool calls as badges, results as a turns/cost footer, worker notices as danger callouts, raw lines verbatim. The meta card now also shows the agent's model backend and supervising worker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48 --- app/src/components/EventLine.tsx | 138 ++++++++++++++++++++++++++ app/src/screens/AgentDetailScreen.tsx | 32 ++++++ app/src/state/AppState.tsx | 45 ++++++++- 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 app/src/components/EventLine.tsx diff --git a/app/src/components/EventLine.tsx b/app/src/components/EventLine.tsx new file mode 100644 index 0000000..2b43de6 --- /dev/null +++ b/app/src/components/EventLine.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { radius, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Badge } from "./Badge"; +import { Mono } from "./primitives"; +import type { AgentEvent } from "../api/client"; + +/** + * One stream-json event of a headless run, rendered by type: assistant text as + * prose, tool calls as badges, the result as a cost/turns footer, worker notices + * as danger callouts, raw lines verbatim. RN port of the web dashboard's + * EventLine (frontend RunsSection). + */ +export function EventLine({ event }: { event: AgentEvent }) { + const { colors } = useTheme(); + const p = (event.payload ?? {}) as Record; + + if (event.type === "system") { + return ( + + ▸ session {p.subtype ?? "event"} + {p.session_id ? ` · ${String(p.session_id).slice(0, 8)}` : ""} + {Array.isArray(p.tools) ? ` · ${p.tools.length} tools` : ""} + + ); + } + + if (event.type === "assistant") { + const content = p.message?.content; + const blocks: any[] = Array.isArray(content) ? content : []; + const prose = blocks + .filter((b) => b?.type === "text" && b.text) + .map((b) => b.text) + .join("\n"); + const tools = blocks.filter((b) => b?.type === "tool_use"); + return ( + + {prose ? ( + {prose} + ) : null} + {tools.length > 0 ? ( + + {tools.map((t, i) => ( + + {`${t.name}${t.input ? `: ${oneLine(t.input)}` : ""}`} + + ))} + + ) : null} + + ); + } + + if (event.type === "result") { + const err = Boolean(p.is_error); + const meta = [ + p.num_turns != null ? `${p.num_turns} turns` : "", + p.total_cost_usd != null ? `$${Number(p.total_cost_usd).toFixed(4)}` : "", + ] + .filter(Boolean) + .join(" · "); + return ( + + + + {err ? "run errored" : "run finished"} + + {meta ? ( + {meta} + ) : null} + + {typeof p.result === "string" && p.result ? ( + {p.result} + ) : null} + + ); + } + + if (event.type === "worker") { + return ( + + + {p.notice ?? "runner notice"} + + {p.stderr_tail ? ( + + {p.stderr_tail} + + ) : null} + + ); + } + + if (event.type === "raw") { + return ( + + {typeof p.line === "string" ? p.line.trimEnd() : JSON.stringify(p)} + + ); + } + + // user (tool results) and anything future: a quiet one-liner, nothing lost, no noise. + return ( + ▸ {event.type} + ); +} + +/* Compact single-line preview of a tool_use input object. */ +function oneLine(input: unknown): string { + const s = + typeof input === "string" + ? input + : (input as Record)?.command + ? String((input as Record).command) + : JSON.stringify(input); + return s.length > 60 ? `${s.slice(0, 57)}…` : s; +} + +const styles = StyleSheet.create({ + xs: { fontSize: 12, lineHeight: 18 }, + toolRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + alignItems: "center", + }, + notice: { + borderWidth: 1, + borderRadius: radius.md, + padding: 10, + }, +}); diff --git a/app/src/screens/AgentDetailScreen.tsx b/app/src/screens/AgentDetailScreen.tsx index 55aac94..6fb7837 100644 --- a/app/src/screens/AgentDetailScreen.tsx +++ b/app/src/screens/AgentDetailScreen.tsx @@ -4,6 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { fonts, radius, text } from "../theme/tokens"; import { useTheme } from "../theme/useTheme"; import { Button } from "../components/Button"; +import { EventLine } from "../components/EventLine"; import { PageHeader } from "../components/PageHeader"; import { SegmentedControl } from "../components/SegmentedControl"; import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; @@ -24,9 +25,11 @@ export function AgentDetailScreen() { openAnswer, detailTab, setDetailTab, + models, selectedAgent, selectedCheckmark, selectedLog, + selectedEvents, kill, } = useAppState(); @@ -48,9 +51,16 @@ export function AgentDetailScreen() { const cm = selectedCheckmark; const openQuestion = cm?.open_question?.trim(); + const modelName = + agent.model_id != null + ? models.find((m) => m.id === agent.model_id)?.name ?? `#${agent.model_id}` + : "claude"; + const meta = [ { label: "Started", value: timeAgo(agent.created_at) }, { label: "Status", value: statusLabel(agent.status) }, + { label: "Model", value: modelName }, + ...(agent.worker_id ? [{ label: "Worker", value: agent.worker_id }] : []), { label: "Tests", value: cm ? statusLabel(cm.tests_status) : "—" }, { label: "Build", value: cm ? statusLabel(cm.build_status) : "—" }, ]; @@ -101,6 +111,7 @@ export function AgentDetailScreen() { + ) : detailTab === "events" ? ( + + {selectedEvents.length === 0 ? ( + + {agent.session_id + ? "No run events yet." + : "No event stream — this agent predates the headless runner."} + + ) : ( + + {selectedEvents.map((e) => ( + + ))} + + )} + ) : ( Promise<{ resumed: boolean; note?: string }>; @@ -375,6 +379,43 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { return rows; }, [agentsByProject, logsByAgent]); + // ---- Selected agent's run events ---------------------------------------- + // Cursor-paged poll (after_id = largest id seen) on a 3s cadence, active only + // while the detail or answer screen is showing an agent. Selection change resets + // the stream; the cap keeps a very chatty run from growing without bound. + const [selectedEvents, setSelectedEvents] = useState([]); + useEffect(() => { + setSelectedEvents([]); + if (!client || !selected || (screen !== "detail" && screen !== "answer")) { + return; + } + const { project, name } = selected; + let cursor = 0; + let stopped = false; + + async function poll() { + if (!client) return; + try { + const batch = await client.api( + `/projects/${enc(project)}/agents/${enc(name)}/events?after_id=${cursor}&limit=200`, + ); + if (stopped || batch.length === 0) return; + cursor = batch[batch.length - 1].id; + setSelectedEvents((prev) => [...prev, ...batch].slice(-400)); + } catch { + // AuthError signs out via onUnauthorized; anything else (a 404 from an + // older server, a blip) just skips this cycle. + } + } + + void poll(); + const id = setInterval(() => void poll(), 3000); + return () => { + stopped = true; + clearInterval(id); + }; + }, [client, selected, screen]); + // ---- Selected agent ------------------------------------------------------ const selectedAgent = useMemo(() => { if (!selected) return null; @@ -489,6 +530,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { selectedAgent, selectedCheckmark, selectedLog, + selectedEvents, sendAnswer, spawn, @@ -512,6 +554,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { selectedAgent, selectedCheckmark, selectedLog, + selectedEvents, sendAnswer, spawn, kill,