mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
1881 lines
75 KiB
TypeScript
1881 lines
75 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo, forwardRef } from "react";
|
||
import { useWorkspaceId } from "@multica/core/hooks";
|
||
import { useTraceIssueLabels } from "./use-trace-issue-labels";
|
||
import { Virtuoso, type VirtuosoHandle, type Components } from "react-virtuoso";
|
||
import {
|
||
Bot,
|
||
Brain,
|
||
CircleAlert,
|
||
CheckCircle2,
|
||
XCircle,
|
||
X,
|
||
Loader2,
|
||
Copy,
|
||
Check,
|
||
ChevronRight,
|
||
Filter,
|
||
FilePen,
|
||
FileText,
|
||
Search,
|
||
Terminal,
|
||
Wrench,
|
||
MoreHorizontal,
|
||
ArrowDownNarrowWide,
|
||
ArrowUpNarrowWide,
|
||
Info,
|
||
Coins,
|
||
GitBranch,
|
||
} from "lucide-react";
|
||
import { cn } from "@multica/ui/lib/utils";
|
||
import { copyText } from "@multica/ui/lib/clipboard";
|
||
import { Button } from "@multica/ui/components/ui/button";
|
||
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuTrigger,
|
||
DropdownMenuContent,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuCheckboxItem,
|
||
DropdownMenuItem,
|
||
} from "@multica/ui/components/ui/dropdown-menu";
|
||
import { ActorAvatar } from "../actor-avatar";
|
||
import { AttributionBadge } from "../../issues/components/attribution-badge";
|
||
import { plainTriggerSummary } from "../../issues/components/task-run-labels";
|
||
import { cancellationActorLabel, cancelReasonLabel, failureReasonLabel } from "../../agents/components/tabs/task-failure";
|
||
import { RichContent } from "../../rich-content";
|
||
import { api } from "@multica/core/api";
|
||
import {
|
||
useTranscriptViewStore,
|
||
type TranscriptFilterKey,
|
||
type TranscriptSortDirection,
|
||
} from "@multica/core/agents/stores";
|
||
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
|
||
import { resolveWorkdirCopyTarget } from "@multica/core/issues";
|
||
import { runtimeDisplayName, providerDisplayName } from "@multica/core/runtimes";
|
||
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
|
||
import { redactSecrets } from "./redact";
|
||
import {
|
||
createLiveEndFollow,
|
||
FOLLOW_EDGE_THRESHOLD,
|
||
LINE_SCROLL_PX,
|
||
} from "./transcript-follow";
|
||
import { isOutputTruncated, type TimelineItem } from "./build-timeline";
|
||
import {
|
||
buildLanes,
|
||
buildSteps,
|
||
groupSteps,
|
||
isCallStep,
|
||
isGroupRow,
|
||
rowCalls,
|
||
toolKindTotals,
|
||
type TraceCallStep,
|
||
type TraceGroupRow,
|
||
type TraceMessageStep,
|
||
type TraceRow,
|
||
type TraceStep,
|
||
} from "./build-steps";
|
||
import { buildRunOutcome } from "./run-outcome";
|
||
import { RunTimeline } from "./run-timeline";
|
||
import {
|
||
base64ByteLength,
|
||
readImageResult,
|
||
traceEventCopyText,
|
||
traceEventDetail,
|
||
traceEventLabel,
|
||
traceEventSummary,
|
||
traceToolArgSummary,
|
||
} from "./trace-event-presenter";
|
||
import type { TraceSummaryLabels } from "./trace-event-presenter";
|
||
import {
|
||
DiffDetailSurface,
|
||
FileWriteSurface,
|
||
PatchDetailSurface,
|
||
ToolDetailSurface,
|
||
} from "./detail-surfaces";
|
||
import { languageForPath } from "./diff-highlight";
|
||
import { useLocale, useT } from "../../i18n";
|
||
import {
|
||
formatTokens,
|
||
formatUsd,
|
||
summarizeTaskUsage,
|
||
} from "../../runtimes/utils";
|
||
import { formatBytes } from "../format-bytes";
|
||
import "../../editor/styles/code.css";
|
||
import "./task-transcript.css";
|
||
|
||
// The run surface, read in four layers: what happened (header), what it
|
||
// produced (outcome), where the time went (timeline), and the evidence (steps).
|
||
// Only one layer is expanded at a time — the inspector opens on demand rather
|
||
// than splitting the surface in two before the reader has asked anything.
|
||
|
||
interface AgentTranscriptDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
task: AgentTask;
|
||
items: TimelineItem[];
|
||
agentName: string;
|
||
isLive?: boolean;
|
||
/**
|
||
* Whether focus returns to the trigger when the dialog closes. Pass `true`
|
||
* only for a keyboard open, where the reader has no other way back. After a
|
||
* pointer open, returning focus is what leaves the trigger wearing a focus
|
||
* ring and its tooltip once Esc closes the log — and, on the hover-revealed
|
||
* comment action row, holds the whole row visible with the pointer long gone.
|
||
*/
|
||
finalFocus?: boolean;
|
||
/** Loading/error content while the caller retrieves the transcript. */
|
||
contentState?: React.ReactNode;
|
||
/**
|
||
* Optional content rendered between the header chips and the event list.
|
||
* Used by autopilot run rows to surface the inbound webhook trigger
|
||
* payload so it's visible regardless of whether the agent echoes it.
|
||
* The dialog stays generic — slot content is the caller's concern.
|
||
*/
|
||
headerSlot?: React.ReactNode;
|
||
}
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function formatDuration(start: string, end: string): string {
|
||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||
return formatElapsedMs(ms);
|
||
}
|
||
|
||
function formatElapsedMs(ms: number): string {
|
||
const seconds = Math.floor(ms / 1000);
|
||
if (seconds < 60) return `${seconds}s`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
const secs = seconds % 60;
|
||
return `${minutes}m ${secs}s`;
|
||
}
|
||
|
||
/** A step's own duration, in the compact form the right column carries. */
|
||
function formatStepDuration(ms: number): string {
|
||
if (ms <= 0) return "—";
|
||
if (ms < 100) return "<0.1s";
|
||
if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
||
const minutes = Math.floor(ms / 60_000);
|
||
const seconds = Math.round((ms % 60_000) / 1000);
|
||
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
||
}
|
||
|
||
/** Offset from the run's start, which is what a step's position means. */
|
||
function formatOffset(ms: number): string {
|
||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
if (minutes < 60) return `+${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||
const hours = Math.floor(minutes / 60);
|
||
return `+${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||
}
|
||
|
||
function formatRunTime(iso: string, locale: string): string {
|
||
return new Date(iso).toLocaleString(locale, {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
});
|
||
}
|
||
|
||
function timeMs(iso: string | undefined): number | undefined {
|
||
if (!iso) return undefined;
|
||
const ms = new Date(iso).getTime();
|
||
return Number.isFinite(ms) ? ms : undefined;
|
||
}
|
||
|
||
function stepFilterKey(step: TraceStep): TranscriptFilterKey {
|
||
return step.kind === "call" ? `tool:${step.tool}` : step.kind;
|
||
}
|
||
|
||
/** Lowercased haystack for the in-run search. Outputs are clipped: a match
|
||
* past 4KB of command output is not one anybody is scanning for. */
|
||
function stepHaystack(step: TraceStep): string {
|
||
if (step.kind !== "call") return (step.item.content ?? "").toLowerCase();
|
||
const input = step.call?.input ? JSON.stringify(step.call.input) : "";
|
||
return `${step.tool} ${input} ${(step.result?.output ?? "").slice(0, 4000)}`.toLowerCase();
|
||
}
|
||
|
||
function StepIcon({ step, className }: { step: TraceStep; className?: string }) {
|
||
if (!isCallStep(step)) {
|
||
if (step.kind === "thinking") return <Brain className={className} />;
|
||
if (step.kind === "error") return <CircleAlert className={className} />;
|
||
return <Bot className={className} />;
|
||
}
|
||
|
||
// Tool names differ per provider, so the input's shape picks the glyph.
|
||
const input = step.call?.input;
|
||
if (input) {
|
||
if (typeof input.command === "string") return <Terminal className={className} />;
|
||
if (typeof input.old_string === "string" || typeof input.content === "string" || input.changes)
|
||
return <FilePen className={className} />;
|
||
if (typeof input.query === "string" || typeof input.pattern === "string")
|
||
return <Search className={className} />;
|
||
if (typeof input.file_path === "string" || typeof input.path === "string")
|
||
return <FileText className={className} />;
|
||
}
|
||
return <Wrench className={className} />;
|
||
}
|
||
|
||
// ─── Run detail row (ⓘ popover) ─────────────────────────────────────────────
|
||
// One labeled fact in the diagnostic popover. When `onCopy` is given the whole
|
||
// row is a copy button (used for the workdir path).
|
||
function RunDetailRow({
|
||
label,
|
||
value,
|
||
mono,
|
||
onCopy,
|
||
copied,
|
||
copyTitle,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
mono?: boolean;
|
||
onCopy?: () => void;
|
||
copied?: boolean;
|
||
copyTitle?: string;
|
||
}) {
|
||
const valueClass = cn("min-w-0 select-text break-all text-foreground", mono && "font-mono");
|
||
if (onCopy) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onCopy}
|
||
title={copyTitle}
|
||
className="group -mx-1 grid w-[calc(100%+0.5rem)] grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-3 rounded-xs px-1 py-0.5 text-left transition-colors hover:bg-accent/60"
|
||
>
|
||
<span className="text-muted-foreground">{label}</span>
|
||
<span className="flex min-w-0 items-start gap-1.5">
|
||
<span className={cn(valueClass, "flex-1")}>{value}</span>
|
||
{copied ? (
|
||
<Check className="mt-0.5 h-3 w-3 shrink-0 text-success" />
|
||
) : (
|
||
<Copy className="mt-0.5 h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||
)}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-3">
|
||
<span className="text-muted-foreground">{label}</span>
|
||
<span className={valueClass}>{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main dialog ────────────────────────────────────────────────────────────
|
||
|
||
// Virtuoso mounts rows as direct children of its List element; carry the
|
||
// divider styling the plain list container used to provide. Defined at module
|
||
// scope — an inline `components` object would remount the list every render.
|
||
const VirtuosoList = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||
function VirtuosoList(props, ref) {
|
||
return <div ref={ref} {...props} className="divide-y" />;
|
||
},
|
||
);
|
||
const LIST_COMPONENTS: Components<TraceRow> = { List: VirtuosoList };
|
||
const COPY_FEEDBACK_DURATION_MS = 2000;
|
||
|
||
function useCopyFeedback() {
|
||
const [copied, setCopied] = useState(false);
|
||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const mountedRef = useRef(true);
|
||
|
||
useEffect(() => {
|
||
mountedRef.current = true;
|
||
return () => {
|
||
mountedRef.current = false;
|
||
if (resetTimerRef.current !== null) {
|
||
clearTimeout(resetTimerRef.current);
|
||
resetTimerRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const showCopied = useCallback(() => {
|
||
if (!mountedRef.current) return;
|
||
setCopied(true);
|
||
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
|
||
resetTimerRef.current = setTimeout(() => {
|
||
resetTimerRef.current = null;
|
||
if (mountedRef.current) setCopied(false);
|
||
}, COPY_FEEDBACK_DURATION_MS);
|
||
}, []);
|
||
|
||
return [copied, showCopied] as const;
|
||
}
|
||
|
||
export function AgentTranscriptDialog({
|
||
open,
|
||
onOpenChange,
|
||
task,
|
||
items,
|
||
agentName,
|
||
isLive = false,
|
||
finalFocus = false,
|
||
headerSlot,
|
||
contentState,
|
||
}: AgentTranscriptDialogProps) {
|
||
const { t } = useT("agents");
|
||
const locale = useLocale();
|
||
const formatText = useTraceIssueLabels(useWorkspaceId(), task.issue_id, items, open);
|
||
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
|
||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => new Set());
|
||
const [query, setQuery] = useState("");
|
||
const [elapsed, setElapsed] = useState("");
|
||
const [copied, showCopied] = useCopyFeedback();
|
||
const [copiedWorkdir, showCopiedWorkdir] = useCopyFeedback();
|
||
const [copiedBranch, showCopiedBranch] = useCopyFeedback();
|
||
const [agentInfo, setAgentInfo] = useState<Agent | null>(null);
|
||
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
|
||
const workdirCopyTarget = useMemo(
|
||
() => resolveWorkdirCopyTarget([task]),
|
||
[task],
|
||
);
|
||
const sortDirection = useTranscriptViewStore((s) => s.sortDirection);
|
||
const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection);
|
||
// Filters always persist across opens — a facet a run doesn't have simply
|
||
// no-ops (see activeFilterKeys), so there is no reason to make persistence a
|
||
// user-facing toggle.
|
||
const selectedFilterKeys = useTranscriptViewStore((s) => s.selectedFilterKeys);
|
||
const toggleFilterKey = useTranscriptViewStore((s) => s.toggleFilterKey);
|
||
const clearFilters = useTranscriptViewStore((s) => s.clearFilterKeys);
|
||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||
// Newest-first live follow (#5921): all latch decisions live in the pure
|
||
// controller (see transcript-follow.ts for the model); this component only
|
||
// wires DOM events to it. A stable instance, never re-rendered by scroll
|
||
// traffic.
|
||
const followCtl = useMemo(() => createLiveEndFollow(), []);
|
||
const detachScrollerRef = useRef<(() => void) | null>(null);
|
||
|
||
const handleScrollerRef = useCallback(
|
||
(el: HTMLElement | Window | null) => {
|
||
detachScrollerRef.current?.();
|
||
detachScrollerRef.current = null;
|
||
if (!(el instanceof HTMLElement)) return;
|
||
let inputFrame: number | null = null;
|
||
const stageInput = (delta: number) => {
|
||
followCtl.input(delta);
|
||
if (inputFrame !== null) cancelAnimationFrame(inputFrame);
|
||
inputFrame = requestAnimationFrame(() => {
|
||
inputFrame = null;
|
||
followCtl.endInputFrame();
|
||
});
|
||
};
|
||
const onWheel = (e: WheelEvent) => {
|
||
const scale =
|
||
e.deltaMode === 1 ? LINE_SCROLL_PX : e.deltaMode === 2 ? el.clientHeight : 1;
|
||
stageInput(e.deltaY * scale);
|
||
};
|
||
let touchId: number | null = null;
|
||
let lastTouchY: number | null = null;
|
||
const trackedTouch = (touches: TouchList) =>
|
||
Array.from(touches).find((touch) => touch.identifier === touchId);
|
||
const onTouchStart = (e: TouchEvent) => {
|
||
if (touchId !== null) return;
|
||
const touch = e.changedTouches[0] ?? e.touches[0];
|
||
if (!touch) return;
|
||
touchId = touch.identifier;
|
||
lastTouchY = touch.clientY;
|
||
followCtl.touchStart();
|
||
};
|
||
const onTouchMove = (e: TouchEvent) => {
|
||
const touch = trackedTouch(e.touches);
|
||
if (!touch) return;
|
||
// Finger moving up scrolls the content down (away from the live end).
|
||
if (lastTouchY !== null) stageInput(lastTouchY - touch.clientY);
|
||
lastTouchY = touch.clientY;
|
||
};
|
||
const onTouchEnd = (e: TouchEvent) => {
|
||
if (touchId === null || trackedTouch(e.touches)) return;
|
||
touchId = null;
|
||
lastTouchY = null;
|
||
followCtl.touchEnd();
|
||
};
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
// Only keys aimed at the scroller itself; Space/arrows bubbling from
|
||
// row controls are not scroll intent.
|
||
if (e.target !== el) return;
|
||
if (e.key === "ArrowDown") stageInput(LINE_SCROLL_PX);
|
||
else if (e.key === "ArrowUp") stageInput(-LINE_SCROLL_PX);
|
||
else if (e.key === "PageDown") stageInput(el.clientHeight);
|
||
// Shift+Space pages up — toward this list's live end.
|
||
else if (e.key === " ") stageInput(e.shiftKey ? -el.clientHeight : el.clientHeight);
|
||
else if (e.key === "PageUp") stageInput(-el.clientHeight);
|
||
else if (e.key === "End") followCtl.disengage();
|
||
};
|
||
// Scrollbar drags hit the scroller element itself; clicks on row
|
||
// content hit children (tracked too — enforcement must not fight text
|
||
// selection autoscroll while the button is held).
|
||
const onPointerDown = (e: MouseEvent) => {
|
||
followCtl.pointerDown(e.target === el);
|
||
};
|
||
const onPointerUp = () => {
|
||
followCtl.pointerUp();
|
||
};
|
||
const onScroll = () => {
|
||
// Enforce the follow here, on the scroll event itself: Virtuoso's
|
||
// prepend compensation lands after React effects, so an effect-timed
|
||
// snap alone stays one flush behind. NOTE: this direct write staying
|
||
// ahead of Virtuoso's own state relies on react-virtuoso registering
|
||
// its scroll listener after calling scrollerRef (true in 4.18.7); if
|
||
// that inverts, route the write through virtuosoRef.scrollTo instead.
|
||
if (followCtl.onScroll(el.scrollTop)) el.scrollTop = 0;
|
||
};
|
||
el.addEventListener("wheel", onWheel, { passive: true });
|
||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||
el.addEventListener("touchmove", onTouchMove, { passive: true });
|
||
el.addEventListener("touchend", onTouchEnd, { passive: true });
|
||
el.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
||
el.addEventListener("keydown", onKeyDown);
|
||
el.addEventListener("mousedown", onPointerDown);
|
||
window.addEventListener("mouseup", onPointerUp, { capture: true });
|
||
el.addEventListener("scroll", onScroll, { passive: true });
|
||
detachScrollerRef.current = () => {
|
||
if (inputFrame !== null) cancelAnimationFrame(inputFrame);
|
||
followCtl.endInputFrame();
|
||
el.removeEventListener("wheel", onWheel);
|
||
el.removeEventListener("touchstart", onTouchStart);
|
||
el.removeEventListener("touchmove", onTouchMove);
|
||
el.removeEventListener("touchend", onTouchEnd);
|
||
el.removeEventListener("touchcancel", onTouchEnd);
|
||
el.removeEventListener("keydown", onKeyDown);
|
||
el.removeEventListener("mousedown", onPointerDown);
|
||
window.removeEventListener("mouseup", onPointerUp, { capture: true });
|
||
el.removeEventListener("scroll", onScroll);
|
||
// The scroller can detach mid-drag (listEpoch remount); a stuck
|
||
// held-mouse flag would suppress enforcement forever.
|
||
followCtl.pointerUp();
|
||
followCtl.touchEnd();
|
||
};
|
||
},
|
||
[followCtl],
|
||
);
|
||
|
||
useEffect(() => () => detachScrollerRef.current?.(), []);
|
||
|
||
// A different run is a different reading position: drop the selection and
|
||
// any group the reader had opened.
|
||
useEffect(() => {
|
||
setSelectedSeq(null);
|
||
setExpandedGroups(new Set());
|
||
}, [task.id]);
|
||
|
||
// ── Derived model ────────────────────────────────────────────────────────
|
||
|
||
// One step per tool call, with its result folded in — see build-steps.ts for
|
||
// why the pairing is positional.
|
||
const steps = useMemo(() => buildSteps(items), [items]);
|
||
|
||
// A facet reads as what its rows look like: the glyph the rows carry, and the
|
||
// name the rows print. The first step of a kind stands in for the glyph. The
|
||
// `tool:` prefix stays in the key (it is what gets persisted) but never
|
||
// reaches the menu — the rows say `Bash`, so the facet says `Bash` too.
|
||
const filterOptions = useMemo(() => {
|
||
const options = new Map<string, { label: string; step: TraceStep }>();
|
||
for (const step of steps) {
|
||
const key = stepFilterKey(step);
|
||
if (options.has(key)) continue;
|
||
options.set(key, {
|
||
label:
|
||
step.kind === "call"
|
||
? step.tool || t(($) => $.transcript.kind_tool)
|
||
: traceEventLabel({ type: step.item.type, tool: step.item.tool }),
|
||
step,
|
||
});
|
||
}
|
||
return Array.from(options.entries()).sort((a, b) => a[1].label.localeCompare(b[1].label));
|
||
}, [steps, t]);
|
||
|
||
const filterOptionKeys = useMemo(
|
||
() => new Set(filterOptions.map(([value]) => value)),
|
||
[filterOptions],
|
||
);
|
||
|
||
const activeFilterKeys = useMemo(
|
||
() => selectedFilterKeys.filter((key) => filterOptionKeys.has(key)),
|
||
[selectedFilterKeys, filterOptionKeys],
|
||
);
|
||
|
||
const activeFilterSet = useMemo(() => new Set(activeFilterKeys), [activeFilterKeys]);
|
||
|
||
const trimmedQuery = query.trim().toLowerCase();
|
||
|
||
const filteredSteps = useMemo(() => {
|
||
if (activeFilterSet.size === 0 && trimmedQuery.length === 0) return steps;
|
||
return steps.filter((step) => {
|
||
if (activeFilterSet.size > 0 && !activeFilterSet.has(stepFilterKey(step))) return false;
|
||
if (trimmedQuery.length > 0) {
|
||
const raw = stepHaystack(step);
|
||
if (!raw.includes(trimmedQuery) && !formatText(raw).toLowerCase().includes(trimmedQuery)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}, [steps, activeFilterSet, trimmedQuery, formatText]);
|
||
|
||
// Grouping runs on what the reader is looking at: filtering breaks adjacency,
|
||
// and a group that spans a hidden step would be a lie about what ran.
|
||
const rows = useMemo(() => groupSteps(filteredSteps), [filteredSteps]);
|
||
|
||
// Apply user-chosen sort direction. Reverse is a pure presentation concern —
|
||
// the underlying steps (and their seq numbers) are untouched.
|
||
const displayRows = useMemo(
|
||
() => (sortDirection === "newest_first" ? [...rows].reverse() : rows),
|
||
[rows, sortDirection],
|
||
);
|
||
|
||
const runStart = task.started_at ?? task.dispatched_at ?? steps[0]?.startedAt;
|
||
const runStartMs = timeMs(runStart);
|
||
const lastStamp = useMemo(() => {
|
||
for (let i = steps.length - 1; i >= 0; i--) {
|
||
const step = steps[i]!;
|
||
const stamp = step.kind === "call" ? (step.endedAt ?? step.startedAt) : step.startedAt;
|
||
if (stamp) return stamp;
|
||
}
|
||
return undefined;
|
||
}, [steps]);
|
||
const runEnd = task.completed_at ?? lastStamp;
|
||
|
||
const lanes = useMemo(() => buildLanes(steps, runStart, runEnd), [steps, runStart, runEnd]);
|
||
const toolKinds = useMemo(() => toolKindTotals(steps), [steps]);
|
||
const outcome = useMemo(() => buildRunOutcome(steps), [steps]);
|
||
|
||
const selectedStep = useMemo(() => {
|
||
if (selectedSeq === null) return null;
|
||
return steps.find((step) => step.seq === selectedSeq) ?? null;
|
||
}, [steps, selectedSeq]);
|
||
|
||
const selectedOffsetMs =
|
||
selectedStep && runStartMs !== undefined
|
||
? Math.max(0, (timeMs(selectedStep.startedAt) ?? runStartMs) - runStartMs)
|
||
: undefined;
|
||
|
||
// Newest-first shows live events as PREPENDS, and Virtuoso items opt out of
|
||
// native scroll anchoring (`overflow-anchor: none`), so without compensation
|
||
// every 500ms flush shifts the reading position. Virtuoso's contract: a
|
||
// decrease of firstItemIndex by N anchors the viewport across an N-item
|
||
// prepend, and the value must never increase within an instance — counting
|
||
// down from a large base satisfies that while the list only grows. Sort,
|
||
// filter, or task changes can shrink the list, so `listEpoch` remounts the
|
||
// instance (fresh at top) instead of letting firstItemIndex climb.
|
||
const firstItemIndex =
|
||
sortDirection === "newest_first" ? 1_000_000 - displayRows.length : 0;
|
||
const listEpoch = `${task.id}:${sortDirection}:${activeFilterKeys.join(",")}:${trimmedQuery}`;
|
||
|
||
const handleSortDirectionChange = useCallback(
|
||
(dir: TranscriptSortDirection) => {
|
||
if (dir === sortDirection) return;
|
||
setSortDirection(dir);
|
||
virtuosoRef.current?.scrollTo({ top: 0 });
|
||
},
|
||
[sortDirection, setSortDirection],
|
||
);
|
||
|
||
useLayoutEffect(() => {
|
||
followCtl.setActive(isLive && sortDirection === "newest_first");
|
||
}, [followCtl, isLive, sortDirection]);
|
||
|
||
// A new list instance (task/sort/filter change) mounts at its live end with
|
||
// the follow engaged — the same contract as first open.
|
||
useLayoutEffect(() => {
|
||
followCtl.reset();
|
||
}, [followCtl, listEpoch]);
|
||
|
||
// Live follow for newest-first (#5921): while the latch is engaged, every
|
||
// flush snaps back to the newest row — prepend anchoring would otherwise
|
||
// hold the viewport on the previous first row. Instant (not smooth): a
|
||
// smooth animation spans multiple flushes and its in-flight position reads
|
||
// as user displacement. The scroll-event enforcement in handleScrollerRef
|
||
// is the authoritative pin; this effect just shortens the first-paint gap.
|
||
const displayCount = displayRows.length;
|
||
useLayoutEffect(() => {
|
||
if (!followCtl.isFollowing()) return;
|
||
virtuosoRef.current?.scrollToIndex({ index: 0, align: "start", behavior: "auto" });
|
||
}, [followCtl, displayCount]);
|
||
|
||
// Fetch agent and runtime metadata when dialog opens
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
let cancelled = false;
|
||
|
||
if (task.agent_id) {
|
||
api.getAgent(task.agent_id).then((agent) => {
|
||
if (!cancelled) setAgentInfo(agent);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
if (task.runtime_id) {
|
||
api.listRuntimes().then((runtimes) => {
|
||
if (cancelled) return;
|
||
const rt = runtimes.find((r) => r.id === task.runtime_id);
|
||
if (rt) setRuntimeInfo(rt);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
return () => { cancelled = true; };
|
||
}, [open, task.agent_id, task.runtime_id]);
|
||
|
||
// Elapsed time for live tasks
|
||
useEffect(() => {
|
||
if (!isLive || (!task.started_at && !task.dispatched_at)) return;
|
||
const startRef = task.started_at ?? task.dispatched_at!;
|
||
const update = () => setElapsed(formatElapsedMs(Date.now() - new Date(startRef).getTime()));
|
||
update();
|
||
const interval = setInterval(update, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [isLive, task.started_at, task.dispatched_at]);
|
||
|
||
const scrollToStep = useCallback(
|
||
(seq: number) => {
|
||
const index = displayRows.findIndex((row) => row.seq === seq || rowCalls(row).some((c) => c.seq === seq));
|
||
if (index < 0) return;
|
||
// Explicit navigation away from the live end unlatches the newest-first
|
||
// follow — otherwise the scroll-event enforcement would pin the viewport
|
||
// straight back to the top. Scrolling back re-engages it as usual.
|
||
if (index > 0) followCtl.disengage();
|
||
virtuosoRef.current?.scrollToIndex({ index, align: "center", behavior: "smooth" });
|
||
},
|
||
[displayRows, followCtl],
|
||
);
|
||
|
||
// Clicking the timeline lands on the first step at or after that offset.
|
||
const handleSeek = useCallback(
|
||
(offsetMs: number) => {
|
||
if (runStartMs === undefined) return;
|
||
const target = runStartMs + offsetMs;
|
||
let best: TraceStep | null = null;
|
||
for (const step of steps) {
|
||
const at = timeMs(step.startedAt);
|
||
if (at === undefined || at < target) continue;
|
||
if (!best || at < (timeMs(best.startedAt) ?? Infinity)) best = step;
|
||
}
|
||
if (!best) return;
|
||
setSelectedSeq(best.seq);
|
||
scrollToStep(best.seq);
|
||
},
|
||
[steps, runStartMs, scrollToStep],
|
||
);
|
||
|
||
const handleCopyWorkdir = useCallback(() => {
|
||
if (!workdirCopyTarget) return;
|
||
void copyText(workdirCopyTarget.path).then((ok) => {
|
||
if (!ok) return;
|
||
showCopiedWorkdir();
|
||
});
|
||
}, [workdirCopyTarget, showCopiedWorkdir]);
|
||
|
||
// Worktree-mode runs deliver a branch instead of edits in the working copy,
|
||
// so copying the name is the fastest path to `git diff <branch>`.
|
||
const handleCopyBranch = useCallback(() => {
|
||
if (!task.branch_name) return;
|
||
void copyText(task.branch_name).then((ok) => {
|
||
if (!ok) return;
|
||
showCopiedBranch();
|
||
});
|
||
}, [task.branch_name, showCopiedBranch]);
|
||
|
||
const handleCopyAll = useCallback(() => {
|
||
// Copy the full body of each event (not the truncated row summary), with
|
||
// the same secret redaction the detail view applies. A step copies as its
|
||
// call followed by its result, which is the order it happened in.
|
||
const events: TimelineItem[] = [];
|
||
for (const row of displayRows) {
|
||
if (row.kind === "group") {
|
||
for (const call of row.steps) {
|
||
if (call.call) events.push(call.call);
|
||
if (call.result) events.push(call.result);
|
||
}
|
||
continue;
|
||
}
|
||
if (row.kind === "call") {
|
||
if (row.call) events.push(row.call);
|
||
if (row.result) events.push(row.result);
|
||
continue;
|
||
}
|
||
events.push(row.item);
|
||
}
|
||
const text = events.map((event) => redactSecrets(traceEventCopyText(event))).join("\n\n");
|
||
void copyText(text).then((ok) => {
|
||
if (!ok) return;
|
||
showCopied();
|
||
});
|
||
}, [displayRows, showCopied]);
|
||
|
||
const handleToggleGroup = useCallback((seq: number) => {
|
||
setExpandedGroups((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(seq)) next.delete(seq);
|
||
else next.add(seq);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const duration =
|
||
task.started_at && task.completed_at
|
||
? formatDuration(task.started_at, task.completed_at)
|
||
: isLive
|
||
? elapsed
|
||
: null;
|
||
|
||
const copyTranscriptLabel = copied
|
||
? t(($) => $.transcript.copied)
|
||
: activeFilterKeys.length > 0 || trimmedQuery.length > 0
|
||
? t(($) => $.transcript.copy_filtered)
|
||
: t(($) => $.transcript.copy_all);
|
||
|
||
// Status badge — full state machine, so queued/dispatched/cancelled render as
|
||
// proper labels instead of raw enum text.
|
||
const effectiveStatus = isLive ? "running" : task.status;
|
||
const statusBadge = (() => {
|
||
const base = "inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-caption font-medium";
|
||
switch (effectiveStatus) {
|
||
case "running":
|
||
return (
|
||
<span className={cn(base, "bg-info/15 text-info")}>
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
{t(($) => $.transcript.status_running)}
|
||
</span>
|
||
);
|
||
case "completed":
|
||
return (
|
||
<span className={cn(base, "bg-success/15 text-success")}>
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
{t(($) => $.transcript.status_completed)}
|
||
</span>
|
||
);
|
||
case "failed":
|
||
return (
|
||
<span className={cn(base, "bg-destructive/15 text-destructive")}>
|
||
<XCircle className="h-3 w-3" />
|
||
{t(($) => $.transcript.status_failed)}
|
||
</span>
|
||
);
|
||
case "cancelled": {
|
||
// A server-cancelled run (worktree claim gate, preserved-work
|
||
// delivery) carries a persisted reason the user must act on; surface
|
||
// it on the badge instead of a bare "Cancelled". User-initiated
|
||
// cancels have no reason, but carry actor provenance when it was
|
||
// recorded. The title contains only this localized status — never the
|
||
// raw `task.error`, which is operator prose reserved for Run details.
|
||
const cancelReason = cancelReasonLabel(task, t);
|
||
const cancelledBy = cancellationActorLabel(task, t);
|
||
const cancelStatus = cancelReason
|
||
? `${cancelledBy ?? t(($) => $.transcript.status_cancelled)} · ${cancelReason}`
|
||
: cancelledBy ?? t(($) => $.transcript.status_cancelled);
|
||
return (
|
||
<span
|
||
className={cn(base, "min-w-0 max-w-[45%] bg-muted text-muted-foreground")}
|
||
title={cancelStatus}
|
||
>
|
||
<XCircle className="h-3 w-3 shrink-0" />
|
||
<span className="truncate">{cancelStatus}</span>
|
||
</span>
|
||
);
|
||
}
|
||
case "queued":
|
||
return (
|
||
<span className={cn(base, "bg-muted text-muted-foreground")}>
|
||
{t(($) => $.transcript.status_queued)}
|
||
</span>
|
||
);
|
||
case "dispatched":
|
||
return (
|
||
<span className={cn(base, "bg-info/15 text-info")}>
|
||
{t(($) => $.transcript.status_dispatched)}
|
||
</span>
|
||
);
|
||
case "waiting_local_directory":
|
||
return (
|
||
<span className={cn(base, "bg-muted text-muted-foreground")}>
|
||
{t(($) => $.transcript.status_waiting)}
|
||
</span>
|
||
);
|
||
default:
|
||
return (
|
||
<span className={cn(base, "bg-muted text-muted-foreground capitalize")}>
|
||
{task.status}
|
||
</span>
|
||
);
|
||
}
|
||
})();
|
||
|
||
// Trigger source: one word answering "why does this run exist" — more useful
|
||
// up front than the runtime/provider diagnostics, which live in the ⓘ popover.
|
||
const triggerLabel = task.parent_task_id
|
||
? t(($) => $.transcript.trigger_retry)
|
||
: task.wakeup_id
|
||
? t(($) => $.transcript.trigger_wakeup)
|
||
: task.kind === "comment" || task.trigger_comment_id
|
||
? t(($) => $.transcript.trigger_comment)
|
||
: task.kind === "autopilot" || task.autopilot_run_id
|
||
? t(($) => $.transcript.trigger_autopilot)
|
||
: task.kind === "chat" || task.chat_session_id
|
||
? t(($) => $.transcript.trigger_chat)
|
||
: task.kind === "quick_create"
|
||
? t(($) => $.transcript.trigger_quick_create)
|
||
: task.kind === "direct"
|
||
? t(($) => $.transcript.trigger_direct)
|
||
: t(($) => $.transcript.trigger_initial);
|
||
|
||
// Diagnostic detail for the ⓘ popover: everything a reader needs only when
|
||
// debugging this specific run, kept off the always-visible surface.
|
||
const providerLabel = runtimeInfo?.provider ? transcriptProviderLabel(runtimeInfo.provider) : null;
|
||
const createdLabel = task.created_at ? formatRunTime(task.created_at, locale) : null;
|
||
const startedLabel = task.started_at ? formatRunTime(task.started_at, locale) : null;
|
||
const completedLabel = task.completed_at ? formatRunTime(task.completed_at, locale) : null;
|
||
const hasTriggeredBy = !!task.attribution?.initiator;
|
||
// This run's own spend. Present on transcripts opened from the issue
|
||
// execution log (the endpoint that hydrates usage); absent elsewhere, where
|
||
// the chip and the usage rows below simply don't render.
|
||
//
|
||
// `summarizeTaskUsage` prices through the custom-rate store, which it reads
|
||
// imperatively — subscribing here is what makes a saved rate change reach
|
||
// this figure, same as on the other usage surfaces.
|
||
useCustomPricingStore((s) => s.pricings);
|
||
const usage = summarizeTaskUsage(task.usage);
|
||
// Two separate things, deliberately not one string (#7411):
|
||
// • `reasonLabel` — the localized reason, derived from the stable
|
||
// `failure_reason` enum. This is the user-facing explanation.
|
||
// • `task.error` — the raw diagnostic the server/daemon persisted, in
|
||
// English, for classification and logs. Kept readable (it is how you
|
||
// find "which worktree holds my preserved work" and "which machine
|
||
// needs upgrading") but labelled as a technical detail rather than
|
||
// presented as the reason, and never merged into the localized text.
|
||
const reasonLabel =
|
||
task.status === "failed"
|
||
? failureReasonLabel(task.failure_reason, t)
|
||
: cancelReasonLabel(task, t);
|
||
const hasRunDetails =
|
||
!!runtimeInfo ||
|
||
!!workdirCopyTarget?.relativePath ||
|
||
!!task.branch_name ||
|
||
!!reasonLabel ||
|
||
!!task.error ||
|
||
!!createdLabel ||
|
||
!!startedLabel ||
|
||
!!completedLabel ||
|
||
!!usage;
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent
|
||
className="!max-w-5xl !w-[calc(100vw-4rem)] !max-h-[calc(100vh-4rem)] !h-[calc(100vh-4rem)] flex flex-col !p-0 !gap-0 overflow-hidden"
|
||
showCloseButton={false}
|
||
finalFocus={finalFocus}
|
||
>
|
||
<DialogTitle className="sr-only">{t(($) => $.transcript.dialog_title)}</DialogTitle>
|
||
|
||
{/* ── Header: outcome, identity, spend ─────────────────────────
|
||
Everything a viewer needs BEFORE reading: how it ended, who ran
|
||
it, why it exists, how long it took and what it cost. Diagnostics
|
||
stay in the ⓘ popover. */}
|
||
<div className="border-b px-4 py-3 shrink-0">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
{statusBadge}
|
||
{/* Primary identity: the agent that ran this. It is the one
|
||
foreground entity — avatar + medium weight. */}
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
{task.agent_id ? (
|
||
<ActorAvatar actorType="agent" actorId={task.agent_id} size="sm" enableHoverCard />
|
||
) : (
|
||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||
<Bot className="h-3 w-3" />
|
||
</div>
|
||
)}
|
||
<span className="truncate font-medium text-body">
|
||
{agentName || agentInfo?.name || ""}
|
||
</span>
|
||
</div>
|
||
{/* Provenance, one muted secondary unit set apart from the agent:
|
||
who triggered the run and how — reads as "<person> · <how>",
|
||
not three peer entities. The person's avatar is dropped here so
|
||
two same-size faces don't read as two agents. */}
|
||
<div className="flex min-w-0 flex-1 items-center gap-x-1.5 overflow-hidden text-caption text-muted-foreground">
|
||
{hasTriggeredBy && (
|
||
<>
|
||
<AttributionBadge
|
||
attribution={task.attribution}
|
||
variant="inline"
|
||
hideAvatar
|
||
className="min-w-0"
|
||
/>
|
||
<FactDot />
|
||
</>
|
||
)}
|
||
<span className="shrink-0">{triggerLabel}</span>
|
||
{duration && (
|
||
<>
|
||
<FactDot />
|
||
<span className="shrink-0 tabular-nums">
|
||
{t(($) => $.transcript.fact_took, { duration })}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* What this run cost, in the header of the run you are reading —
|
||
so "why was this one expensive" is answerable without going
|
||
back to the list. The split lives one click away in the ⓘ
|
||
popover. */}
|
||
{usage && (
|
||
<span
|
||
className="flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-micro tabular-nums"
|
||
title={t(($) => $.transcript.usage_chip_title)}
|
||
>
|
||
<Coins aria-hidden="true" className="h-3 w-3 text-muted-foreground" />
|
||
<span className="font-medium">{formatTokens(usage.tokens)}</span>
|
||
<span className="text-faint-foreground">·</span>
|
||
<span className="text-muted-foreground">{formatUsd(usage.cost)}</span>
|
||
</span>
|
||
)}
|
||
|
||
<div className="flex shrink-0 items-center gap-0.5">
|
||
{hasRunDetails && (
|
||
<Popover>
|
||
<PopoverTrigger
|
||
render={
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={t(($) => $.transcript.run_info)}
|
||
title={t(($) => $.transcript.run_info)}
|
||
className="text-muted-foreground"
|
||
/>
|
||
}
|
||
>
|
||
<Info className="h-3.5 w-3.5" />
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" className="w-80 max-w-[calc(100vw-2rem)] p-3">
|
||
<div className="mb-2 text-caption font-medium text-foreground">
|
||
{t(($) => $.transcript.run_info)}
|
||
</div>
|
||
<div className="space-y-1 text-caption">
|
||
{runtimeInfo && (
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_runtime)}
|
||
value={runtimeDisplayName(runtimeInfo)}
|
||
/>
|
||
)}
|
||
{providerLabel && (
|
||
<RunDetailRow label={t(($) => $.transcript.details_provider)} value={providerLabel} />
|
||
)}
|
||
{runtimeInfo && (
|
||
<RunDetailRow label={t(($) => $.transcript.details_mode)} value={runtimeInfo.runtime_mode} />
|
||
)}
|
||
{workdirCopyTarget?.relativePath && (
|
||
<RunDetailRow
|
||
label={
|
||
workdirCopyTarget.source ===
|
||
"durable_project_directory"
|
||
? t(
|
||
($) =>
|
||
$.transcript.details_project_directory,
|
||
)
|
||
: t(($) => $.transcript.details_workdir)
|
||
}
|
||
value={workdirCopyTarget.relativePath}
|
||
mono
|
||
onCopy={handleCopyWorkdir}
|
||
copied={copiedWorkdir}
|
||
copyTitle={
|
||
workdirCopyTarget.source ===
|
||
"durable_project_directory"
|
||
? t(
|
||
($) =>
|
||
$.transcript.copy_project_directory,
|
||
)
|
||
: t(($) => $.transcript.copy_workdir)
|
||
}
|
||
/>
|
||
)}
|
||
{task.branch_name && (
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_branch)}
|
||
value={task.branch_name}
|
||
mono
|
||
onCopy={handleCopyBranch}
|
||
copied={copiedBranch}
|
||
copyTitle={t(($) => $.transcript.copy_branch)}
|
||
/>
|
||
)}
|
||
{/* The localized reason, from the stable
|
||
`failure_reason` enum — this is the explanation, and
|
||
it reads in the user's language. */}
|
||
{reasonLabel && (
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_reason)}
|
||
value={reasonLabel}
|
||
/>
|
||
)}
|
||
{createdLabel && (
|
||
<RunDetailRow label={t(($) => $.transcript.details_created)} value={createdLabel} />
|
||
)}
|
||
{startedLabel && (
|
||
<RunDetailRow label={t(($) => $.transcript.details_started)} value={startedLabel} />
|
||
)}
|
||
{completedLabel && (
|
||
<RunDetailRow label={t(($) => $.transcript.details_completed)} value={completedLabel} />
|
||
)}
|
||
{/* The raw persisted diagnostic, last and behind its own
|
||
divider. It is English prose written by the server
|
||
and daemon for logs and classification, so it is
|
||
labelled "Technical details" — a translated heading
|
||
over untranslated content — rather than shown as the
|
||
run's reason (#7411). Still the place where "which
|
||
worktree holds my preserved work" is readable. */}
|
||
{task.error && (
|
||
<>
|
||
<div className="my-2 h-px bg-border" />
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_diagnostics)}
|
||
value={task.error}
|
||
mono
|
||
/>
|
||
</>
|
||
)}
|
||
{usage && (
|
||
<>
|
||
<div className="my-2 h-px bg-border" />
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_input)}
|
||
value={formatTokens(usage.input)}
|
||
/>
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_output)}
|
||
value={formatTokens(usage.output)}
|
||
/>
|
||
{usage.cacheRead > 0 && (
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_cache_read)}
|
||
value={formatTokens(usage.cacheRead)}
|
||
/>
|
||
)}
|
||
{usage.cacheWrite > 0 && (
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_cache_write)}
|
||
value={formatTokens(usage.cacheWrite)}
|
||
/>
|
||
)}
|
||
<RunDetailRow
|
||
label={t(($) => $.transcript.details_cost)}
|
||
value={formatUsd(usage.cost)}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => onOpenChange(false)}
|
||
aria-label={t(($) => $.transcript.close)}
|
||
className="text-muted-foreground"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── What was asked ─────────────────────────────────────────── */}
|
||
<RunTriggerRow task={task} />
|
||
|
||
{/* ── What the run produced ──────────────────────────────────── */}
|
||
<RunOutcomeRow outcome={outcome} branch={task.branch_name} />
|
||
|
||
{/* ── Where the time went ────────────────────────────────────── */}
|
||
{lanes && (
|
||
<RunTimeline
|
||
lanes={lanes}
|
||
toolKinds={toolKinds}
|
||
selectedOffsetMs={selectedOffsetMs}
|
||
onSeek={handleSeek}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Optional header slot (e.g. webhook payload preview) ── */}
|
||
{headerSlot && (
|
||
<div className="border-b px-4 py-3 shrink-0 bg-muted/20">
|
||
{headerSlot}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── List toolbar: search left, the two menus right ── */}
|
||
<div className="flex items-center gap-2 border-b px-4 py-1.5 shrink-0">
|
||
<label className="flex h-7 min-w-0 max-w-xs flex-1 items-center gap-1.5 rounded-md bg-muted px-2 text-caption">
|
||
<Search aria-hidden className="h-3 w-3 shrink-0 text-faint-foreground" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={t(($) => $.transcript.search_placeholder)}
|
||
aria-label={t(($) => $.transcript.search_placeholder)}
|
||
className="min-w-0 flex-1 bg-transparent text-caption outline-none placeholder:text-faint-foreground"
|
||
/>
|
||
</label>
|
||
<span className="ml-auto shrink-0 whitespace-nowrap text-caption text-muted-foreground">
|
||
{activeFilterKeys.length > 0 || trimmedQuery.length > 0
|
||
? t(($) => $.transcript.steps_filtered, {
|
||
shown: filteredSteps.length,
|
||
total: steps.length,
|
||
})
|
||
: t(($) => $.transcript.steps_count, { count: steps.length })}
|
||
</span>
|
||
{filterOptions.length > 0 && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<Button
|
||
variant={activeFilterKeys.length > 0 ? "brand" : "ghost"}
|
||
size="sm"
|
||
aria-label={t(($) => $.transcript.filter)}
|
||
className={activeFilterKeys.length > 0 ? undefined : "text-muted-foreground"}
|
||
/>
|
||
}
|
||
>
|
||
<Filter className="h-3 w-3" />
|
||
<span className="hidden sm:inline">{t(($) => $.transcript.filter)}</span>
|
||
{activeFilterKeys.length > 0 && (
|
||
<span className="ml-0.5 rounded-full bg-brand-foreground/20 px-1.5 py-0 text-micro font-medium tabular-nums">
|
||
{activeFilterKeys.length}
|
||
</span>
|
||
)}
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-auto">
|
||
{filterOptions.map(([value, option]) => (
|
||
<DropdownMenuCheckboxItem
|
||
key={value}
|
||
checked={selectedFilterKeys.includes(value)}
|
||
onCheckedChange={() => toggleFilterKey(value)}
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<StepIcon
|
||
step={option.step}
|
||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||
/>
|
||
{option.label}
|
||
</span>
|
||
</DropdownMenuCheckboxItem>
|
||
))}
|
||
{selectedFilterKeys.length > 0 && (
|
||
<>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={clearFilters} className="text-muted-foreground">
|
||
{t(($) => $.transcript.clear_filters)}
|
||
</DropdownMenuItem>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
{/* Sort and copy are the run-level actions nobody needs on the way
|
||
in, so they live behind one overflow control instead of two
|
||
permanent buttons. */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={t(($) => $.transcript.more_actions)}
|
||
className="text-muted-foreground"
|
||
/>
|
||
}
|
||
>
|
||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-56">
|
||
<DropdownMenuItem
|
||
onClick={() =>
|
||
handleSortDirectionChange(
|
||
sortDirection === "chronological" ? "newest_first" : "chronological",
|
||
)
|
||
}
|
||
>
|
||
{sortDirection === "chronological" ? (
|
||
<ArrowUpNarrowWide className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ArrowDownNarrowWide className="h-3.5 w-3.5" />
|
||
)}
|
||
{sortDirection === "chronological"
|
||
? t(($) => $.transcript.sort_newest_first)
|
||
: t(($) => $.transcript.sort_chronological)}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={handleCopyAll}>
|
||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||
{copyTranscriptLabel}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
{/* ── Steps, and the inspector when one is selected ───────────── */}
|
||
<div className="flex min-h-0 flex-1">
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
{contentState ? <div className="flex h-full items-center justify-center p-4">{contentState}</div> : displayRows.length === 0 ? (
|
||
<div className="flex h-full items-center justify-center text-body text-muted-foreground">
|
||
{isLive && steps.length === 0 ? (
|
||
<div className="flex items-center gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
{t(($) => $.transcript.waiting_events)}
|
||
</div>
|
||
) : steps.length === 0 ? (
|
||
t(($) => $.transcript.no_data)
|
||
) : (
|
||
t(($) => $.transcript.no_matches)
|
||
)}
|
||
</div>
|
||
) : (
|
||
// Virtualized so a multi-thousand-step run mounts a bounded
|
||
// number of DOM rows (#5733).
|
||
<Virtuoso
|
||
key={listEpoch}
|
||
ref={virtuosoRef}
|
||
style={{ height: "100%" }}
|
||
data={displayRows}
|
||
firstItemIndex={firstItemIndex}
|
||
// Open a live chronological transcript pinned to the newest
|
||
// step (#5921); the per-listEpoch remount re-applies this after
|
||
// task / sort / filter changes.
|
||
initialTopMostItemIndex={
|
||
isLive && sortDirection !== "newest_first"
|
||
? { index: "LAST", align: "end" }
|
||
: 0
|
||
}
|
||
followOutput={(atBottom) =>
|
||
isLive && sortDirection !== "newest_first" && atBottom ? "auto" : false
|
||
}
|
||
atBottomThreshold={FOLLOW_EDGE_THRESHOLD}
|
||
atTopThreshold={FOLLOW_EDGE_THRESHOLD}
|
||
atTopStateChange={(atTop) => followCtl.onAtEdgeChange(atTop)}
|
||
scrollerRef={handleScrollerRef}
|
||
computeItemKey={(_, row) => row.seq}
|
||
components={LIST_COMPONENTS}
|
||
itemContent={(_, row) => (
|
||
<TranscriptRow
|
||
row={row}
|
||
formatText={formatText}
|
||
runStartMs={runStartMs}
|
||
isLive={isLive}
|
||
selectedSeq={selectedSeq}
|
||
expanded={row.kind === "group" && expandedGroups.has(row.seq)}
|
||
onToggleGroup={handleToggleGroup}
|
||
onSelect={setSelectedSeq}
|
||
/>
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
{selectedStep && (
|
||
<StepInspector
|
||
step={selectedStep}
|
||
runStartMs={runStartMs}
|
||
onClose={() => setSelectedSeq(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// Provider slugs this view names differently from the runtime list. The daemon
|
||
// has no display-name override for Claude, so the shared formatter answers
|
||
// "Claude" — but a run's diagnostics have always named the tool "Claude Code",
|
||
// and `claude-code` is a legacy provider value still present on older tasks
|
||
// (title-casing it alone would read as "Claude-code"). Every other provider
|
||
// defers to the shared formatter so this row cannot drift from the runtime
|
||
// list the way it did before (#5260).
|
||
const TRANSCRIPT_PROVIDER_LABELS: Record<string, string> = {
|
||
claude: "Claude Code",
|
||
"claude-code": "Claude Code",
|
||
};
|
||
|
||
function transcriptProviderLabel(provider: string): string {
|
||
return TRANSCRIPT_PROVIDER_LABELS[provider.toLowerCase()] ?? providerDisplayName(provider);
|
||
}
|
||
|
||
// ─── Facts line separator ───────────────────────────────────────────────────
|
||
|
||
function FactDot() {
|
||
return (
|
||
<span aria-hidden className="text-faint-foreground">
|
||
·
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ─── Outcome row ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* What the run produced, above the evidence for it. For most readers this is
|
||
* the whole answer — "it touched these files and ran these commands" settles
|
||
* "can I trust this" without opening a single step.
|
||
*
|
||
* Renders nothing when the run produced nothing nameable; a row of zeroes
|
||
* would read as "it did nothing" rather than "we have nothing to summarize".
|
||
*/
|
||
/**
|
||
* What the person asked for, in full. The header names only how the run was
|
||
* triggered ("Comment"), and the lists that open this dialog show the ask on
|
||
* one truncated line — so this is where a long request is read whole. The
|
||
* snapshot itself is capped at ~200 runes by the server; it is shown as it
|
||
* was captured, wrapping rather than truncating.
|
||
*/
|
||
function RunTriggerRow({ task }: { task: AgentTask }) {
|
||
const { t } = useT("agents");
|
||
const { t: tIssues } = useT("issues");
|
||
if (!task.trigger_summary) return null;
|
||
const text = plainTriggerSummary(
|
||
task.trigger_summary,
|
||
tIssues(($) => $.execution_log.trigger_image),
|
||
);
|
||
if (!text) return null;
|
||
return (
|
||
<div className="flex shrink-0 items-baseline gap-2 border-b px-4 py-2">
|
||
<span className="shrink-0 text-micro text-muted-foreground">
|
||
{t(($) => $.transcript.trigger_text)}
|
||
</span>
|
||
<p className="min-w-0 whitespace-pre-wrap break-words text-caption text-foreground">{text}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RunOutcomeRow({
|
||
outcome,
|
||
branch,
|
||
}: {
|
||
outcome: ReturnType<typeof buildRunOutcome>;
|
||
branch?: string | null;
|
||
}) {
|
||
const { t } = useT("agents");
|
||
if (!outcome) return null;
|
||
|
||
const chip = "inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-surface px-2 py-1 text-caption";
|
||
return (
|
||
<div className="flex shrink-0 items-center gap-2 overflow-hidden border-b bg-muted/40 px-4 py-1.5">
|
||
<span className="shrink-0 text-micro text-faint-foreground">
|
||
{t(($) => $.transcript.outcome_label)}
|
||
</span>
|
||
{outcome.paths.length > 0 && (
|
||
<span className={chip} title={outcome.paths.join("\n")}>
|
||
<FilePen aria-hidden className="h-3 w-3 text-muted-foreground" />
|
||
<span className="font-medium">
|
||
{t(($) => $.transcript.outcome_files, { count: outcome.paths.length })}
|
||
</span>
|
||
{outcome.addedLines > 0 && (
|
||
<span className="tabular-nums text-success">+{outcome.addedLines}</span>
|
||
)}
|
||
{outcome.removedLines > 0 && (
|
||
<span className="tabular-nums text-destructive">−{outcome.removedLines}</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
{outcome.commandCount > 0 && (
|
||
<span className={chip}>
|
||
<Terminal aria-hidden className="h-3 w-3 text-muted-foreground" />
|
||
<span className="font-medium">
|
||
{t(($) => $.transcript.outcome_commands, { count: outcome.commandCount })}
|
||
</span>
|
||
</span>
|
||
)}
|
||
{branch && (
|
||
<span className={cn(chip, "min-w-0")}>
|
||
<GitBranch aria-hidden className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||
<span className="truncate font-mono text-micro">{branch}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Rows ───────────────────────────────────────────────────────────────────
|
||
|
||
interface TranscriptRowProps {
|
||
formatText: (text: string) => string;
|
||
row: TraceRow;
|
||
runStartMs?: number;
|
||
isLive: boolean;
|
||
selectedSeq: number | null;
|
||
expanded: boolean;
|
||
onToggleGroup: (seq: number) => void;
|
||
onSelect: (seq: number) => void;
|
||
}
|
||
|
||
function TranscriptRow(props: TranscriptRowProps) {
|
||
const { row } = props;
|
||
if (isGroupRow(row)) return <GroupRow {...props} row={row} />;
|
||
if (!isCallStep(row) && row.kind === "text") return <ProseRow {...props} row={row} />;
|
||
return <StepRow {...props} row={row} />;
|
||
}
|
||
|
||
/** The offset column: where in the run this happened. */
|
||
function OffsetCell({ startedAt, runStartMs }: { startedAt?: string; runStartMs?: number }) {
|
||
const locale = useLocale();
|
||
const at = timeMs(startedAt);
|
||
const label = at !== undefined && runStartMs !== undefined ? formatOffset(at - runStartMs) : "";
|
||
return (
|
||
<span
|
||
className="w-11 shrink-0 pt-0.5 text-right font-mono text-micro tabular-nums text-faint-foreground"
|
||
title={startedAt ? new Date(startedAt).toLocaleString(locale) : undefined}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DurationCell({ ms, pending }: { ms?: number; pending?: boolean }) {
|
||
const { t } = useT("agents");
|
||
if (pending) {
|
||
return (
|
||
<span className="shrink-0 pt-0.5 text-micro text-info" title={t(($) => $.transcript.step_running)}>
|
||
<Loader2 aria-hidden className="h-3 w-3 animate-spin" />
|
||
<span className="sr-only">{t(($) => $.transcript.step_running)}</span>
|
||
</span>
|
||
);
|
||
}
|
||
if (ms === undefined) return <span className="w-12 shrink-0" />;
|
||
return (
|
||
<StepDuration
|
||
ms={ms}
|
||
unknownLabel={t(($) => $.transcript.step_duration_unknown)}
|
||
className="w-12 shrink-0 pt-0.5 text-right font-mono text-micro tabular-nums text-faint-foreground"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function StepDuration({
|
||
ms,
|
||
unknownLabel,
|
||
className,
|
||
}: {
|
||
ms: number;
|
||
unknownLabel: string;
|
||
className?: string;
|
||
}) {
|
||
const unknown = ms <= 0;
|
||
return (
|
||
<span className={className} title={unknown ? unknownLabel : undefined}>
|
||
<span aria-hidden={unknown || undefined}>{formatStepDuration(ms)}</span>
|
||
{unknown && <span className="sr-only">{unknownLabel}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The agent's own words, at body size and in reading measure.
|
||
*
|
||
* This is the layer inversion the redesign turns on: the report used to look
|
||
* exactly like a `Read`, so the one thing worth reading was the hardest thing
|
||
* to find. Prose is content and stays open; tool calls are evidence and fold.
|
||
*
|
||
* Identity is deliberately absent here. A transcript is one run by one agent —
|
||
* `TraceMessageStep` carries no per-step actor, so an avatar and name on the
|
||
* row would be the same two values repeated for every step, and the header
|
||
* already states them. Repeating them cost a semibold 12px line above 12px
|
||
* body text, which on a run of short steps made the row's heaviest element its
|
||
* least informative one.
|
||
*
|
||
* What the row does keep is its kind: the same `StepIcon` column every other
|
||
* row carries, so "Agent" in the filter has something to point at. Kind, not
|
||
* identity — the glyph says "the agent's own words", which is what the facet
|
||
* selects, and it says it in 12px without a repeated string.
|
||
*/
|
||
function ProseRow({ row, runStartMs }: TranscriptRowProps & { row: TraceMessageStep }) {
|
||
return (
|
||
<div className="group flex items-start gap-2 px-4 py-2.5">
|
||
<OffsetCell startedAt={row.startedAt} runStartMs={runStartMs} />
|
||
<span aria-hidden className="mt-1 w-0.5 self-stretch rounded-full bg-success" />
|
||
<StepIcon step={row} className="mt-1 h-3 w-3 shrink-0 text-muted-foreground" />
|
||
<div className="min-w-0 flex-1 pr-2">
|
||
<RichContent
|
||
content={row.item.content ?? ""}
|
||
density="compact"
|
||
className="transcript-prose max-w-[62rem]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One call, one thinking block, or one error: a line, with its detail one
|
||
* click away in the inspector. */
|
||
function StepRow({
|
||
row,
|
||
formatText,
|
||
runStartMs,
|
||
isLive,
|
||
selectedSeq,
|
||
onSelect,
|
||
}: TranscriptRowProps & { row: TraceStep }) {
|
||
const { t } = useT("agents");
|
||
const summaryLabels = useMemo<TraceSummaryLabels>(
|
||
() => ({
|
||
formatText,
|
||
morePaths: (path, extraCount) =>
|
||
t(($) => $.transcript.patch_summary_more, { path, extra: extraCount }),
|
||
}),
|
||
[t, formatText],
|
||
);
|
||
|
||
const call = isCallStep(row) ? row : null;
|
||
const label = call
|
||
? call.tool || t(($) => $.transcript.kind_tool)
|
||
: row.kind === "thinking"
|
||
? t(($) => $.transcript.kind_thinking)
|
||
: t(($) => $.transcript.kind_error);
|
||
const summary = call
|
||
? callSummary(call, summaryLabels)
|
||
: firstLineOf((row as TraceMessageStep).item.content);
|
||
const pending = call !== null && isLive && !call.result;
|
||
const selected = selectedSeq === row.seq;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelect(row.seq)}
|
||
aria-current={selected ? "true" : undefined}
|
||
className={cn(
|
||
"group flex w-full items-start gap-2 px-4 py-1.5 text-left transition-colors",
|
||
selected ? "bg-brand/8" : "hover:bg-accent/40",
|
||
row.kind === "error" && "bg-destructive/5",
|
||
)}
|
||
>
|
||
<OffsetCell startedAt={row.startedAt} runStartMs={runStartMs} />
|
||
<span
|
||
aria-hidden
|
||
className={cn(
|
||
"mt-0.5 w-0.5 self-stretch rounded-full",
|
||
selected ? "bg-brand" : row.kind === "error" ? "bg-destructive" : "bg-border",
|
||
)}
|
||
/>
|
||
<StepIcon
|
||
step={row}
|
||
className={cn(
|
||
"mt-1 h-3 w-3 shrink-0",
|
||
row.kind === "error" ? "text-destructive" : "text-muted-foreground",
|
||
)}
|
||
/>
|
||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||
<span
|
||
className={cn(
|
||
"shrink-0 text-caption font-medium",
|
||
row.kind === "error" ? "text-destructive" : "text-foreground",
|
||
)}
|
||
>
|
||
{label}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"truncate text-micro text-muted-foreground",
|
||
call && "font-mono",
|
||
)}
|
||
>
|
||
{summary}
|
||
</span>
|
||
</span>
|
||
<DurationCell ms={call?.durationMs} pending={pending} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** Consecutive same-tool calls, folded to one line until asked. */
|
||
function GroupRow({
|
||
row,
|
||
formatText,
|
||
runStartMs,
|
||
selectedSeq,
|
||
expanded,
|
||
onToggleGroup,
|
||
onSelect,
|
||
}: TranscriptRowProps & { row: TraceGroupRow }) {
|
||
const { t } = useT("agents");
|
||
const summaryLabels = useMemo<TraceSummaryLabels>(
|
||
() => ({
|
||
formatText,
|
||
morePaths: (path, extraCount) =>
|
||
t(($) => $.transcript.patch_summary_more, { path, extra: extraCount }),
|
||
}),
|
||
[t, formatText],
|
||
);
|
||
|
||
return (
|
||
<div className={cn(selectedSeq !== null && row.steps.some((s) => s.seq === selectedSeq) && "bg-brand/5")}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleGroup(row.seq)}
|
||
aria-expanded={expanded}
|
||
className="group flex w-full items-start gap-2 px-4 py-1.5 text-left transition-colors hover:bg-accent/40"
|
||
>
|
||
<OffsetCell startedAt={row.startedAt} runStartMs={runStartMs} />
|
||
<span aria-hidden className="mt-0.5 w-0.5 self-stretch rounded-full bg-border" />
|
||
<ChevronRight
|
||
aria-hidden
|
||
className={cn(
|
||
"mt-1 h-3 w-3 shrink-0 text-faint-foreground transition-transform",
|
||
expanded && "rotate-90",
|
||
)}
|
||
/>
|
||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||
<span className="shrink-0 text-caption font-medium">{row.tool}</span>
|
||
{/* Name the first thing it touched, not just how many: "Read · 4
|
||
calls" hides the one detail that makes a folded group scannable. */}
|
||
<span className="truncate font-mono text-micro text-muted-foreground">
|
||
{callSummary(row.steps[0]!, summaryLabels)}
|
||
</span>
|
||
<span className="shrink-0 text-micro text-faint-foreground">
|
||
{t(($) => $.transcript.group_calls, { count: row.steps.length })}
|
||
</span>
|
||
</span>
|
||
<DurationCell ms={row.durationMs} />
|
||
</button>
|
||
{expanded && (
|
||
<div className="ml-[4.4rem] border-l pl-3">
|
||
{row.steps.map((step) => (
|
||
<button
|
||
key={step.seq}
|
||
type="button"
|
||
onClick={() => onSelect(step.seq)}
|
||
className={cn(
|
||
"flex w-full items-baseline gap-2 rounded-xs px-2 py-1 text-left text-micro transition-colors",
|
||
selectedSeq === step.seq ? "bg-brand/10" : "hover:bg-accent/40",
|
||
)}
|
||
>
|
||
<span className="truncate font-mono text-muted-foreground">
|
||
{callSummary(step, summaryLabels) || step.tool}
|
||
</span>
|
||
{step.durationMs === undefined ? (
|
||
<span className="ml-auto shrink-0" />
|
||
) : (
|
||
<StepDuration
|
||
ms={step.durationMs}
|
||
unknownLabel={t(($) => $.transcript.step_duration_unknown)}
|
||
className="ml-auto shrink-0 font-mono tabular-nums text-faint-foreground"
|
||
/>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The one line a call is worth in the list: what it was asked to do.
|
||
*
|
||
* Deliberately the call's argument, never the result — the result row is where
|
||
* base64 screenshots and 8KB command dumps used to land in the middle of the
|
||
* list. Only a call we never saw falls back to its output.
|
||
*/
|
||
function callSummary(step: TraceCallStep, labels: TraceSummaryLabels): string {
|
||
if (step.call) {
|
||
const summary = traceToolArgSummary(step.call.input, labels);
|
||
if (summary) return summary;
|
||
}
|
||
if (!step.result) return "";
|
||
if (readImageResult(step.result.output)) return "";
|
||
return traceEventSummary({ type: "tool_result", output: step.result.output }, labels);
|
||
}
|
||
|
||
function firstLineOf(value: string | undefined): string {
|
||
return value?.split("\n").find((line) => line.trim().length > 0) ?? "";
|
||
}
|
||
|
||
// ─── Inspector ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The evidence for one step, opened on demand.
|
||
*
|
||
* Input and result are stacked rather than tabbed: at this width both fit, and
|
||
* a tab would hide half the evidence behind a click to save nothing.
|
||
*/
|
||
function StepInspector({
|
||
step,
|
||
runStartMs,
|
||
onClose,
|
||
}: {
|
||
step: TraceStep;
|
||
runStartMs?: number;
|
||
onClose: () => void;
|
||
}) {
|
||
const { t } = useT("agents");
|
||
const [copied, showCopied] = useCopyFeedback();
|
||
|
||
const at = timeMs(step.startedAt);
|
||
const offset = at !== undefined && runStartMs !== undefined ? formatOffset(at - runStartMs) : null;
|
||
|
||
const call = isCallStep(step) ? step : null;
|
||
const message = call ? null : (step as TraceMessageStep);
|
||
|
||
const handleCopy = useCallback(() => {
|
||
const parts: string[] = [];
|
||
if (call) {
|
||
if (call.call) parts.push(traceEventCopyText(call.call));
|
||
if (call.result) parts.push(traceEventCopyText(call.result));
|
||
} else if (message) {
|
||
parts.push(traceEventCopyText(message.item));
|
||
}
|
||
void copyText(redactSecrets(parts.join("\n\n"))).then((ok) => {
|
||
if (!ok) return;
|
||
showCopied();
|
||
});
|
||
}, [call, message, showCopied]);
|
||
|
||
const title =
|
||
call
|
||
? call.tool || t(($) => $.transcript.kind_tool)
|
||
: step.kind === "thinking"
|
||
? t(($) => $.transcript.kind_thinking)
|
||
: step.kind === "text"
|
||
? t(($) => $.transcript.kind_text)
|
||
: t(($) => $.transcript.kind_error);
|
||
|
||
return (
|
||
<aside className="flex w-[26rem] shrink-0 flex-col border-l bg-muted/25">
|
||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||
<StepIcon step={step} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
<span className="shrink-0 text-label font-semibold">{title}</span>
|
||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-micro text-muted-foreground">
|
||
{offset && <span className="font-mono tabular-nums">{offset}</span>}
|
||
{call?.durationMs !== undefined && (
|
||
<>
|
||
<FactDot />
|
||
<StepDuration
|
||
ms={call.durationMs}
|
||
unknownLabel={t(($) => $.transcript.step_duration_unknown)}
|
||
className="font-mono tabular-nums"
|
||
/>
|
||
</>
|
||
)}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={handleCopy}
|
||
aria-label={t(($) => $.transcript.copy_step)}
|
||
title={t(($) => $.transcript.copy_step)}
|
||
className="shrink-0 text-muted-foreground"
|
||
>
|
||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onClose}
|
||
aria-label={t(($) => $.transcript.close_step)}
|
||
className="shrink-0 text-muted-foreground"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||
{call ? (
|
||
<>
|
||
{call.call && (
|
||
<InspectorSection label={t(($) => $.transcript.step_input)}>
|
||
<StepBody item={call.call} />
|
||
</InspectorSection>
|
||
)}
|
||
{call.result && (
|
||
<InspectorSection label={t(($) => $.transcript.step_result)}>
|
||
<StepBody item={call.result} />
|
||
</InspectorSection>
|
||
)}
|
||
</>
|
||
) : (
|
||
<InspectorSection label={title}>
|
||
<ToolDetailSurface text={redactSecrets(message?.item.content ?? "")} />
|
||
</InspectorSection>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function InspectorSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="border-b last:border-b-0">
|
||
<h3 className="px-3 pt-2 text-micro font-medium uppercase tracking-wide text-faint-foreground">
|
||
{label}
|
||
</h3>
|
||
<div className="px-1 pb-2">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** One payload, rendered as what it is. */
|
||
/** Pre-existing render ceiling for a body with no server-side budget. */
|
||
const DISPLAY_CLIP_CHARS = 8000;
|
||
|
||
export function StepBody({ item }: { item: TimelineItem }) {
|
||
const { t } = useT("agents");
|
||
const detail = useMemo(() => traceEventDetail(item), [item]);
|
||
const image = useMemo(() => readImageResult(item.output), [item.output]);
|
||
// Stated where the output actually ends, for a reader who has just reached
|
||
// the bottom and is wondering whether that was all of it. A header badge said
|
||
// the same thing louder, before anyone had asked the question.
|
||
const note = isOutputTruncated(item) ? t(($) => $.transcript.output_truncated_note) : undefined;
|
||
|
||
// A screenshot is a picture, not a 200KB base64 string in a <pre>.
|
||
if (image) {
|
||
return (
|
||
<figure className="px-2 py-1">
|
||
<img
|
||
src={`data:${image.mediaType};base64,${image.base64}`}
|
||
alt={t(($) => $.transcript.image_result)}
|
||
className="max-h-80 w-full rounded-md border object-contain"
|
||
/>
|
||
<figcaption className="pt-1 text-micro text-faint-foreground">
|
||
{t(($) => $.transcript.image_result)} · {formatBytes(base64ByteLength(image.base64))}
|
||
</figcaption>
|
||
{note && <span className="block pt-1 text-micro text-faint-foreground">{note}</span>}
|
||
</figure>
|
||
);
|
||
}
|
||
|
||
switch (detail.kind) {
|
||
case "diff":
|
||
return <DiffDetailSurface lines={detail.lines} path={detail.path} />;
|
||
case "patch":
|
||
return <PatchDetailSurface files={detail.files} truncated={detail.truncated} />;
|
||
case "file":
|
||
return <FileWriteSurface text={detail.text} lineCount={detail.lineCount} path={detail.path} />;
|
||
default: {
|
||
const text = detail.text;
|
||
// A stored tool result is already capped at 8192 bytes by the daemon, so
|
||
// clipping it again could only shave a couple of hundred more characters
|
||
// — under a note that already reports the same loss. Tool input has no
|
||
// server-side budget and keeps the clip at its existing length; nothing
|
||
// about how long an input renders is this change's business.
|
||
const clip = item.type === "tool_result" ? null : DISPLAY_CLIP_CHARS;
|
||
const clipped =
|
||
clip !== null && text.length > clip
|
||
? `${redactSecrets(text.slice(0, clip))}\n${t(($) => $.transcript.display_clipped)}`
|
||
: redactSecrets(text);
|
||
const path = item.type === "tool_use" ? readPathFromInput(item.input) : undefined;
|
||
return (
|
||
<ToolDetailSurface
|
||
text={clipped}
|
||
language={path ? languageForPath(path) : undefined}
|
||
note={note}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function readPathFromInput(input: Record<string, unknown> | undefined): string | undefined {
|
||
if (!input) return undefined;
|
||
const path = input.file_path ?? input.path;
|
||
return typeof path === "string" ? path : undefined;
|
||
}
|
||
|