mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:36:24 +00:00
feat(app): headless run event stream on the agent detail screen
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
@@ -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<string, any>;
|
||||||
|
|
||||||
|
if (event.type === "system") {
|
||||||
|
return (
|
||||||
|
<Mono style={[styles.xs, { color: colors.textMuted }]}>
|
||||||
|
▸ session {p.subtype ?? "event"}
|
||||||
|
{p.session_id ? ` · ${String(p.session_id).slice(0, 8)}` : ""}
|
||||||
|
{Array.isArray(p.tools) ? ` · ${p.tools.length} tools` : ""}
|
||||||
|
</Mono>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
{prose ? (
|
||||||
|
<Text style={[text.bodySm, { color: colors.textBody }]}>{prose}</Text>
|
||||||
|
) : null}
|
||||||
|
{tools.length > 0 ? (
|
||||||
|
<View style={styles.toolRow}>
|
||||||
|
{tools.map((t, i) => (
|
||||||
|
<Badge key={i} tone="neutral">
|
||||||
|
{`${t.name}${t.input ? `: ${oneLine(t.input)}` : ""}`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<View style={styles.toolRow}>
|
||||||
|
<Badge tone={err ? "danger" : "positive"}>
|
||||||
|
{err ? "run errored" : "run finished"}
|
||||||
|
</Badge>
|
||||||
|
{meta ? (
|
||||||
|
<Mono style={[styles.xs, { color: colors.textMuted }]}>{meta}</Mono>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{typeof p.result === "string" && p.result ? (
|
||||||
|
<Text style={[text.caption, { color: colors.textMuted }]}>{p.result}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "worker") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.notice,
|
||||||
|
{ backgroundColor: colors.dangerTint, borderColor: colors.danger },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[text.bodySm, { color: colors.danger }]}>
|
||||||
|
{p.notice ?? "runner notice"}
|
||||||
|
</Text>
|
||||||
|
{p.stderr_tail ? (
|
||||||
|
<Mono style={[styles.xs, { color: colors.danger, marginTop: 4 }]}>
|
||||||
|
{p.stderr_tail}
|
||||||
|
</Mono>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "raw") {
|
||||||
|
return (
|
||||||
|
<Mono style={[styles.xs, { color: colors.textMuted }]}>
|
||||||
|
{typeof p.line === "string" ? p.line.trimEnd() : JSON.stringify(p)}
|
||||||
|
</Mono>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// user (tool results) and anything future: a quiet one-liner, nothing lost, no noise.
|
||||||
|
return (
|
||||||
|
<Mono style={[styles.xs, { color: colors.textMuted }]}>▸ {event.type}</Mono>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact single-line preview of a tool_use input object. */
|
||||||
|
function oneLine(input: unknown): string {
|
||||||
|
const s =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: (input as Record<string, unknown>)?.command
|
||||||
|
? String((input as Record<string, unknown>).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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { fonts, radius, text } from "../theme/tokens";
|
import { fonts, radius, text } from "../theme/tokens";
|
||||||
import { useTheme } from "../theme/useTheme";
|
import { useTheme } from "../theme/useTheme";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
|
import { EventLine } from "../components/EventLine";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { SegmentedControl } from "../components/SegmentedControl";
|
import { SegmentedControl } from "../components/SegmentedControl";
|
||||||
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
import { Card, Divider, Mono, SectionLabel } from "../components/primitives";
|
||||||
@@ -24,9 +25,11 @@ export function AgentDetailScreen() {
|
|||||||
openAnswer,
|
openAnswer,
|
||||||
detailTab,
|
detailTab,
|
||||||
setDetailTab,
|
setDetailTab,
|
||||||
|
models,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
selectedCheckmark,
|
selectedCheckmark,
|
||||||
selectedLog,
|
selectedLog,
|
||||||
|
selectedEvents,
|
||||||
kill,
|
kill,
|
||||||
} = useAppState();
|
} = useAppState();
|
||||||
|
|
||||||
@@ -48,9 +51,16 @@ export function AgentDetailScreen() {
|
|||||||
const cm = selectedCheckmark;
|
const cm = selectedCheckmark;
|
||||||
const openQuestion = cm?.open_question?.trim();
|
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 = [
|
const meta = [
|
||||||
{ label: "Started", value: timeAgo(agent.created_at) },
|
{ label: "Started", value: timeAgo(agent.created_at) },
|
||||||
{ label: "Status", value: statusLabel(agent.status) },
|
{ 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: "Tests", value: cm ? statusLabel(cm.tests_status) : "—" },
|
||||||
{ label: "Build", value: cm ? statusLabel(cm.build_status) : "—" },
|
{ label: "Build", value: cm ? statusLabel(cm.build_status) : "—" },
|
||||||
];
|
];
|
||||||
@@ -101,6 +111,7 @@ export function AgentDetailScreen() {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
segments={[
|
segments={[
|
||||||
{ value: "state", label: "Checkmark" },
|
{ value: "state", label: "Checkmark" },
|
||||||
|
{ value: "events", label: "Events" },
|
||||||
{ value: "log", label: "Log" },
|
{ value: "log", label: "Log" },
|
||||||
]}
|
]}
|
||||||
value={detailTab}
|
value={detailTab}
|
||||||
@@ -178,6 +189,27 @@ export function AgentDetailScreen() {
|
|||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
) : detailTab === "events" ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.sunkenCard,
|
||||||
|
{ backgroundColor: colors.surfaceSunken, borderColor: colors.borderSubtle },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{selectedEvents.length === 0 ? (
|
||||||
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||||
|
{agent.session_id
|
||||||
|
? "No run events yet."
|
||||||
|
: "No event stream — this agent predates the headless runner."}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
{selectedEvents.map((e) => (
|
||||||
|
<EventLine key={e.id} event={e} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
AuthError,
|
AuthError,
|
||||||
createClient,
|
createClient,
|
||||||
type Agent,
|
type Agent,
|
||||||
|
type AgentEvent,
|
||||||
type ApiClient,
|
type ApiClient,
|
||||||
type ApiError,
|
type ApiError,
|
||||||
type Checkmark,
|
type Checkmark,
|
||||||
@@ -41,7 +42,7 @@ export type Screen =
|
|||||||
| "log"
|
| "log"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
export type DetailTab = "state" | "log";
|
export type DetailTab = "state" | "events" | "log";
|
||||||
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||||
export type RecentTone = "positive" | "danger";
|
export type RecentTone = "positive" | "danger";
|
||||||
|
|
||||||
@@ -108,6 +109,9 @@ interface AppStateValue {
|
|||||||
selectedAgent: Agent | null;
|
selectedAgent: Agent | null;
|
||||||
selectedCheckmark: Checkmark | null;
|
selectedCheckmark: Checkmark | null;
|
||||||
selectedLog: LogEntry[];
|
selectedLog: LogEntry[];
|
||||||
|
/* Headless run event stream for the selected agent, oldest-first (empty for a
|
||||||
|
* legacy tmux agent). Polled on a fast cadence while the detail screen is open. */
|
||||||
|
selectedEvents: AgentEvent[];
|
||||||
|
|
||||||
// Mutations.
|
// Mutations.
|
||||||
sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
|
sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
|
||||||
@@ -375,6 +379,43 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [agentsByProject, logsByAgent]);
|
}, [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<AgentEvent[]>([]);
|
||||||
|
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<AgentEvent[]>(
|
||||||
|
`/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 ------------------------------------------------------
|
// ---- Selected agent ------------------------------------------------------
|
||||||
const selectedAgent = useMemo<Agent | null>(() => {
|
const selectedAgent = useMemo<Agent | null>(() => {
|
||||||
if (!selected) return null;
|
if (!selected) return null;
|
||||||
@@ -489,6 +530,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
selectedAgent,
|
selectedAgent,
|
||||||
selectedCheckmark,
|
selectedCheckmark,
|
||||||
selectedLog,
|
selectedLog,
|
||||||
|
selectedEvents,
|
||||||
|
|
||||||
sendAnswer,
|
sendAnswer,
|
||||||
spawn,
|
spawn,
|
||||||
@@ -512,6 +554,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
selectedAgent,
|
selectedAgent,
|
||||||
selectedCheckmark,
|
selectedCheckmark,
|
||||||
selectedLog,
|
selectedLog,
|
||||||
|
selectedEvents,
|
||||||
sendAnswer,
|
sendAnswer,
|
||||||
spawn,
|
spawn,
|
||||||
kill,
|
kill,
|
||||||
|
|||||||
Reference in New Issue
Block a user