753 lines · 23.3 KB · TypeScript
RawDownload
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.