Tighten live run transcript streaming and stdout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user