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 (
Workflow not found
);
}
return (
);
}
function WorkflowPanelInner({
run,
environmentId,
onStop,
}: {
run: WorkflowRun;
environmentId: EnvironmentId;
onStop?: (() => void) | undefined;
}): ReactElement {
const [tab, setTab] = useState("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 (
{workflowRunTitle(run)}
{isTerminal && run.handles?.runId !== undefined && scriptPath !== undefined && (
)}
{onStop && (
)}
{WORKFLOW_TABS.map((entry) => (
))}
{tab === "run" && (
)}
{tab === "script" && (
)}
{tab === "logs" && (
)}
);
}
// ---------------------------------------------------------------------------
// 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 (
);
}
// ---------------------------------------------------------------------------
// 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 ;
}
return (
Running in the cloud — open session
);
}
if (run.phases.length === 0) {
return ;
}
return (
{run.phases.map((phase) => (
{phase.agents.map((agent) => (
))}
))}
);
}
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) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
},
[toggle],
);
const leading = canExpand ? (
) : (
);
return (
{canExpand && expanded && agentId !== undefined && transcriptDir !== undefined && (
)}
);
}
// ---------------------------------------------------------------------------
// 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;
// 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;
if (record.type !== "assistant") {
return [];
}
const message =
typeof record.message === "object" && record.message !== null
? (record.message as Record)
: 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;
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([]);
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 (
{transcriptEntries.length === 0 ? (
failed ? (
Failed to load transcript.
) : loading || !caughtUp ? (
Loading transcript…
) : (
No assistant output yet.
)
) : (
<>
{trimmed && (
Earlier activity trimmed — showing the latest entries.
)}
{transcriptEntries.map((entry, index) => (
{entry.kind === "tool" ? `→ ${entry.text}` : entry.text}
))}
>
)}
{failed && lines.length > 0 && (
Failed to load more transcript.
)}
);
}
// ---------------------------------------------------------------------------
// Script tab
// ---------------------------------------------------------------------------
let cachedScriptHighlighter: Promise | undefined;
function getScriptHighlighter(): Promise {
cachedScriptHighlighter ??= getSharedHighlighter({
themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
langs: ["javascript" as SupportedLanguages],
preferredHighlighter: "shiki-js",
});
return cachedScriptHighlighter;
}
class WorkflowCodeErrorBoundary extends Component<
{ fallback: ReactNode; children: ReactNode },
{ hasError: boolean }
> {
constructor(props: { fallback: ReactNode; children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
override render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
function ScriptHighlight({
source,
themeName,
}: {
source: string;
themeName: DiffThemeName;
}): ReactElement {
const highlighter = use(getScriptHighlighter());
const html = useMemo(
() => highlighter.codeToHtml(source, { lang: "javascript", theme: themeName }),
[highlighter, source, themeName],
);
return (
);
}
function ScriptTab({
scriptPath,
query,
themeName,
}: {
scriptPath: string | undefined;
query: {
data: { source: string; truncated: boolean } | null;
error: string | null;
isPending: boolean;
};
themeName: DiffThemeName;
}): ReactElement {
if (scriptPath === undefined) {
return ;
}
if (query.error !== null) {
return {query.error}
;
}
if (query.data === null) {
return ;
}
const { source, truncated } = query.data;
const fallback = (
{source}
);
return (
{truncated && (
Script truncated for display.
)}
);
}
// ---------------------------------------------------------------------------
// Logs tab
// ---------------------------------------------------------------------------
interface JournalEntry {
agentId: string;
hasResult: boolean;
resultJson?: string | undefined;
resultTruncated?: boolean | undefined;
}
function LogsTab({
logs,
transcriptDir,
query,
}: {
logs: string[];
transcriptDir: string | undefined;
query: {
data: { entries: readonly JournalEntry[]; truncated: boolean } | null;
error: string | null;
isPending: boolean;
};
}): ReactElement {
return (
{logs.length === 0 ? (
) : (
{logs.map((log, index) => (
// oxlint-disable-next-line no-array-index-key -- logs are append-only, index is stable
{log}
))}
)}
{transcriptDir !== undefined && (
Results
{query.error !== null ? (
{query.error}
) : query.data === null ? (
query.isPending ? (
) : null
) : query.data.entries.length === 0 ? (
) : (
{query.data.entries.map((entry) => (
))}
)}
{query.data?.truncated && (
Results truncated.
)}
)}
);
}
function JournalResultRow({ entry }: { entry: JournalEntry }): ReactElement {
const [expanded, setExpanded] = useState(false);
const resultJson = entry.resultJson;
return (
{entry.agentId}
{!entry.hasResult && pending}
{resultJson !== undefined &&
(expanded ? (
setExpanded(false)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded(false);
}
}}
>
{resultJson}
{entry.resultTruncated && (
Result truncated.
)}
) : (
))}
);
}
// ---------------------------------------------------------------------------
// Shared muted body
// ---------------------------------------------------------------------------
function MutedBody({ text }: { text: string }): ReactElement {
return {text}
;
}