import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";import type { EnvironmentId } from "@t3tools/contracts";import { CheckIcon, ChevronRightIcon, CopyIcon, ExternalLinkIcon, NetworkIcon } from "lucide-react";import { Component, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, Suspense, use, useCallback, useEffect, useMemo, useRef, useState,} from "react"; import { useTheme } from "~/hooks/useTheme";import { type DiffThemeName, resolveDiffThemeName } from "~/lib/diffRendering";import { cn } from "~/lib/utils";import { useEnvironmentQuery } from "~/state/query";import { useAtomCommand } from "~/state/use-atom-command";import { workflowEnvironment } from "~/state/workflow";import { isRemoteWorkflowRun, type WorkflowRun, type WorkflowRunAgent, type WorkflowRunStatus, workflowRunTitle,} from "~/workflow-logic";import { Button } from "../ui/button";import { AgentRowContent, PhaseHeader, WorkflowStatusChip, safeWorkflowSessionUrl,} from "./workflowUi";import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; type WorkflowTabId = "run" | "script" | "logs"; const WORKFLOW_TABS: ReadonlyArray<{ id: WorkflowTabId; label: string }> = [ { id: "run", label: "Run" }, { id: "script", label: "Script" }, { id: "logs", label: "Logs" },]; // ---------------------------------------------------------------------------// Root — handles the not-found empty state, then delegates to the inner panel// so every data hook runs unconditionally.// --------------------------------------------------------------------------- export function WorkflowPanel(props: { workflowRun: WorkflowRun | null; environmentId: EnvironmentId; onStop?: (() => void) | undefined;}): ReactElement { if (props.workflowRun === null) { return ( <div className="flex h-full min-w-0 items-center justify-center p-6 text-muted-foreground text-sm"> Workflow not found </div> ); } return ( <WorkflowPanelInner run={props.workflowRun} environmentId={props.environmentId} onStop={props.onStop} /> );} function WorkflowPanelInner({ run, environmentId, onStop,}: { run: WorkflowRun; environmentId: EnvironmentId; onStop?: (() => void) | undefined;}): ReactElement { const [tab, setTab] = useState<WorkflowTabId>("run"); const { resolvedTheme } = useTheme(); const themeName = resolveDiffThemeName(resolvedTheme); const scriptPath = run.handles?.scriptPath; const transcriptDir = run.handles?.transcriptDir; const remote = isRemoteWorkflowRun(run); const isTerminal = run.status !== "running"; const scriptQuery = useEnvironmentQuery( tab === "script" && scriptPath !== undefined ? workflowEnvironment.readScript({ environmentId, input: { scriptPath } }) : null, ); const journalQuery = useEnvironmentQuery( tab === "logs" && transcriptDir !== undefined ? workflowEnvironment.readJournal({ environmentId, input: { transcriptDir } }) : null, ); return ( <div className="flex h-full min-w-0 flex-col"> <div className="flex items-center gap-2 border-border/60 border-b px-3 py-2"> <NetworkIcon className="size-4 shrink-0 text-muted-foreground" /> <span className="truncate font-medium text-foreground text-sm"> {workflowRunTitle(run)} </span> <WorkflowStatusChip status={run.status} /> <div className="ml-auto flex items-center gap-1.5"> {isTerminal && run.handles?.runId !== undefined && scriptPath !== undefined && ( <CopyResumeButton scriptPath={scriptPath} runId={run.handles.runId} /> )} {onStop && ( <Button type="button" size="xs" variant="outline" onClick={onStop}> Stop </Button> )} </div> </div> <div className="flex items-center gap-1 border-border/60 border-b px-2 py-1.5"> {WORKFLOW_TABS.map((entry) => ( <button key={entry.id} type="button" data-active-tab={tab === entry.id} onClick={() => setTab(entry.id)} className={cn( "flex h-7 items-center rounded-md px-2.5 text-sm transition-colors", tab === entry.id ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > {entry.label} </button> ))} </div> <div className="min-h-0 flex-1 overflow-y-auto p-2.5"> {tab === "run" && ( <RunTab run={run} environmentId={environmentId} transcriptDir={transcriptDir} remote={remote} /> )} {tab === "script" && ( <ScriptTab scriptPath={scriptPath} query={scriptQuery} themeName={themeName} /> )} {tab === "logs" && ( <LogsTab logs={run.logs} transcriptDir={transcriptDir} query={journalQuery} /> )} </div> </div> );} // ---------------------------------------------------------------------------// Header copy button// --------------------------------------------------------------------------- function CopyResumeButton({ scriptPath, runId,}: { scriptPath: string; runId: string;}): ReactElement { const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 1200, target: "workflow resume command", }); const handleCopy = useCallback(() => { copyToClipboard(`Workflow({ scriptPath: "${scriptPath}", resumeFromRunId: "${runId}" })`); }, [copyToClipboard, runId, scriptPath]); return ( <Button type="button" size="xs" variant="outline" onClick={handleCopy}> {isCopied ? <CheckIcon /> : <CopyIcon />} Copy resume command </Button> );} // ---------------------------------------------------------------------------// Run tab// --------------------------------------------------------------------------- function RunTab({ run, environmentId, transcriptDir, remote,}: { run: WorkflowRun; environmentId: EnvironmentId; transcriptDir: string | undefined; remote: boolean;}): ReactElement { if (remote) { const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); if (sessionUrl === undefined) { return <MutedBody text="Running in the cloud" />; } return ( <a href={sessionUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 rounded-md px-0.5 py-1 text-[12px] text-foreground/82 leading-5 hover:bg-accent/20 hover:text-foreground" > <ExternalLinkIcon className="size-3.5 shrink-0 text-muted-foreground" /> Running in the cloud — open session </a> ); } if (run.phases.length === 0) { return <MutedBody text="No agents yet" />; } return ( <div className="space-y-px"> {run.phases.map((phase) => ( <div key={`${run.taskId}:${phase.index}`}> <PhaseHeader phase={phase} /> {phase.agents.map((agent) => ( <ExpandableAgentRow key={`${run.taskId}:${agent.index}`} agent={agent} environmentId={environmentId} transcriptDir={transcriptDir} runStatus={run.status} /> ))} </div> ))} </div> );} const stopPropagation = (event: ReactMouseEvent) => event.stopPropagation(); function ExpandableAgentRow({ agent, environmentId, transcriptDir, runStatus,}: { agent: WorkflowRunAgent; environmentId: EnvironmentId; transcriptDir: string | undefined; runStatus: WorkflowRunStatus;}): ReactElement { const [expanded, setExpanded] = useState(false); const agentId = agent.agentId; const canExpand = agentId !== undefined && transcriptDir !== undefined && agent.isolation !== "remote"; const toggle = useCallback(() => setExpanded((value) => !value), []); const onKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLDivElement>) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(); } }, [toggle], ); const leading = canExpand ? ( <ChevronRightIcon className={cn( "size-3 shrink-0 text-muted-foreground/60 transition-transform", expanded && "rotate-90", )} /> ) : ( <span className="size-3 shrink-0" /> ); return ( <div className={cn( "flex flex-col rounded-md px-0.5 py-0.5 transition-colors", canExpand && "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-inset", )} {...(canExpand ? { role: "button", tabIndex: 0, "aria-expanded": expanded, onClick: toggle, onKeyDown } : {})} > <AgentRowContent agent={agent} leading={leading} /> {canExpand && expanded && agentId !== undefined && transcriptDir !== undefined && ( <AgentTranscriptView environmentId={environmentId} transcriptDir={transcriptDir} agentId={agentId} runStatus={runStatus} /> )} </div> );} // ---------------------------------------------------------------------------// Transcript view (cursor-paged, polled while running)// --------------------------------------------------------------------------- interface TranscriptEntry { kind: "text" | "tool"; text: string;} const TRANSCRIPT_MAX_RETAINED_LINES = 600;const TRANSCRIPT_TEXT_MAX_CHARS = 600;const TRANSCRIPT_TOOL_PREVIEW_CHARS = 120; function clipTranscriptText(text: string, limit: number): string { const trimmed = text.trim(); return trimmed.length > limit ? `${trimmed.slice(0, limit)}\u2026` : trimmed;} function toolInputPreview(input: unknown): string | undefined { if (typeof input !== "object" || input === null) { return undefined; } const record = input as Record<string, unknown>; // The most informative single field per common tool, else the first string. const preferred = record.command ?? record.file_path ?? record.pattern ?? record.prompt; const value = typeof preferred === "string" ? preferred : Object.values(record).find((entry): entry is string => typeof entry === "string"); return value !== undefined ? clipTranscriptText(value, TRANSCRIPT_TOOL_PREVIEW_CHARS) : undefined;} /** * Distill one transcript JSONL line into displayable entries. Only assistant * text and tool calls render; user turns, tool results, attachments, thinking, * and harness metadata are skipped — they dominate line counts on long runs * and read as noise ("user", "attachment", ...) when printed raw. */function parseTranscriptEntries(raw: string): TranscriptEntry[] { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return []; } if (typeof parsed !== "object" || parsed === null) { return []; } const record = parsed as Record<string, unknown>; if (record.type !== "assistant") { return []; } const message = typeof record.message === "object" && record.message !== null ? (record.message as Record<string, unknown>) : record; const content = message.content; if (typeof content === "string") { const text = clipTranscriptText(content, TRANSCRIPT_TEXT_MAX_CHARS); return text.length > 0 ? [{ kind: "text", text }] : []; } if (!Array.isArray(content)) { return []; } const entries: TranscriptEntry[] = []; for (const block of content) { if (typeof block !== "object" || block === null) { continue; } const blockRecord = block as Record<string, unknown>; if (blockRecord.type === "text" && typeof blockRecord.text === "string") { const text = clipTranscriptText(blockRecord.text, TRANSCRIPT_TEXT_MAX_CHARS); if (text.length > 0) { entries.push({ kind: "text", text }); } } else if (blockRecord.type === "tool_use" && typeof blockRecord.name === "string") { const preview = toolInputPreview(blockRecord.input); entries.push({ kind: "tool", text: preview !== undefined ? `${blockRecord.name} ${preview}` : blockRecord.name, }); } } return entries;} function AgentTranscriptView({ environmentId, transcriptDir, agentId, runStatus,}: { environmentId: EnvironmentId; transcriptDir: string; agentId: string; runStatus: WorkflowRunStatus;}): ReactElement { const runTranscript = useAtomCommand( workflowEnvironment.readAgentTranscript, "workflow read transcript", ); const [lines, setLines] = useState<string[]>([]); const [trimmed, setTrimmed] = useState(false); const [failed, setFailed] = useState(false); const [loading, setLoading] = useState(false); // Render-visible mirror of completeRef: "have we caught up to EOF at least // once" — before that, an absence of parsed entries just means we are // still paging, not that the agent produced no output. const [caughtUp, setCaughtUp] = useState(false); const transcriptEntries = useMemo(() => lines.flatMap(parseTranscriptEntries), [lines]); const nextLineRef = useRef(0); const completeRef = useRef(false); const loadingRef = useRef(false); const loadMore = useCallback(async () => { // `complete` only means the last read caught up to end-of-file; a live // run keeps appending, so polling must keep re-reading past prior EOF. if (loadingRef.current) { return; } loadingRef.current = true; setLoading(true); const result = await runTranscript({ environmentId, input: { transcriptDir, agentId, afterLine: nextLineRef.current }, }); loadingRef.current = false; setLoading(false); if (result._tag !== "Success") { setFailed(true); return; } setFailed(false); nextLineRef.current = result.value.nextLine; completeRef.current = result.value.complete; if (result.value.complete) { setCaughtUp(true); } if (result.value.lines.length > 0) { // Long-running agents can produce transcripts far larger than a view // needs — retain a bounded tail so memory stays flat on million-token // threads. setLines((prev) => { const merged = [...prev, ...result.value.lines]; if (merged.length > TRANSCRIPT_MAX_RETAINED_LINES) { setTrimmed(true); return merged.slice(-TRANSCRIPT_MAX_RETAINED_LINES); } return merged; }); } }, [agentId, environmentId, runTranscript, transcriptDir]); // Drain all currently-available pages when the row opens. useEffect(() => { const control = { cancelled: false }; const drain = async () => { while (!control.cancelled && !completeRef.current) { const before = nextLineRef.current; await loadMore(); if (control.cancelled || nextLineRef.current === before) { break; } } }; void drain(); return () => { control.cancelled = true; }; }, [loadMore]); // Keep polling for new lines while the run is live; when the run settles // (or the row opens on an already-terminal run), fetch once more so lines // appended after the last poll tick are not lost. useEffect(() => { if (runStatus !== "running") { void loadMore(); return; } const id = setInterval(() => { void loadMore(); }, 2000); return () => clearInterval(id); }, [loadMore, runStatus]); return ( <div className="mt-1 ml-4 max-h-72 overflow-y-auto rounded-md border border-border/60 bg-muted/30 p-1.5 font-mono text-[11px] leading-4" onClick={stopPropagation}Lines 1–500 of 753. Load more
import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import type { EnvironmentId } from "@t3tools/contracts"; import { CheckIcon, ChevronRightIcon, CopyIcon, ExternalLinkIcon, NetworkIcon } from "lucide-react"; import { Component, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, Suspense, use, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTheme } from "~/hooks/useTheme"; import { type DiffThemeName, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { workflowEnvironment } from "~/state/workflow"; import { isRemoteWorkflowRun, type WorkflowRun, type WorkflowRunAgent, type WorkflowRunStatus, workflowRunTitle, } from "~/workflow-logic"; import { Button } from "../ui/button"; import { AgentRowContent, PhaseHeader, WorkflowStatusChip, safeWorkflowSessionUrl, } from "./workflowUi"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; type WorkflowTabId = "run" | "script" | "logs"; const WORKFLOW_TABS: ReadonlyArray<{ id: WorkflowTabId; label: string }> = [ { id: "run", label: "Run" }, { id: "script", label: "Script" }, { id: "logs", label: "Logs" }, ]; // --------------------------------------------------------------------------- // Root — handles the not-found empty state, then delegates to the inner panel // so every data hook runs unconditionally. // --------------------------------------------------------------------------- export function WorkflowPanel(props: { workflowRun: WorkflowRun | null; environmentId: EnvironmentId; onStop?: (() => void) | undefined; }): ReactElement { if (props.workflowRun === null) { return ( <div className="flex h-full min-w-0 items-center justify-center p-6 text-muted-foreground text-sm"> Workflow not found </div> ); } return ( <WorkflowPanelInner run={props.workflowRun} environmentId={props.environmentId} onStop={props.onStop} /> ); } function WorkflowPanelInner({ run, environmentId, onStop, }: { run: WorkflowRun; environmentId: EnvironmentId; onStop?: (() => void) | undefined; }): ReactElement { const [tab, setTab] = useState<WorkflowTabId>("run"); const { resolvedTheme } = useTheme(); const themeName = resolveDiffThemeName(resolvedTheme); const scriptPath = run.handles?.scriptPath; const transcriptDir = run.handles?.transcriptDir; const remote = isRemoteWorkflowRun(run); const isTerminal = run.status !== "running"; const scriptQuery = useEnvironmentQuery( tab === "script" && scriptPath !== undefined ? workflowEnvironment.readScript({ environmentId, input: { scriptPath } }) : null, ); const journalQuery = useEnvironmentQuery( tab === "logs" && transcriptDir !== undefined ? workflowEnvironment.readJournal({ environmentId, input: { transcriptDir } }) : null, ); return ( <div className="flex h-full min-w-0 flex-col"> <div className="flex items-center gap-2 border-border/60 border-b px-3 py-2"> <NetworkIcon className="size-4 shrink-0 text-muted-foreground" /> <span className="truncate font-medium text-foreground text-sm"> {workflowRunTitle(run)} </span> <WorkflowStatusChip status={run.status} /> <div className="ml-auto flex items-center gap-1.5"> {isTerminal && run.handles?.runId !== undefined && scriptPath !== undefined && ( <CopyResumeButton scriptPath={scriptPath} runId={run.handles.runId} /> )} {onStop && ( <Button type="button" size="xs" variant="outline" onClick={onStop}> Stop </Button> )} </div> </div> <div className="flex items-center gap-1 border-border/60 border-b px-2 py-1.5"> {WORKFLOW_TABS.map((entry) => ( <button key={entry.id} type="button" data-active-tab={tab === entry.id} onClick={() => setTab(entry.id)} className={cn( "flex h-7 items-center rounded-md px-2.5 text-sm transition-colors", tab === entry.id ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > {entry.label} </button> ))} </div> <div className="min-h-0 flex-1 overflow-y-auto p-2.5"> {tab === "run" && ( <RunTab run={run} environmentId={environmentId} transcriptDir={transcriptDir} remote={remote} /> )} {tab === "script" && ( <ScriptTab scriptPath={scriptPath} query={scriptQuery} themeName={themeName} /> )} {tab === "logs" && ( <LogsTab logs={run.logs} transcriptDir={transcriptDir} query={journalQuery} /> )} </div> </div> ); } // --------------------------------------------------------------------------- // Header copy button // --------------------------------------------------------------------------- function CopyResumeButton({ scriptPath, runId, }: { scriptPath: string; runId: string; }): ReactElement { const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 1200, target: "workflow resume command", }); const handleCopy = useCallback(() => { copyToClipboard(`Workflow({ scriptPath: "${scriptPath}", resumeFromRunId: "${runId}" })`); }, [copyToClipboard, runId, scriptPath]); return ( <Button type="button" size="xs" variant="outline" onClick={handleCopy}> {isCopied ? <CheckIcon /> : <CopyIcon />} Copy resume command </Button> ); } // --------------------------------------------------------------------------- // Run tab // --------------------------------------------------------------------------- function RunTab({ run, environmentId, transcriptDir, remote, }: { run: WorkflowRun; environmentId: EnvironmentId; transcriptDir: string | undefined; remote: boolean; }): ReactElement { if (remote) { const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); if (sessionUrl === undefined) { return <MutedBody text="Running in the cloud" />; } return ( <a href={sessionUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 rounded-md px-0.5 py-1 text-[12px] text-foreground/82 leading-5 hover:bg-accent/20 hover:text-foreground" > <ExternalLinkIcon className="size-3.5 shrink-0 text-muted-foreground" /> Running in the cloud — open session </a> ); } if (run.phases.length === 0) { return <MutedBody text="No agents yet" />; } return ( <div className="space-y-px"> {run.phases.map((phase) => ( <div key={`${run.taskId}:${phase.index}`}> <PhaseHeader phase={phase} /> {phase.agents.map((agent) => ( <ExpandableAgentRow key={`${run.taskId}:${agent.index}`} agent={agent} environmentId={environmentId} transcriptDir={transcriptDir} runStatus={run.status} /> ))} </div> ))} </div> ); } const stopPropagation = (event: ReactMouseEvent) => event.stopPropagation(); function ExpandableAgentRow({ agent, environmentId, transcriptDir, runStatus, }: { agent: WorkflowRunAgent; environmentId: EnvironmentId; transcriptDir: string | undefined; runStatus: WorkflowRunStatus; }): ReactElement { const [expanded, setExpanded] = useState(false); const agentId = agent.agentId; const canExpand = agentId !== undefined && transcriptDir !== undefined && agent.isolation !== "remote"; const toggle = useCallback(() => setExpanded((value) => !value), []); const onKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLDivElement>) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(); } }, [toggle], ); const leading = canExpand ? ( <ChevronRightIcon className={cn( "size-3 shrink-0 text-muted-foreground/60 transition-transform", expanded && "rotate-90", )} /> ) : ( <span className="size-3 shrink-0" /> ); return ( <div className={cn( "flex flex-col rounded-md px-0.5 py-0.5 transition-colors", canExpand && "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-inset", )} {...(canExpand ? { role: "button", tabIndex: 0, "aria-expanded": expanded, onClick: toggle, onKeyDown } : {})} > <AgentRowContent agent={agent} leading={leading} /> {canExpand && expanded && agentId !== undefined && transcriptDir !== undefined && ( <AgentTranscriptView environmentId={environmentId} transcriptDir={transcriptDir} agentId={agentId} runStatus={runStatus} /> )} </div> ); } // --------------------------------------------------------------------------- // Transcript view (cursor-paged, polled while running) // --------------------------------------------------------------------------- interface TranscriptEntry { kind: "text" | "tool"; text: string; } const TRANSCRIPT_MAX_RETAINED_LINES = 600; const TRANSCRIPT_TEXT_MAX_CHARS = 600; const TRANSCRIPT_TOOL_PREVIEW_CHARS = 120; function clipTranscriptText(text: string, limit: number): string { const trimmed = text.trim(); return trimmed.length > limit ? `${trimmed.slice(0, limit)}\u2026` : trimmed; } function toolInputPreview(input: unknown): string | undefined { if (typeof input !== "object" || input === null) { return undefined; } const record = input as Record<string, unknown>; // The most informative single field per common tool, else the first string. const preferred = record.command ?? record.file_path ?? record.pattern ?? record.prompt; const value = typeof preferred === "string" ? preferred : Object.values(record).find((entry): entry is string => typeof entry === "string"); return value !== undefined ? clipTranscriptText(value, TRANSCRIPT_TOOL_PREVIEW_CHARS) : undefined; } /** * Distill one transcript JSONL line into displayable entries. Only assistant * text and tool calls render; user turns, tool results, attachments, thinking, * and harness metadata are skipped — they dominate line counts on long runs * and read as noise ("user", "attachment", ...) when printed raw. */ function parseTranscriptEntries(raw: string): TranscriptEntry[] { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return []; } if (typeof parsed !== "object" || parsed === null) { return []; } const record = parsed as Record<string, unknown>; if (record.type !== "assistant") { return []; } const message = typeof record.message === "object" && record.message !== null ? (record.message as Record<string, unknown>) : record; const content = message.content; if (typeof content === "string") { const text = clipTranscriptText(content, TRANSCRIPT_TEXT_MAX_CHARS); return text.length > 0 ? [{ kind: "text", text }] : []; } if (!Array.isArray(content)) { return []; } const entries: TranscriptEntry[] = []; for (const block of content) { if (typeof block !== "object" || block === null) { continue; } const blockRecord = block as Record<string, unknown>; if (blockRecord.type === "text" && typeof blockRecord.text === "string") { const text = clipTranscriptText(blockRecord.text, TRANSCRIPT_TEXT_MAX_CHARS); if (text.length > 0) { entries.push({ kind: "text", text }); } } else if (blockRecord.type === "tool_use" && typeof blockRecord.name === "string") { const preview = toolInputPreview(blockRecord.input); entries.push({ kind: "tool", text: preview !== undefined ? `${blockRecord.name} ${preview}` : blockRecord.name, }); } } return entries; } function AgentTranscriptView({ environmentId, transcriptDir, agentId, runStatus, }: { environmentId: EnvironmentId; transcriptDir: string; agentId: string; runStatus: WorkflowRunStatus; }): ReactElement { const runTranscript = useAtomCommand( workflowEnvironment.readAgentTranscript, "workflow read transcript", ); const [lines, setLines] = useState<string[]>([]); const [trimmed, setTrimmed] = useState(false); const [failed, setFailed] = useState(false); const [loading, setLoading] = useState(false); // Render-visible mirror of completeRef: "have we caught up to EOF at least // once" — before that, an absence of parsed entries just means we are // still paging, not that the agent produced no output. const [caughtUp, setCaughtUp] = useState(false); const transcriptEntries = useMemo(() => lines.flatMap(parseTranscriptEntries), [lines]); const nextLineRef = useRef(0); const completeRef = useRef(false); const loadingRef = useRef(false); const loadMore = useCallback(async () => { // `complete` only means the last read caught up to end-of-file; a live // run keeps appending, so polling must keep re-reading past prior EOF. if (loadingRef.current) { return; } loadingRef.current = true; setLoading(true); const result = await runTranscript({ environmentId, input: { transcriptDir, agentId, afterLine: nextLineRef.current }, }); loadingRef.current = false; setLoading(false); if (result._tag !== "Success") { setFailed(true); return; } setFailed(false); nextLineRef.current = result.value.nextLine; completeRef.current = result.value.complete; if (result.value.complete) { setCaughtUp(true); } if (result.value.lines.length > 0) { // Long-running agents can produce transcripts far larger than a view // needs — retain a bounded tail so memory stays flat on million-token // threads. setLines((prev) => { const merged = [...prev, ...result.value.lines]; if (merged.length > TRANSCRIPT_MAX_RETAINED_LINES) { setTrimmed(true); return merged.slice(-TRANSCRIPT_MAX_RETAINED_LINES); } return merged; }); } }, [agentId, environmentId, runTranscript, transcriptDir]); // Drain all currently-available pages when the row opens. useEffect(() => { const control = { cancelled: false }; const drain = async () => { while (!control.cancelled && !completeRef.current) { const before = nextLineRef.current; await loadMore(); if (control.cancelled || nextLineRef.current === before) { break; } } }; void drain(); return () => { control.cancelled = true; }; }, [loadMore]); // Keep polling for new lines while the run is live; when the run settles // (or the row opens on an already-terminal run), fetch once more so lines // appended after the last poll tick are not lost. useEffect(() => { if (runStatus !== "running") { void loadMore(); return; } const id = setInterval(() => { void loadMore(); }, 2000); return () => clearInterval(id); }, [loadMore, runStatus]); return ( <div className="mt-1 ml-4 max-h-72 overflow-y-auto rounded-md border border-border/60 bg-muted/30 p-1.5 font-mono text-[11px] leading-4" onClick={stopPropagation}