feat(web): render command stages with a Logs tab showing real stdout/stderr

Command nodes now show a "Logs" tab (in place of "Transcript") that fetches
the actual stdout/stderr bytes via the stage log endpoint, rather than the
blob:// refs carried in command.completed events. Exit code and duration
sit on the right side of the toolbar alongside the tab toggle. Agent stages
are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 16:21:56 -07:00
parent 858caf7434
commit 0aceebbd9e
No known key found for this signature in database
3 changed files with 322 additions and 50 deletions

View file

@ -162,6 +162,18 @@ export function fetchRunCommandLog(
);
}
export function useRunStageLog(
id: string | undefined,
stageId: string | undefined,
stream: CommandOutputStream,
enabled: boolean,
) {
return useSWR<CommandLogResponse>(
enabled && id && stageId ? queryKeys.runs.stageLog(id, stageId, stream) : null,
apiFetcher,
);
}
export function useWorkflows() {
return useSWR<PaginatedWorkflowListResponse | null>(
queryKeys.workflows.list(),

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { eventsToActivity, extractStageModel } from "./run-stages";
import { eventsToActivity, extractStageModel, turnsToStageKind } from "./run-stages";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
@ -79,8 +79,10 @@ describe("eventsToActivity", () => {
event: "command.completed",
node_id: "fmt",
properties: {
stdout: "ok",
stderr: "",
stdout: "blob://sha256/abc",
stderr: "blob://sha256/def",
stdout_bytes: 42,
stderr_bytes: 0,
exit_code: 0,
duration_ms: 12,
termination: "exited",
@ -94,6 +96,8 @@ describe("eventsToActivity", () => {
kind: "command",
script: "cargo fmt",
running: false,
stdoutBytes: 42,
stderrBytes: 0,
});
});
@ -251,3 +255,49 @@ describe("eventsToActivity", () => {
}
});
});
describe("turnsToStageKind", () => {
test("classifies a stage with only command events as command", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
node_id: "fmt",
properties: { text: "run formatter" },
}),
envelope(2, {
event: "command.started",
node_id: "fmt",
properties: { script: "cargo fmt", language: "shell" },
}),
envelope(3, {
event: "command.completed",
node_id: "fmt",
properties: {
stdout: "blob://sha256/abc",
stderr: "blob://sha256/def",
exit_code: 0,
duration_ms: 5,
termination: "exited",
},
}),
];
expect(turnsToStageKind(eventsToActivity(events, "fmt"))).toBe("command");
});
test("classifies a stage with agent events as agent", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.message",
node_id: "simplify",
properties: { text: "thinking…" },
}),
];
expect(turnsToStageKind(eventsToActivity(events, "simplify"))).toBe("agent");
});
test("defaults to agent for empty turns", () => {
expect(turnsToStageKind([])).toBe("agent");
});
});

View file

