diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 52f10ed05..339a8e559 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -1,21 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; -import { eventsToActivity, isSafeMarkdownHref } from "./run-stages"; - -describe("isSafeMarkdownHref", () => { - test("rejects protocol-relative URLs", () => { - expect(isSafeMarkdownHref("//attacker.example/pixel.png")).toBe(false); - }); - - test("accepts root-relative, hash, http, https, and mailto URLs", () => { - expect(isSafeMarkdownHref("/runs/run-1")).toBe(true); - expect(isSafeMarkdownHref("#section-1")).toBe(true); - expect(isSafeMarkdownHref("https://fabro.sh")).toBe(true); - expect(isSafeMarkdownHref("http://localhost:3000")).toBe(true); - expect(isSafeMarkdownHref("mailto:test@example.com")).toBe(true); - }); -}); +import { eventsToActivity } from "./run-stages"; function envelope(seq: number, partial: Partial): EventEnvelope { return { @@ -59,14 +45,14 @@ describe("eventsToActivity", () => { const firstVisit = eventsToActivity(events, "verify@1"); expect(firstVisit).toEqual([ - { kind: "system", content: "first visit prompt" }, - { kind: "assistant", content: "first visit reply" }, + { kind: "system", ts: "2026-04-09T12:00:00Z", content: "first visit prompt" }, + { kind: "assistant", ts: "2026-04-09T12:00:00Z", content: "first visit reply" }, ]); const secondVisit = eventsToActivity(events, "verify@2"); expect(secondVisit).toEqual([ - { kind: "system", content: "second visit prompt" }, - { kind: "assistant", content: "second visit reply" }, + { kind: "system", ts: "2026-04-09T12:00:00Z", content: "second visit prompt" }, + { kind: "assistant", ts: "2026-04-09T12:00:00Z", content: "second visit reply" }, ]); }); @@ -94,11 +80,7 @@ describe("eventsToActivity", () => { expect(turns).toHaveLength(1); expect(turns[0]).toMatchObject({ kind: "command", - stageId: "fmt", script: "cargo fmt", - language: "shell", - stdout: "ok", - exitCode: 0, running: false, }); }); @@ -130,7 +112,6 @@ describe("eventsToActivity", () => { const turn = turns[0]; expect(turn.kind).toBe("command"); if (turn.kind === "command") { - expect(turn.stageId).toBe("verify@2"); expect(turn.script).toBe("echo hi"); expect(turn.running).toBe(false); } @@ -163,11 +144,8 @@ describe("eventsToActivity", () => { expect(turns).toHaveLength(1); expect(turns[0].kind).toBe("tool"); if (turns[0].kind === "tool") { - expect(turns[0].tools).toHaveLength(1); - expect(turns[0].tools[0]).toMatchObject({ - id: "call-1", + expect(turns[0]).toMatchObject({ toolName: "read_file", - result: "[redis]", isError: false, }); } diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index d6ef9514d..e5cd4e54c 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -1,69 +1,23 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router"; -import { Marked } from "marked"; +import { XMarkIcon } from "@heroicons/react/24/outline"; -const SAFE_HTTP_URL_RE = /^https?:\/\//i; -const SAFE_MAILTO_URL_RE = /^mailto:/i; - -export function isSafeMarkdownHref(href: string): boolean { - return ( - SAFE_HTTP_URL_RE.test(href) || - SAFE_MAILTO_URL_RE.test(href) || - href.startsWith("#") || - (href.startsWith("/") && !href.startsWith("//")) - ); -} - -const markedSafe = new Marked(); -markedSafe.use({ - async: false, - walkTokens(token) { - if ( - (token.type === "link" || token.type === "image") && - typeof token.href === "string" && - !isSafeMarkdownHref(token.href) - ) { - token.href = ""; - } - }, - renderer: { - html() { - return ""; - }, - }, -}); -import { CommandLineIcon, ChatBubbleLeftIcon, PlayIcon } from "@heroicons/react/24/outline"; -import { ToolBlock } from "../components/tool-use"; -import type { ToolUse } from "../components/tool-use"; import { StageSidebar } from "../components/stage-sidebar"; import type { Stage } from "../components/stage-sidebar"; import { EmptyState } from "../components/state"; -import { CopyButton } from "../components/ui"; -import { fetchRunCommandLog, useRunStageEvents, useRunStages } from "../lib/queries"; +import { useRun, useRunStageEvents, useRunStages } from "../lib/queries"; import { STAGE_ACTIVITY_EVENT_TYPES, type StageActivityEventType } from "../lib/run-events"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; -import { - CommandOutputStream, - CommandTermination, - type EventEnvelope, -} from "@qltysh/fabro-api-client"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; export const handle = { wide: true, fullHeight: true }; type TurnType = - | { kind: "system"; content: string } - | { kind: "assistant"; content: string } - | { kind: "tool"; tools: ToolUse[] } - | { kind: "command"; stageId: string; script: string; language: string; stdout?: string; stderr?: string; exitCode?: number | null; durationMs?: number; termination?: CommandTermination; running: boolean }; - -function readTermination(props: UnknownRecord): CommandTermination { - const v = props.termination; - if (v === CommandTermination.EXITED || v === CommandTermination.TIMED_OUT || v === CommandTermination.CANCELLED) { - return v; - } - return CommandTermination.EXITED; -} + | { kind: "system"; ts: string; content: string } + | { kind: "assistant"; ts: string; content: string } + | { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean } + | { kind: "command"; ts: string; script: string; running: boolean; exitCode: number | null; durationMs: number }; const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); @@ -77,12 +31,21 @@ function activityEventStageId(event: EventEnvelope): string | undefined { return getString(event.properties ?? {}, "node_id"); } +interface PendingTool { + ts: string; + toolName: string; + input: string; +} + +interface PendingCommand { + ts: string; + script: string; +} + export function eventsToActivity(events: EventEnvelope[], stageId: string): TurnType[] { const turns: TurnType[] = []; - // Collect tool pairs: started → completed - const pendingTools = new Map(); - // Track pending command for pairing started → completed - let pendingCommand: { stageId: string; script: string; language: string } | undefined; + const pendingTools = new Map(); + let pendingCommand: PendingCommand | undefined; for (const e of events) { const eventName = e.event; @@ -93,24 +56,22 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn ) { continue; } - // Exhaustive switch over StageActivityEventType: adding a new variant to - // STAGE_ACTIVITY_EVENT_TYPES forces a TS error here until the case is - // handled, keeping the SWR invalidation set and the reducer in sync. const eventType = eventName as StageActivityEventType; - const props = e.properties ?? {}; + const props: UnknownRecord = e.properties ?? {}; switch (eventType) { case "stage.prompt": - turns.push({ kind: "system", content: getString(props, "text") ?? e.text ?? "" }); + turns.push({ kind: "system", ts: e.ts, content: getString(props, "text") ?? e.text ?? "" }); break; case "agent.message": { const msg = getString(props, "text") ?? e.text ?? ""; - if (msg) turns.push({ kind: "assistant", content: msg }); + if (msg) turns.push({ kind: "assistant", ts: e.ts, content: msg }); break; } case "agent.tool.started": { const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; const args = props.arguments ?? e.arguments; pendingTools.set(callId, { + ts: e.ts, toolName: getString(props, "tool_name") ?? e.tool_name ?? "", input: typeof args === "string" ? args : JSON.stringify(args ?? ""), }); @@ -119,39 +80,34 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn case "agent.tool.completed": { const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; const started = pendingTools.get(callId); + pendingTools.delete(callId); const output = props.output ?? e.output ?? ""; - const result = typeof output === "string" ? output : JSON.stringify(output); - const tool: ToolUse = { - id: callId, + const result = typeof output === "string" ? output : JSON.stringify(output, null, 2); + turns.push({ + kind: "tool", + ts: started?.ts ?? e.ts, toolName: started?.toolName ?? getString(props, "tool_name") ?? e.tool_name ?? "", input: started?.input ?? "", result, isError: (props.is_error ?? e.is_error) === true, - }; - pendingTools.delete(callId); - turns.push({ kind: "tool", tools: [tool] }); + }); break; } case "command.started": { pendingCommand = { - stageId, + ts: e.ts, script: getString(props, "script") ?? "", - language: getString(props, "language") ?? "shell", }; break; } case "command.completed": { turns.push({ kind: "command", - stageId: pendingCommand?.stageId ?? stageId, + ts: pendingCommand?.ts ?? e.ts, script: pendingCommand?.script ?? "", - language: pendingCommand?.language ?? "shell", - stdout: getString(props, "stdout") ?? "", - stderr: getString(props, "stderr") ?? "", + running: false, exitCode: getNumber(props, "exit_code") ?? null, durationMs: getNumber(props, "duration_ms") ?? 0, - termination: readTermination(props), - running: false, }); pendingCommand = undefined; break; @@ -161,392 +117,268 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn } } - // If command.started was seen but no command.completed, it's still running if (pendingCommand) { turns.push({ kind: "command", - stageId: pendingCommand.stageId, + ts: pendingCommand.ts, script: pendingCommand.script, - language: pendingCommand.language, running: true, + exitCode: null, + durationMs: 0, }); } return turns; } -function Markdown({ content }: { content: string }) { - const html = useMemo(() => markedSafe.parse(content, { async: false }) as string, [content]); - return ( -
- ); +function turnLabel(turn: TurnType): string { + switch (turn.kind) { + case "system": + return "System"; + case "assistant": + return "Agent"; + case "tool": + return "Tool"; + case "command": + return "Command"; + } } -function SystemBlock({ content }: { content: string }) { - return ( -
-
- - System prompt -
- -
-
- -
- ); +function turnTone(turn: TurnType): string { + if (turn.kind === "tool" && turn.isError) { + return "bg-coral/15 text-coral"; + } + switch (turn.kind) { + case "system": + return "bg-amber/15 text-amber"; + case "assistant": + return "bg-teal-500/15 text-teal-500"; + case "tool": + case "command": + return "bg-mint/15 text-mint"; + } } -function AssistantBlock({ content }: { content: string }) { - return ( -
-
- - Assistant -
- -
-
- -
- ); +const SUMMARY_MAX_CHARS = 80; + +function oneLine(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= SUMMARY_MAX_CHARS) return collapsed; + return `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`; } -function StatusPill({ - tone, - children, +const TOOL_NAME_DISPLAY: Record = { + read_file: "Read", + write_file: "Write", + edit_file: "Edit", + shell: "Bash", + grep: "Grep", + glob: "Glob", + read_many_files: "Read Many", + list_dir: "List Dir", + web_search: "Web Search", + web_fetch: "Web Fetch", +}; + +export function humanizeToolName(raw: string): string { + if (!raw) return "tool"; + if (TOOL_NAME_DISPLAY[raw]) return TOOL_NAME_DISPLAY[raw]; + // MCP tools are namespaced like `mcp____`; display the trailing segment. + const lastSegment = raw.split("__").pop() ?? raw; + return lastSegment + .split(/[_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function turnSummary(turn: TurnType): string { + switch (turn.kind) { + case "system": + case "assistant": + return oneLine(turn.content); + case "tool": + return humanizeToolName(turn.toolName); + case "command": + return oneLine(turn.script) || (turn.running ? "running…" : ""); + } +} + +export function formatElapsed(eventTs: string, runStart: string | undefined): string { + if (!runStart) return ""; + const startMs = Date.parse(runStart); + const eventMs = Date.parse(eventTs); + if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return ""; + const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000)); + const hours = Math.floor(delta / 3600); + const minutes = Math.floor((delta % 3600) / 60); + const seconds = delta % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} + +function EventRow({ + turn, + runStart, + selected, + onSelect, }: { - tone: "running" | "failed" | "success" | "neutral"; - children: React.ReactNode; + turn: TurnType; + runStart: string | undefined; + selected: boolean; + onSelect: () => void; }) { - const toneClass = { - running: "bg-teal-500/15 text-teal-500", - failed: "bg-coral/15 text-coral", - success: "bg-mint/15 text-mint", - neutral: "bg-overlay text-fg-3", - }[tone]; return ( - - {children} - + ); } -const COLLAPSE_AFTER_LINES = 20; -const LOG_POLL_INTERVAL_MS = 1000; -const LOG_FETCH_LIMIT_BYTES = 65_536; -const LOG_MEMORY_CAP_BYTES = 5 * 1024 * 1024; - -function StreamLabel({ label }: { label: string }) { - return ( -
- {label} -
- ); -} - -interface CommandLogState { - text: string; - eof: boolean; - loading: boolean; - error: boolean; - truncated: boolean; - casRef: string | null; - liveStreaming: boolean; - totalBytes: number; -} - -function decodeBase64Bytes(value: string): Uint8Array { - if (!value) return new Uint8Array(); - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -function trimTextToBytes(text: string, maxBytes: number) { - // Each UTF-16 code unit encodes to at most 3 bytes in UTF-8 (4-byte encodings - // come from surrogate pairs counted as 2 units). Skip the full encode when - // the upper bound is already under the cap. - if (text.length * 3 <= maxBytes) { - return { text, truncated: false }; - } - const encoded = new TextEncoder().encode(text); - if (encoded.byteLength <= maxBytes) { - return { text, truncated: false }; - } - const start = encoded.byteLength - maxBytes; - const trimmed = new TextDecoder().decode(encoded.slice(start)); - return { text: trimmed.replace(/^\uFFFD/, ""), truncated: true }; -} - -function useCommandLog( - runId: string | undefined, - stageId: string | undefined, - stream: CommandOutputStream, - running: boolean, -): CommandLogState { - const [state, setState] = useState({ - text: "", - eof: false, - loading: true, - error: false, - truncated: false, - casRef: null, - liveStreaming: false, - totalBytes: 0, - }); - const offsetRef = useRef(0); - const finalPollDoneRef = useRef(false); - const decoderRef = useRef(new TextDecoder()); - - useEffect(() => { - offsetRef.current = 0; - finalPollDoneRef.current = false; - decoderRef.current = new TextDecoder(); - setState({ - text: "", - eof: false, - loading: true, - error: false, - truncated: false, - casRef: null, - liveStreaming: false, - totalBytes: 0, - }); - }, [runId, stageId, stream]); - - useEffect(() => { - if (!runId || !stageId) return; - let cancelled = false; - let timer: ReturnType | undefined; - - async function poll() { - try { - const chunk = await fetchRunCommandLog( - runId, - stageId, - stream, - offsetRef.current, - LOG_FETCH_LIMIT_BYTES, - ); - if (cancelled) return; - - offsetRef.current = chunk.next_offset; - const bytes = decodeBase64Bytes(chunk.bytes_base64); - const decoded = decoderRef.current.decode(bytes, { stream: !chunk.eof }); - finalPollDoneRef.current = chunk.eof; - setState((current) => { - if ( - decoded.length === 0 && - current.eof === chunk.eof && - current.totalBytes === chunk.total_bytes && - current.casRef === chunk.cas_ref && - current.liveStreaming === chunk.live_streaming && - !current.loading && - !current.error - ) { - return current; - } - const next = trimTextToBytes(current.text + decoded, LOG_MEMORY_CAP_BYTES); - return { - text: next.text, - eof: chunk.eof, - loading: false, - error: false, - truncated: current.truncated || next.truncated, - casRef: chunk.cas_ref, - liveStreaming: chunk.live_streaming, - totalBytes: chunk.total_bytes, - }; - }); - } catch { - if (!cancelled) { - setState((current) => ({ ...current, loading: false, error: true })); - } - } - - if (!cancelled && (running || !finalPollDoneRef.current)) { - timer = setTimeout(poll, LOG_POLL_INTERVAL_MS); - } - } - - void poll(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [runId, running, stageId, stream]); - - return state; -} - -function streamStatus(state: CommandLogState, hasContent: boolean): string { - if (state.error) return "Failed to load"; - if (state.loading) return "Waiting"; - if (hasContent) { - if (state.eof) return state.casRef ? "Stored" : "Complete"; - return state.liveStreaming ? "Streaming" : "Running"; - } - return state.eof ? "No output" : "Waiting"; -} - -function OutputStream({ +function DetailField({ label, - state, - tone = "normal", - forceExpanded = false, + children, + mono = false, }: { label: string; - state: CommandLogState; - tone?: "normal" | "error"; - forceExpanded?: boolean; + children: React.ReactNode; + mono?: boolean; }) { - const content = state.text; - const lines = content.split("\n"); - const isLong = lines.length > COLLAPSE_AFTER_LINES; - const [expanded, setExpanded] = useState(forceExpanded); - const scrollRef = useRef(null); - const followTailRef = useRef(true); - const visible = isLong && !expanded - ? lines.slice(-COLLAPSE_AFTER_LINES).join("\n") - : content; - const hiddenLines = isLong && !expanded ? lines.length - COLLAPSE_AFTER_LINES : 0; - const preClass = - tone === "error" - ? "whitespace-pre-wrap font-mono text-sm leading-relaxed text-coral sm:text-xs" - : "whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs"; - const status = streamStatus(state, content.length > 0); - - useEffect(() => { - if (!forceExpanded) return; - setExpanded(true); - }, [forceExpanded]); - - useEffect(() => { - const el = scrollRef.current; - if (el && followTailRef.current) { - el.scrollTop = el.scrollHeight; - } - }, [visible]); - return (
-
- - {status} - {state.truncated ? ( - Last 5 MiB - ) : null} - +
+ {label}
- {isLong && !expanded ? ( - - ) : null} - {content.length === 0 ? ( -
- {state.error ? "Unable to fetch this stream." : "No bytes received yet."} -
- ) : ( -
 {
-            const el = event.currentTarget;
-            followTailRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
-          }}
-          className={`${preClass} max-h-96 overflow-auto`}
-        >
-          {visible}
-        
+
+ {children} +
+
+ ); +} + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children || empty}
+    
+ ); +} + +function EventDetails({ turn, runStart }: { turn: TurnType; runStart: string | undefined }) { + const elapsed = formatElapsed(turn.ts, runStart); + const absolute = (() => { + const ms = Date.parse(turn.ts); + if (Number.isNaN(ms)) return turn.ts; + return new Date(ms).toLocaleString(); + })(); + + return ( +
+ + {elapsed ? `${elapsed} · ${absolute}` : absolute} + + + {(turn.kind === "system" || turn.kind === "assistant") && ( + + {turn.content} + + )} + + {turn.kind === "tool" && ( + <> + + {humanizeToolName(turn.toolName)}{" "} + ({turn.toolName}) + + + {turn.input} + + + {turn.result} + + + )} + + {turn.kind === "command" && ( + <> + + {turn.running + ? "Running…" + : `exit ${turn.exitCode ?? "?"}${ + turn.durationMs + ? ` · ${ + turn.durationMs < 1000 + ? `${turn.durationMs}ms` + : `${(turn.durationMs / 1000).toFixed(1)}s` + }` + : "" + }`} + + + {turn.script} + + )}
); } -function CommandBlock({ - runId, +function EventDetailsPanel({ turn, + runStart, + onClose, }: { - runId: string | undefined; - turn: Extract; + turn: TurnType | null; + runStart: string | undefined; + onClose: () => void; }) { - const failed = !turn.running && (turn.termination !== CommandTermination.EXITED || turn.exitCode !== 0); - const stdout = useCommandLog(runId, turn.stageId, CommandOutputStream.STDOUT, turn.running); - const stderr = useCommandLog(runId, turn.stageId, CommandOutputStream.STDERR, turn.running); - const borderColor = turn.running ? "border-teal-500/20" : failed ? "border-coral/15" : "border-mint/15"; - const bgColor = turn.running ? "bg-teal-500/5" : failed ? "bg-coral/5" : "bg-mint/5"; + useEffect(() => { + if (!turn) return; + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [turn, onClose]); return ( -
- {/* Header */} -
- - - {turn.language === "python" ? "Python" : "Shell"} - -
- {turn.running ? ( - Running… - ) : turn.termination === CommandTermination.TIMED_OUT ? ( - Timed out - ) : turn.termination === CommandTermination.CANCELLED ? ( - Cancelled - ) : ( - <> - - exit {turn.exitCode ?? "?"} - - {turn.durationMs != null && ( - - {turn.durationMs < 1000 - ? `${turn.durationMs}ms` - : `${(turn.durationMs / 1000).toFixed(1)}s`} - - )} - - )} - {turn.script ? ( -
- -
- ) : null} -
+
+
+

+ {turn ? `${turnLabel(turn)} event` : ""} +

+
- - {/* Script */} - {turn.script && ( -
-
{turn.script}
-
- )} - -
-
- -
-
- 0} - /> -
+
+ {turn ? : null}
); @@ -554,6 +386,7 @@ function CommandBlock({ export default function RunStages() { const { id, stageId } = useParams(); + const runQuery = useRun(id); const stagesQuery = useRunStages(id); const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), @@ -571,6 +404,12 @@ export default function RunStages() { [stageEventsQuery.data, selectedStageId], ); + const [openIndex, setOpenIndex] = useState(null); + useEffect(() => { + setOpenIndex(null); + }, [selectedStageId]); + const openTurn = openIndex != null ? turns[openIndex] ?? null : null; + if (!id || !stages.length) { return (
@@ -582,28 +421,38 @@ export default function RunStages() { ); } + const runStart = runQuery.data?.created_at; + return ( -
+
-