Files
multica/packages/views/common/task-transcript/agent-transcript-dialog.tsx
Bohan Jiang 7be3838ada feat(transcript): add sort direction toggle to agent transcript dialog (MUL-2368) (#2848)
Adds a header toggle that lets users flip the agent transcript between
chronological (oldest first, current behavior) and newest-first. The
preference is persisted via a small Zustand store. Default stays
chronological so existing readers see no behavior change.

Sort is a pure presentation concern — the underlying timeline (seq
numbers, filter keys, segment navigation) is untouched. Toggling resets
the scroll container to the top so the user lands on the newest end of
the chosen direction. Copy-all respects the displayed order so the
exported text matches what's on screen.

Scope is limited to the task transcript dialog per the MVP plan; the
issue execution log and agent activity tab are out of scope and may be
revisited once this interaction validates.

Closes GH #2736.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:03:31 +08:00

806 lines
30 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import {
Bot,
ChevronRight,
Brain,
AlertCircle,
CheckCircle2,
XCircle,
X,
Loader2,
Clock,
Copy,
Check,
Monitor,
Cloud,
Cpu,
Filter,
ArrowDownNarrowWide,
ArrowUpNarrowWide,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { ActorAvatar } from "../actor-avatar";
import { api } from "@multica/core/api";
import { useTranscriptViewStore, type TranscriptSortDirection } from "@multica/core/agents/stores";
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
import { redactSecrets } from "./redact";
import type { TimelineItem } from "./build-timeline";
import { useT } from "../../i18n";
interface AgentTranscriptDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
task: AgentTask;
items: TimelineItem[];
agentName: string;
isLive?: boolean;
/**
* 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;
}
// ─── Color mapping for timeline segments ────────────────────────────────────
type EventColor = "agent" | "thinking" | "tool" | "result" | "error";
function getEventColor(item: TimelineItem): EventColor {
switch (item.type) {
case "text":
return "agent";
case "thinking":
return "thinking";
case "tool_use":
return "tool";
case "tool_result":
return "result";
case "error":
return "error";
default:
return "result";
}
}
const colorClasses: Record<EventColor, { bg: string; bgActive: string; label: string }> = {
agent: { bg: "bg-emerald-400/60", bgActive: "bg-emerald-500", label: "bg-emerald-500" },
thinking: { bg: "bg-violet-400/60", bgActive: "bg-violet-500", label: "bg-violet-500/20 text-violet-700 dark:text-violet-300" },
tool: { bg: "bg-blue-400/60", bgActive: "bg-blue-500", label: "bg-blue-500/20 text-blue-700 dark:text-blue-300" },
result: { bg: "bg-slate-300/60 dark:bg-slate-600/60", bgActive: "bg-slate-400 dark:bg-slate-500", label: "bg-muted text-muted-foreground" },
error: { bg: "bg-red-400/60", bgActive: "bg-red-500", label: "bg-red-500/20 text-red-700 dark:text-red-300" },
};
// ─── Helpers ────────────────────────────────────────────────────────────────
function getEventLabel(item: TimelineItem): string {
switch (item.type) {
case "text":
return "Agent";
case "thinking":
return "Thinking";
case "tool_use":
return item.tool ?? "Tool";
case "tool_result":
return item.tool ? `${item.tool}` : "Result";
case "error":
return "Error";
default:
return "Event";
}
}
function getEventSummary(item: TimelineItem): string {
switch (item.type) {
case "text":
return item.content?.split("\n").find((l) => l.trim().length > 0) ?? "";
case "thinking":
return item.content?.slice(0, 200) ?? "";
case "tool_use": {
if (!item.input) return "";
const inp = item.input as Record<string, string>;
if (inp.query) return inp.query;
if (inp.file_path) return shortenPath(inp.file_path);
if (inp.path) return shortenPath(inp.path);
if (inp.pattern) return inp.pattern;
if (inp.description) return String(inp.description);
if (inp.command) {
const cmd = String(inp.command);
return cmd.length > 120 ? cmd.slice(0, 120) + "..." : cmd;
}
if (inp.prompt) {
const p = String(inp.prompt);
return p.length > 120 ? p.slice(0, 120) + "..." : p;
}
if (inp.skill) return String(inp.skill);
for (const v of Object.values(inp)) {
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
}
return "";
}
case "tool_result":
return item.output?.slice(0, 200) ?? "";
case "error":
return item.content ?? "";
default:
return "";
}
}
function shortenPath(p: string): string {
const parts = p.split("/");
if (parts.length <= 3) return p;
return ".../" + parts.slice(-2).join("/");
}
function formatDuration(start: string, end: string): string {
const ms = new Date(end).getTime() - new Date(start).getTime();
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`;
}
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`;
}
// ─── Main dialog ────────────────────────────────────────────────────────────
export function AgentTranscriptDialog({
open,
onOpenChange,
task,
items,
agentName,
isLive = false,
headerSlot,
}: AgentTranscriptDialogProps) {
const { t } = useT("agents");
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
const [elapsed, setElapsed] = useState("");
const [copied, setCopied] = useState(false);
const [agentInfo, setAgentInfo] = useState<Agent | null>(null);
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const sortDirection = useTranscriptViewStore((s) => s.sortDirection);
const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection);
const eventRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Derive filter options from each item:
// tool_use / tool_result → filter value = tool, display = "tool:Bash"
// other types → display from getEventLabel
const filterOptions = useMemo(() => {
const options = new Map<string, string>();
for (const item of items) {
if (item.tool && (item.type === "tool_use" || item.type === "tool_result")) {
const key = `tool:${item.tool}`;
if (!options.has(key)) options.set(key, key);
} else {
const value = item.type;
if (!options.has(value)) {
options.set(value, getEventLabel(item));
}
}
}
return Array.from(options.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [items]);
// Resolve filter key for each item — mirrors filterOptions derivation exactly
const itemFilterKey = (item: TimelineItem) =>
item.tool && (item.type === "tool_use" || item.type === "tool_result")
? `tool:${item.tool}`
: item.type;
// Strict filter
const filteredItems = useMemo(() => {
if (selectedTools.size === 0) return items;
return items.filter((item) => selectedTools.has(itemFilterKey(item)));
}, [items, selectedTools]);
// Apply user-chosen sort direction. Reverse is a pure presentation concern —
// the underlying timeline (and its seq numbers) is untouched, so copy/filter
// and segment navigation continue to work against the same data.
const displayItems = useMemo(
() => (sortDirection === "newest_first" ? [...filteredItems].reverse() : filteredItems),
[filteredItems, sortDirection],
);
// Toggling direction is a manual user action; jump the scroll container back
// to the top so the newest end of the timeline (per the chosen direction) is
// immediately visible. Avoids stranding the user mid-scroll on the wrong end.
const handleSortDirectionChange = useCallback(
(dir: typeof sortDirection) => {
if (dir === sortDirection) return;
setSortDirection(dir);
scrollContainerRef.current?.scrollTo({ top: 0 });
},
[sortDirection, setSortDirection],
);
// 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 handleSegmentClick = useCallback((seq: number) => {
setSelectedSeq(seq);
eventRefs.current.get(seq)?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Copy all events as text. Use the displayed order so users get the same
// sequence they see on screen — matters when sort is set to newest-first.
const handleCopyAll = useCallback(() => {
const text = displayItems
.map((item) => {
const label = getEventLabel(item);
const summary = getEventSummary(item);
return `[${label}] ${summary}`;
})
.join("\n");
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [displayItems]);
// Toggle tool filter
const toggleTool = useCallback((tool: string) => {
setSelectedTools((prev) => {
const next = new Set(prev);
if (next.has(tool)) next.delete(tool);
else next.add(tool);
return next;
});
}, []);
const clearFilters = useCallback(() => {
setSelectedTools(new Set());
}, []);
// Duration
const duration =
task.started_at && task.completed_at
? formatDuration(task.started_at, task.completed_at)
: isLive
? elapsed
: null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
// Status display
const statusBadge = isLive ? (
<span className="inline-flex items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-xs font-medium text-info">
<Loader2 className="h-3 w-3 animate-spin" />
{t(($) => $.transcript.status_running)}
</span>
) : task.status === "completed" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">
<CheckCircle2 className="h-3 w-3" />
{t(($) => $.transcript.status_completed)}
</span>
) : task.status === "failed" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium text-destructive">
<XCircle className="h-3 w-3" />
{t(($) => $.transcript.status_failed)}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground capitalize">
{task.status}
</span>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="!max-w-4xl !w-[calc(100vw-4rem)] !max-h-[calc(100vh-4rem)] !h-[calc(100vh-4rem)] flex flex-col !p-0 !gap-0 overflow-hidden"
showCloseButton={false}
>
<DialogTitle className="sr-only">{t(($) => $.transcript.dialog_title)}</DialogTitle>
{/* ── Header ─────────────────────────────────────────────── */}
<div className="border-b px-4 py-3 shrink-0 space-y-2">
{/* Top row: agent name, status, actions */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size={24} />
) : (
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-info/10 text-info">
<Bot className="h-3.5 w-3.5" />
</div>
)}
<span className="font-medium text-sm">{agentName}</span>
</div>
{statusBadge}
<div className="ml-auto flex items-center gap-1">
{items.length > 1 && (
<SortDirectionToggle
value={sortDirection}
onChange={handleSortDirectionChange}
labels={{
chronological: t(($) => $.transcript.sort_chronological),
newestFirst: t(($) => $.transcript.sort_newest_first),
ariaLabel: t(($) => $.transcript.sort_label),
}}
/>
)}
{filterOptions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
selectedTools.size > 0
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
: "text-muted-foreground hover:text-foreground hover:bg-accent",
)}
>
<Filter className="h-3 w-3" />
{t(($) => $.transcript.filter)}
{selectedTools.size > 0 && (
<span className="ml-0.5 rounded-full bg-blue-500/20 px-1.5 py-0 text-[10px] font-medium">
{selectedTools.size}
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
{filterOptions.map(([value, label]) => (
<DropdownMenuCheckboxItem
key={value}
checked={selectedTools.has(value)}
onCheckedChange={() => toggleTool(value)}
>
{label}
</DropdownMenuCheckboxItem>
))}
{selectedTools.size > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={clearFilters} className="text-muted-foreground">
{t(($) => $.transcript.clear_filters)}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<button
onClick={handleCopyAll}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? t(($) => $.transcript.copied) : selectedTools.size > 0 ? t(($) => $.transcript.copy_filtered) : t(($) => $.transcript.copy_all)}
</button>
<button
onClick={() => onOpenChange(false)}
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Metadata chips row */}
<div className="flex items-center gap-2 flex-wrap text-xs">
{/* Runtime provider */}
{runtimeInfo?.provider && (
<MetadataChip icon={<Cpu className="h-3 w-3" />}>
{formatProvider(runtimeInfo.provider)}
</MetadataChip>
)}
{/* Runtime environment */}
{runtimeInfo && (
<MetadataChip
icon={runtimeInfo.runtime_mode === "cloud" ? <Cloud className="h-3 w-3" /> : <Monitor className="h-3 w-3" />}
>
{runtimeInfo.name}
<span className="text-muted-foreground/60 ml-0.5">({runtimeInfo.runtime_mode})</span>
</MetadataChip>
)}
{/* Agent type / description */}
{agentInfo?.description && (
<MetadataChip icon={<Bot className="h-3 w-3" />}>
{agentInfo.description.length > 40 ? agentInfo.description.slice(0, 40) + "..." : agentInfo.description}
</MetadataChip>
)}
{/* Duration */}
{duration && (
<MetadataChip icon={<Clock className="h-3 w-3" />}>
{duration}
</MetadataChip>
)}
{/* Event counts */}
{toolCount > 0 && (
<MetadataChip>{t(($) => $.transcript.tool_calls, { count: toolCount })}</MetadataChip>
)}
<MetadataChip>
{selectedTools.size > 0
? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length })
: t(($) => $.transcript.events, { count: items.length })}
</MetadataChip>
{/* Created time */}
{task.created_at && (
<MetadataChip>
{new Date(task.created_at).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</MetadataChip>
)}
</div>
</div>
{/* ── Timeline progress bar ─────────────────────────────── */}
{displayItems.length > 0 && (
<div className="border-b px-4 py-2.5 shrink-0">
<TimelineBar
items={displayItems}
selectedSeq={selectedSeq}
onSegmentClick={handleSegmentClick}
/>
</div>
)}
{/* ── Optional header slot (e.g. webhook payload preview) ── */}
{headerSlot && (
<div className="border-b px-4 py-3 shrink-0 bg-muted/20">
{headerSlot}
</div>
)}
{/* ── Event list ─────────────────────────────────────────── */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
>
{displayItems.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{isLive ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t(($) => $.transcript.waiting_events)}
</div>
) : (
t(($) => $.transcript.no_data)
)}
</div>
) : (
<div className="divide-y">
{displayItems.map((item) => (
<TranscriptEventRow
key={item.seq}
ref={(el) => {
if (el) eventRefs.current.set(item.seq, el);
else eventRefs.current.delete(item.seq);
}}
item={item}
isSelected={selectedSeq === item.seq}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Sort direction toggle ──────────────────────────────────────────────────
interface SortDirectionToggleProps {
value: TranscriptSortDirection;
onChange: (dir: TranscriptSortDirection) => void;
labels: { chronological: string; newestFirst: string; ariaLabel: string };
}
function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) {
return (
<div
role="group"
aria-label={labels.ariaLabel}
className="inline-flex items-center rounded border bg-muted/40 p-0.5 text-xs"
>
<button
type="button"
aria-pressed={value === "chronological"}
title={labels.chronological}
onClick={() => onChange("chronological")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "chronological"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<ArrowDownNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.chronological}</span>
</button>
<button
type="button"
aria-pressed={value === "newest_first"}
title={labels.newestFirst}
onClick={() => onChange("newest_first")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "newest_first"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<ArrowUpNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.newestFirst}</span>
</button>
</div>
);
}
// ─── Metadata chip ──────────────────────────────────────────────────────────
function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-[11px] text-muted-foreground">
{icon}
{children}
</span>
);
}
function formatProvider(provider: string): string {
const map: Record<string, string> = {
claude: "Claude Code",
"claude-code": "Claude Code",
codex: "Codex",
pi: "Pi",
};
return map[provider.toLowerCase()] ?? provider;
}
// ─── Timeline bar (colored segments) ────────────────────────────────────────
function TimelineBar({
items,
selectedSeq,
onSegmentClick,
}: {
items: TimelineItem[];
selectedSeq: number | null;
onSegmentClick: (seq: number) => void;
}) {
const segments: { startIdx: number; endIdx: number; color: EventColor; count: number }[] = [];
let currentColor: EventColor | null = null;
let currentStart = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
const color = getEventColor(item);
if (color !== currentColor) {
if (currentColor !== null) {
segments.push({ startIdx: currentStart, endIdx: i - 1, color: currentColor, count: i - currentStart });
}
currentColor = color;
currentStart = i;
}
}
if (currentColor !== null) {
segments.push({ startIdx: currentStart, endIdx: items.length - 1, color: currentColor, count: items.length - currentStart });
}
return (
<div className="flex gap-0.5 h-5 rounded overflow-hidden" role="navigation" aria-label="Timeline">
{segments.map((seg) => {
const isSelected = selectedSeq !== null && items.slice(seg.startIdx, seg.endIdx + 1).some((i) => i.seq === selectedSeq);
const color = colorClasses[seg.color];
const widthPercent = (seg.count / items.length) * 100;
return (
<button
key={seg.startIdx}
className={cn(
"h-full transition-all duration-150 hover:opacity-80 relative group",
isSelected ? color.bgActive : color.bg,
"min-w-[4px]",
)}
style={{ width: `${Math.max(widthPercent, 0.5)}%` }}
onClick={() => onSegmentClick(items[seg.startIdx]!.seq)}
title={`${getEventLabel(items[seg.startIdx]!)}${seg.count > 1 ? ` (+${seg.count - 1} more)` : ""}`}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block z-10 pointer-events-none">
<div className="rounded bg-popover border px-2 py-1 text-[10px] text-popover-foreground shadow-md whitespace-nowrap">
{getEventLabel(items[seg.startIdx]!)}
{seg.count > 1 && <span className="text-muted-foreground ml-1">+{seg.count - 1}</span>}
</div>
</div>
</button>
);
})}
</div>
);
}
// ─── Transcript event row ───────────────────────────────────────────────────
interface TranscriptEventRowProps {
item: TimelineItem;
isSelected: boolean;
}
const TranscriptEventRow = ({
ref,
item,
isSelected,
}: TranscriptEventRowProps & { ref?: React.Ref<HTMLDivElement> }) => {
const [expanded, setExpanded] = useState(false);
const color = getEventColor(item);
const label = getEventLabel(item);
const summary = getEventSummary(item);
const hasDetail =
(item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) ||
(item.type === "tool_result" && item.output && item.output.length > 0) ||
(item.type === "thinking" && item.content && item.content.length > 0) ||
(item.type === "text" && item.content && item.content.length > 0) ||
(item.type === "error" && item.content && item.content.length > 0);
return (
<div
ref={ref}
className={cn(
"group transition-colors",
isSelected && "bg-accent/50",
)}
>
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="flex items-start gap-2 px-4 py-2">
{/* Type label badge */}
<span
className={cn(
"inline-flex items-center shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium mt-0.5 min-w-[60px] justify-center",
colorClasses[color].label,
)}
>
{item.type === "thinking" && <Brain className="h-3 w-3 mr-1 shrink-0" />}
{item.type === "error" && <AlertCircle className="h-3 w-3 mr-1 shrink-0" />}
{label}
</span>
{/* Summary */}
<CollapsibleTrigger
className={cn(
"flex-1 text-left text-xs min-w-0 py-0.5 transition-colors",
hasDetail ? "cursor-pointer hover:text-foreground" : "cursor-default",
item.type === "error" ? "text-destructive" : "text-muted-foreground",
)}
disabled={!hasDetail}
>
<div className="flex items-start gap-1.5">
{hasDetail && (
<ChevronRight
className={cn(
"h-3 w-3 shrink-0 mt-0.5 text-muted-foreground/50 transition-transform",
expanded && "rotate-90",
)}
/>
)}
<span className="truncate">{summary || "(empty)"}</span>
</div>
</CollapsibleTrigger>
{/* Seq number / index */}
<span className="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums mt-1">
#{item.seq}
</span>
</div>
{/* Expanded detail */}
{hasDetail && (
<CollapsibleContent>
<div className="px-4 pb-3">
<div className="ml-[72px] rounded bg-muted/40 border">
<EventDetailContent item={item} />
</div>
</div>
</CollapsibleContent>
)}
</Collapsible>
</div>
);
};
// ─── Event detail content ───────────────────────────────────────────────────
function EventDetailContent({ item }: { item: TimelineItem }) {
switch (item.type) {
case "tool_use":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
</pre>
);
case "tool_result":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{item.output
? item.output.length > 4000
? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
: redactSecrets(item.output)
: ""}
</pre>
);
case "thinking":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
{item.content ?? ""}
</pre>
);
case "text":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
{item.content ?? ""}
</pre>
);
case "error":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-destructive whitespace-pre-wrap break-words">
{item.content ?? ""}
</pre>
);
default:
return null;
}
}