Tighten live run transcript streaming and stdout

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-11 13:29:40 -05:00
parent 98ede67b9b
commit 5e9c223077
5 changed files with 68 additions and 12 deletions

View File

@@ -1368,12 +1368,13 @@ export function heartbeatService(db: Db) {
const onLog = async (stream: "stdout" | "stderr", chunk: string) => { const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
const ts = new Date().toISOString();
if (handle) { if (handle) {
await runLogStore.append(handle, { await runLogStore.append(handle, {
stream, stream,
chunk, chunk,
ts: new Date().toISOString(), ts,
}); });
} }
@@ -1388,6 +1389,7 @@ export function heartbeatService(db: Db) {
payload: { payload: {
runId: run.id, runId: run.id,
agentId: run.agentId, agentId: run.agentId,
ts,
stream, stream,
chunk: payloadChunk, chunk: payloadChunk,
truncated: payloadChunk.length !== chunk.length, truncated: payloadChunk.length !== chunk.length,

View File

@@ -146,6 +146,7 @@ function AgentRunCard({
density="compact" density="compact"
limit={5} limit={5}
streaming={isActive} streaming={isActive}
collapseStdout
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."} emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
/> />
</div> </div>

View File

@@ -87,7 +87,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
if (runs.length === 0) return null; if (runs.length === 0) return null;
return ( return (
<div className="overflow-hidden rounded-2xl border border-cyan-500/25 bg-background/80 shadow-[0_18px_50px_rgba(6,182,212,0.08)]"> <div className="overflow-hidden rounded-xl border border-cyan-500/25 bg-background/80 shadow-[0_18px_50px_rgba(6,182,212,0.08)]">
<div className="border-b border-border/60 bg-cyan-500/[0.04] px-4 py-3"> <div className="border-b border-border/60 bg-cyan-500/[0.04] px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300"> <div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">
Live Runs Live Runs
@@ -147,6 +147,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
density="compact" density="compact"
limit={8} limit={8}
streaming={isActive} streaming={isActive}
collapseStdout
emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."} emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."}
/> />
</div> </div>

View File

@@ -21,6 +21,7 @@ interface RunTranscriptViewProps {
density?: TranscriptDensity; density?: TranscriptDensity;
limit?: number; limit?: number;
streaming?: boolean; streaming?: boolean;
collapseStdout?: boolean;
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
} }
@@ -70,6 +71,11 @@ type TranscriptBlock =
status: "running" | "completed" | "error"; status: "running" | "completed" | "error";
}>; }>;
} }
| {
type: "stdout";
ts: string;
text: string;
}
| { | {
type: "event"; type: "event";
ts: string; ts: string;
@@ -480,13 +486,16 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
continue; continue;
} }
blocks.push({ if (previous?.type === "stdout") {
type: "event", previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
ts: entry.ts, previous.ts = entry.ts;
label: "stdout", } else {
tone: "neutral", blocks.push({
text: entry.text, type: "stdout",
}); ts: entry.ts,
text: entry.text,
});
}
} }
return groupCommandBlocks(blocks); return groupCommandBlocks(blocks);
@@ -859,6 +868,44 @@ function TranscriptEventRow({
); );
} }
function TranscriptStdoutRow({
block,
density,
collapseByDefault,
}: {
block: Extract<TranscriptBlock, { type: "stdout" }>;
density: TranscriptDensity;
collapseByDefault: boolean;
}) {
const [open, setOpen] = useState(!collapseByDefault);
return (
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
stdout
</span>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setOpen((value) => !value)}
aria-label={open ? "Collapse stdout" : "Expand stdout"}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</div>
{open && (
<pre className={cn(
"mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-foreground/80",
density === "compact" ? "text-[11px]" : "text-xs",
)}>
{block.text}
</pre>
)}
</div>
);
}
function RawTranscriptView({ function RawTranscriptView({
entries, entries,
density, density,
@@ -903,6 +950,7 @@ export function RunTranscriptView({
density = "comfortable", density = "comfortable",
limit, limit,
streaming = false, streaming = false,
collapseStdout = false,
emptyMessage = "No transcript yet.", emptyMessage = "No transcript yet.",
className, className,
}: RunTranscriptViewProps) { }: RunTranscriptViewProps) {
@@ -937,6 +985,9 @@ export function RunTranscriptView({
{block.type === "thinking" && <TranscriptThinkingBlock block={block} density={density} />} {block.type === "thinking" && <TranscriptThinkingBlock block={block} density={density} />}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />} {block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />} {block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)}
{block.type === "activity" && <TranscriptActivityRow block={block} density={density} />} {block.type === "activity" && <TranscriptActivityRow block={block} density={density} />}
{block.type === "event" && <TranscriptEventRow block={block} density={density} />} {block.type === "event" && <TranscriptEventRow block={block} density={density} />}
</div> </div>

View File

@@ -46,7 +46,7 @@ function parsePersistedLogContent(
ts, ts,
stream, stream,
chunk, chunk,
dedupeKey: `persisted:${runId}:${ts}:${stream}:${chunk}`, dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
}); });
} catch { } catch {
// Ignore malformed log rows. // Ignore malformed log rows.
@@ -202,6 +202,7 @@ export function useLiveRunTranscripts({
if (event.type === "heartbeat.run.log") { if (event.type === "heartbeat.run.log") {
const chunk = readString(payload["chunk"]); const chunk = readString(payload["chunk"]);
if (!chunk) return; if (!chunk) return;
const ts = readString(payload["ts"]) ?? event.createdAt;
const stream = const stream =
readString(payload["stream"]) === "stderr" readString(payload["stream"]) === "stderr"
? "stderr" ? "stderr"
@@ -209,10 +210,10 @@ export function useLiveRunTranscripts({
? "system" ? "system"
: "stdout"; : "stdout";
appendChunks(runId, [{ appendChunks(runId, [{
ts: event.createdAt, ts,
stream, stream,
chunk, chunk,
dedupeKey: `socket:log:${runId}:${event.createdAt}:${stream}:${chunk}`, dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
}]); }]);
return; return;
} }