@ -20,11 +20,17 @@ import { Marked } from "marked";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
import { useRun, useRunStageEvents, useRunStages } from "../lib/queries";
import { formatBytes } from "../lib/format";
import {
useRun,
useRunStageEvents,
useRunStageLog,
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 type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { CommandOutputStream, EventEnvelope } from "@qltysh/fabro-api-client";
export const handle = { wide: true, fullHeight: true };
@ -32,7 +38,19 @@ type TurnType =
| { kind: "system"; ts: string; content: string }
| { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number }
| { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean; durationMs: number }
| { kind: "command"; ts: string; script: string; running: boolean; exitCode: number | null; durationMs: number };
| {
kind: "command";
ts: string;
script: string;
running: boolean;
exitCode: number | null;
durationMs: number;
stdoutBytes: number;
stderrBytes: number;
};
type CommandTurn = Extract<TurnType, { kind: "command" }>;
type StageKind = "agent" | "command";
const STAGE_ACTIVITY_EVENT_SET = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
@ -72,10 +90,10 @@ function debugCategoryTone(category: string): string {
const EVENTS_TABS = ["transcript", "debug"] as const;
type EventsTab = (typeof EVENTS_TABS)[number];
const EVENTS_TAB_LABEL: Record<EventsTab, string> = {
transcript: "Transcript",
debug: "Debug",
};
function eventsTabLabel(tab: EventsTab, stageKind: StageKind): string {
if (tab === "debug") return "Debug";
return stageKind === "command" ? "Logs" : "Transcript";
}
function assertNever(value: never): never {
throw new Error(`Unhandled stage activity event type: ${value}`);
@ -174,6 +192,8 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn
running: false,
exitCode: getNumber(props, "exit_code") ?? null,
durationMs: getNumber(props, "duration_ms") ?? 0,
stdoutBytes: getNumber(props, "stdout_bytes") ?? 0,
stderrBytes: getNumber(props, "stderr_bytes") ?? 0,
});
pendingCommand = undefined;
break;
@ -191,12 +211,23 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn
running: true,
exitCode: null,
durationMs: 0,
stdoutBytes: 0,
stderrBytes: 0,
});
}
return turns;
}
export function turnsToStageKind(turns: TurnType[]): StageKind {
let hasCommand = false;
for (const t of turns) {
if (t.kind === "assistant" || t.kind === "tool") return "agent";
if (t.kind === "command") hasCommand = true;
}
return hasCommand ? "command" : "agent";
}
const STAGE_MODEL_EVENT_NAMES = new Set([
"stage.prompt",
"agent.session.activated",
@ -614,6 +645,157 @@ function EventDetails({ turn, runStart }: { turn: TurnType; runStart: string | u
);
}
function decodeBase64Utf8(b64: string): string {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
function LogStream({
runId,
stageId,
stream,
label,
byteCount,
enabled,
tone,
}: {
runId: string;
stageId: string;
stream: CommandOutputStream;
label: string;
byteCount: number;
enabled: boolean;
tone?: "stderr";
}) {
const { data, error, isLoading } = useRunStageLog(runId, stageId, stream, enabled && byteCount > 0);
const text = useMemo(() => {
if (!data?.bytes_base64) return "";
try {
return decodeBase64Utf8(data.bytes_base64);
} catch {
return "";
}
}, [data]);
const truncated =
data && data.total_bytes > data.next_offset ? data.total_bytes - data.next_offset : 0;
return (
<section>
<header className="mb-1 flex items-baseline justify-between gap-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
{label}
</h3>
{byteCount > 0 && (
<span className="font-mono text-[11px] tabular-nums text-fg-muted">
{formatBytes(byteCount)}
</span>
)}
</header>
<pre
className={`overflow-x-auto whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed ${
tone === "stderr" ? "text-coral" : "text-fg-3"
}`}
>
{byteCount === 0 ? (
<span className="text-fg-muted">empty</span>
) : isLoading && !data ? (
<span className="text-fg-muted">loading</span>
) : error ? (
<span className="text-coral">Failed to load {stream}.</span>
) : (
text || <span className="text-fg-muted">empty</span>
)}
</pre>
{truncated > 0 && (
<p className="mt-1 text-[11px] text-fg-muted">
Showing first {formatBytes(data!.next_offset)} of {formatBytes(data!.total_bytes)}.
</p>
)}
</section>
);
}
function CommandStatus({ turn }: { turn: CommandTurn }) {
const exitTone =
turn.exitCode == null
? "text-fg-muted"
: turn.exitCode === 0
? "text-mint"
: "text-coral";
return (
<span className="ml-auto inline-flex items-center gap-x-3 text-xs">
{turn.running ? (
<span className="inline-flex items-center gap-1.5 text-amber">
<span className="size-1.5 animate-pulse rounded-full bg-amber" />
Running
</span>
) : (
<span className={`font-mono tabular-nums ${exitTone}`}>
exit {turn.exitCode ?? "?"}
</span>
)}
{turn.durationMs > 0 && (
<span className="font-mono tabular-nums text-fg-muted">
{formatDurationMs(turn.durationMs)}
</span>
)}
</span>
);
}
function CommandScript({ script }: { script: string }) {
return (
<section>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-muted">
Command
</h3>
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
{script || <span className="text-fg-muted">empty</span>}
</pre>
</section>
);
}
function CommandLogs({
runId,
stageId,
turn,
}: {
runId: string;
stageId: string;
turn: CommandTurn | null;
}) {
if (!turn) {
return (
<div className="px-2 py-6 text-sm text-fg-muted">No command output yet.</div>
);
}
return (
<div className="space-y-5 pl-3 pr-4 sm:pr-6 lg:pr-8">
<CommandScript script={turn.script} />
<LogStream
runId={runId}
stageId={stageId}
stream="stdout"
label="Stdout"
byteCount={turn.stdoutBytes}
enabled={!turn.running}
/>
<LogStream
runId={runId}
stageId={stageId}
stream="stderr"
label="Stderr"
byteCount={turn.stderrBytes}
enabled={!turn.running}
tone="stderr"
/>
</div>
);
}
function DetailsPanel({
title,
isOpen,
@ -711,9 +893,11 @@ function DebugEventDetailsPanel({
function EventsTabToggle({
tab,
stageKind,
onTabChange,
}: {
tab: EventsTab;
stageKind: StageKind;
onTabChange: (tab: EventsTab) => void;
}) {
return (
@ -736,7 +920,7 @@ function EventsTabToggle({
: "text-fg-muted hover:text-fg-2"
}`}
>
{EVENTS_TAB_LABEL[value]}
{eventsTabLabel(value, stageKind)}
</button>
);
})}
@ -832,6 +1016,8 @@ function SearchInput({
function EventsToolbar({
tab,
stageKind,
commandTurn,
onTabChange,
selectedKinds,
onKindsChange,
@ -845,6 +1031,8 @@ function EventsToolbar({
model,
}: {
tab: EventsTab;
stageKind: StageKind;
commandTurn: CommandTurn | null;
onTabChange: (tab: EventsTab) => void;
selectedKinds: EventKind[];
onKindsChange: (kinds: EventKind[]) => void;
@ -857,13 +1045,16 @@ function EventsToolbar({
totalCount: number;
model: string | null;
}) {
const showFilters = !(tab === "transcript" && stageKind === "command");
const transcriptAllSelected = selectedKinds.length === EVENT_KINDS.length;
const debugAllSelected =
selectedDebugCategories.length === 0 ||
selectedDebugCategories.length === availableDebugCategories.length;
const isFiltering = tab === "transcript"
? !transcriptAllSelected || search.length > 0
: !debugAllSelected || search.length > 0;
const isFiltering =
showFilters &&
(tab === "transcript"
? !transcriptAllSelected || search.length > 0
: !debugAllSelected || search.length > 0);
function clearFilters() {
if (tab === "transcript") onKindsChange([...EVENT_KINDS]);
@ -873,35 +1064,37 @@ function EventsToolbar({
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<EventsTabToggle tab={tab} onTabChange={onTabChange} />
<div className="flex flex-1 flex-wrap items-center gap-2">
{tab === "transcript" ? (
<MultiSelectFilter<EventKind>
selected={selectedKinds}
options={EVENT_KINDS}
labelOf={(k) => EVENT_KIND_LABEL[k]}
onChange={onKindsChange}
/>
) : (
<MultiSelectFilter<string>
selected={selectedDebugCategories}
options={availableDebugCategories}
labelOf={debugCategoryLabel}
onChange={onDebugCategoriesChange}
emptyMeansAll
/>
)}
<SearchInput value={search} onChange={onSearchChange} />
{isFiltering && (
<button
type="button"
onClick={clearFilters}
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
Clear
</button>
)}
</div>
<EventsTabToggle tab={tab} stageKind={stageKind} onTabChange={onTabChange} />
{showFilters && (
<div className="flex flex-1 flex-wrap items-center gap-2">
{tab === "transcript" ? (
<MultiSelectFilter<EventKind>
selected={selectedKinds}
options={EVENT_KINDS}
labelOf={(k) => EVENT_KIND_LABEL[k]}
onChange={onKindsChange}
/>
) : (
<MultiSelectFilter<string>
selected={selectedDebugCategories}
options={availableDebugCategories}
labelOf={debugCategoryLabel}
onChange={onDebugCategoriesChange}
emptyMeansAll
/>
)}
<SearchInput value={search} onChange={onSearchChange} />
{isFiltering && (
<button
type="button"
onClick={clearFilters}
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
Clear
</button>
)}
</div>
)}
{isFiltering && totalCount > 0 && (
<span className="text-xs tabular-nums text-fg-muted">
{filteredCount.toLocaleString()} of {totalCount.toLocaleString()} events
@ -909,13 +1102,16 @@ function EventsToolbar({
)}
{model && (
<span
className="inline-flex items-center gap-1.5 text-xs text-fg-muted"
className={`inline-flex items-center gap-1.5 text-xs text-fg-muted ${
showFilters ? "" : "ml-auto"
}`}
title="LLM model used for this stage"
>
<CpuChipIcon className="size-3.5" aria-hidden="true" />
<span className="font-mono">{model}</span>
</span>
)}
{!showFilters && commandTurn && <CommandStatus turn={commandTurn} />}
</div>
);
}
@ -939,6 +1135,14 @@ export default function RunStages() {
: [],
[stageEventsQuery.data, selectedStageId],
);
const stageKind = useMemo(() => turnsToStageKind(turns), [turns]);
const commandTurn = useMemo<CommandTurn | null>(() => {
for (let i = turns.length - 1; i >= 0; i -= 1) {
const t = turns[i];
if (t.kind === "command") return t;
}
return null;
}, [turns]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [openDebugSeq, setOpenDebugSeq] = useState<number | null>(null);
@ -1043,6 +1247,8 @@ export default function RunStages() {
<div className="pl-3 pr-4 sm:pr-6 lg:pr-8">
<EventsToolbar
tab={tab}
stageKind={stageKind}
commandTurn={commandTurn}
onTabChange={setTab}
selectedKinds={selectedKinds}
onKindsChange={setSelectedKinds}
@ -1059,7 +1265,9 @@ export default function RunStages() {
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-2">
{tab === "transcript" ? (
turns.length > 0 && filteredTurns.length === 0 ? (
stageKind === "command" ? (
<CommandLogs runId={id} stageId={selectedStage.id} turn={commandTurn} />
) : turns.length > 0 && filteredTurns.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
No events match these filters.
</div>
@ -1093,11 +1301,13 @@ export default function RunStages() {
</div>
{tab === "transcript" ? (
<EventDetailsPanel
turn={openTurn}
runStart={runStart}
onClose={() => setOpenIndex(null)}
/>
stageKind === "command" ? null : (
<EventDetailsPanel
turn={openTurn}
runStart={runStart}
onClose={() => setOpenIndex(null)}
/>
)
) : (
<DebugEventDetailsPanel
event={openDebugEvent}