From 887c9228b809f527898f84f8fa2439b7bc7cdf95 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:12:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(views):=20transcript=20reading=20hierarchy?= =?UTF-8?q?=20=E2=80=94=20presenter,=20expand=20modes,=20log-scale=20markd?= =?UTF-8?q?own=20(#5871)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(views): transcript reading hierarchy — presenter, expand modes, log-scale markdown The transcript rendered all five event kinds as identical truncated one-liners: agent replies and errors were clipped to half a sentence and only readable through an 11px bordered scroll box, indistinguishable in weight from dozens of tool rows. - trace-event-presenter.ts: pure presentation rules — kind, verbatim tool labels, newline-free one-line summaries, shell-wrapper stripping for command summaries, and per-kind default expansion. - Smart reading hierarchy: agent text renders in place through RichContent (compact density + transcript-prose log scale: headings demoted to body size, 12.5px/11px two-step type ramp) and errors read unboxed; thinking and tool rows stay folded to one line. - Expand mode menu replaces the expand-visible toggle: a persisted three-way preference (smart / expand all / collapse all) with per-item descriptions; row-level toggles override it until the mode changes. transcript-view-store migrates the legacy defaultExpanded boolean. - Tool params/output expand into a quiet borderless surface; long content fades behind "Show all" instead of a nested scrollbar. Co-Authored-By: Claude Fable 5 * refactor(views): transcript header — status-first identity row, facts as typography, list toolbar The header mixed five different natures in one visual container: status pill, attribution badge, nine equal-weight metadata chips (including truncated agent-description prose and a hostname), and the list controls — everything a rounded bordered capsule, so information and controls were indistinguishable and wrapped into a ragged 2-3 lines. Restructure into three fixed-purpose rows: - Identity: status pill anchors the left edge (the fact every viewer opens the dialog for), then the agent through its existing avatar component (hover card for details; fixes the empty-name case via agentInfo fallback) and a new borderless `inline` AttributionBadge variant. Close stays at the right. - Facts: one dot-separated plain-text line — provider, runtime mode (hostname in hover title), duration, counts, timestamp; the workdir path collapses into a copy icon with the path as tooltip. The agent-description chip is gone (it lives in the avatar hover card). - List toolbar: expand mode / sort / filter / copy move to their own row attached to the list they operate. Only controls keep borders; only status keeps color; entities render through their identity components; facts are typography. Co-Authored-By: Claude Fable 5 * refactor(views): transcript header — two-tier by necessity (ⓘ popover), trigger source, full status machine Following PR #5747's design lesson: split header metadata by whether a viewer needs it BEFORE reading. The flat facts line put diagnostics (provider, runtime hostname, mode, workdir, timestamps) on the always-visible surface with equal weight. - Identity row (tier 1): status · agent · trigger source · attribution. Trigger source ("Initial run" / "From a comment" / "Retry" / ...) answers "why does this run exist" — restored from #5747, was dropped. - Status badge now covers the full state machine (queued / dispatched / cancelled / waiting), not just running/completed/failed. - ⓘ Run-details popover (tier 2): runtime, provider, mode, workdir (copyable), created/started/completed — off the default surface. - Toolbar left carries duration + event/tool counts (a read-before-you- read summary), so the control row balances instead of stranding the controls at the right with empty left space. Persisted preferences (sort, filter selection, preserve-filters, expand density) are untouched — same controls, same behavior. Co-Authored-By: Claude Fable 5 * fix(views): transcript summary/controls — JSON preview, unify sort button, attribution to ⓘ - Tool-result summary took the first non-empty line, so pretty-printed JSON previewed as a lone "[" or "{". Collapse whitespace instead so it reads "[ { "id": ... } ]". - Sort was a segmented tab strip, reading as a different control family. Make it a single two-state toggle button on the shared toolbar chassis (shows current direction, flips on click) — every toolbar control now shares one chassis with a type-appropriate affordance (toggle / menu / action), not one forced shape. - Attribution left the identity row. The accountable human is audit metadata, not read-time context, and "on behalf of" misread the direction; the trigger *mechanism* stays on the identity row, the *person* moves to the ⓘ popover as "Triggered by". - Drop the tool-call count from the toolbar: redundant with the event count and informs no reading decision. Co-Authored-By: Claude Fable 5 * refactor(views): transcript toolbar uses the shared Button component + labels fix Toolbar controls were hand-rolled ), + DropdownMenuRadioGroup: ({ + value, + onValueChange, + children, + }: { + value?: string; + onValueChange?: (value: string) => void; + children: ReactNode; + }) => ( + {children} + ), + DropdownMenuRadioItem: ({ + value, + children, + }: { + value: string; + children: ReactNode; + }) => { + const ctx = React.useContext(RadioContext); + return ( + + ); + }, + }; +}); + +// The transcript body renders agent markdown through RichContent; stub it to +// keep these tests independent of the markdown pipeline. +vi.mock("../../rich-content", () => ({ + RichContent: ({ content }: { content: string }) => ( +
{content}
+ ), })); vi.mock("@multica/ui/components/ui/collapsible", async () => { @@ -122,15 +168,18 @@ vi.mock("@multica/ui/components/ui/collapsible", async () => { disabled, children, className: _className, + ...props }: ButtonHTMLAttributes) => { const ctx = React.useContext(Context); return ( @@ -226,9 +275,10 @@ beforeEach(() => { vi.mocked(api.listRuntimes).mockResolvedValue([]); useTranscriptViewStore.setState({ sortDirection: "chronological", - preserveFilters: false, selectedFilterKeys: [], - defaultExpanded: false, + // Legacy row assertions below expect one-line summaries; smart density is + // exercised by its own tests. + density: "collapsed", }); }); @@ -255,15 +305,16 @@ describe("AgentTranscriptDialog", () => { renderDialog([], { task: liveTask, isLive: true }); - await screen.findByText("hermes runtime"); + // Runtime detail now lives in the ⓘ popover; its trigger appearing proves + // the runtime loaded. The non-antigravity live state still waits. + await screen.findByRole("button", { name: "Run details" }); expect(screen.getByText("Waiting for events...")).toBeInTheDocument(); }); - it("preserves selected filters across dialog remounts when enabled", () => { + it("preserves selected filters across dialog remounts unconditionally", () => { const first = renderDialog(); fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Thinking" })); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Preserve filters" })); expect(screen.queryByText("Agent summary")).not.toBeInTheDocument(); expect(screen.getByText(/Thinking summary/)).toBeInTheDocument(); @@ -278,7 +329,6 @@ describe("AgentTranscriptDialog", () => { it("ignores stale persisted filter keys that are not available in the current transcript", () => { useTranscriptViewStore.setState({ - preserveFilters: true, selectedFilterKeys: ["thinking"], }); @@ -294,30 +344,50 @@ describe("AgentTranscriptDialog", () => { expect(screen.queryByText("No execution data recorded.")).not.toBeInTheDocument(); }); - it("expands and collapses every currently visible detailed row", () => { + it("switches wholesale between expand-all and collapse-all via the density menu", () => { renderDialog(); expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument(); expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Expand visible" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Expand all/ })); expect(screen.getByText(/Agent hidden detail/)).toBeInTheDocument(); + expect(screen.getByText(/Thinking hidden detail/)).toBeInTheDocument(); expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Collapse visible" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Collapse all/ })); expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument(); expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument(); }); - it("uses the default-expanded preference for newly opened transcripts", () => { - useTranscriptViewStore.setState({ defaultExpanded: true }); + it("smart density opens agent text in place and keeps process noise folded", () => { + useTranscriptViewStore.setState({ density: "smart" }); renderDialog(); - expect(screen.getByText(/Agent hidden detail/)).toBeInTheDocument(); - expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument(); + // Agent body reads without a click (through RichContent), tools stay folded. + expect(screen.getByTestId("rich-content")).toHaveTextContent("Agent hidden detail"); + expect(screen.queryByText(/Thinking hidden detail/)).not.toBeInTheDocument(); + expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument(); + }); + + it("row-level toggles override the density default until the mode changes", () => { + useTranscriptViewStore.setState({ density: "smart" }); + + renderDialog(); + + // Fold the default-open agent body back to one line. The `expanded` + // filter distinguishes the collapse trigger from the timeline segment, + // which also carries the "Agent" accessible name via its title. + fireEvent.click(screen.getByRole("button", { name: "Agent", expanded: true })); + expect(screen.queryByTestId("rich-content")).not.toBeInTheDocument(); + expect(screen.getByText("Agent summary")).toBeInTheDocument(); + + // Open a default-folded thinking row. + fireEvent.click(screen.getByRole("button", { name: /Thinking summary/ })); + expect(screen.getByText(/Thinking hidden detail/)).toBeInTheDocument(); }); it("copies RFC 3339 timestamps before event labels", () => { @@ -338,11 +408,13 @@ describe("AgentTranscriptDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Copy all" })); + // Full body (not the truncated summary) with the RFC 3339 prefix, events + // separated by a blank line. expect(copyTextMock).toHaveBeenCalledWith( [ - "[2026-06-08T00:00:00.000Z] [Agent] Agent summary", + "[2026-06-08T00:00:00.000Z] [Agent] Agent summary\nAgent hidden detail", "[2026-06-08T08:00:05.123Z] [Thinking] Thinking summary", - ].join("\n"), + ].join("\n\n"), ); }); @@ -364,7 +436,7 @@ describe("AgentTranscriptDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Copy all" })); expect(copyTextMock).toHaveBeenCalledWith( - ["[Agent] Missing timestamp", "[Error] Invalid timestamp"].join("\n"), + ["[Agent] Missing timestamp", "[Error] Invalid timestamp"].join("\n\n"), ); }); }); diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 3c96bb1507..e355dece87 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -14,17 +14,17 @@ import { Clock, Copy, Check, - Monitor, - Cloud, - Cpu, Filter, - Folder, ArrowDownNarrowWide, ArrowUpNarrowWide, + ListCollapse, + Info, } 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible"; import { DropdownMenu, @@ -33,19 +33,33 @@ import { DropdownMenuSeparator, DropdownMenuCheckboxItem, DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, } from "@multica/ui/components/ui/dropdown-menu"; import { ActorAvatar } from "../actor-avatar"; import { AttributionBadge } from "../../issues/components/attribution-badge"; +import { RichContent } from "../../rich-content"; import { api } from "@multica/core/api"; import { useTranscriptViewStore, + type TranscriptDetailDensity, type TranscriptFilterKey, 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 { + traceEventCopyText, + traceEventDefaultExpanded, + traceEventHasDetail, + traceEventKind, + traceEventLabel, + traceEventSummary, + traceEventSummaryIsMono, +} from "./trace-event-presenter"; import { useT } from "../../i18n"; +import "./task-transcript.css"; interface AgentTranscriptDialogProps { open: boolean; @@ -93,23 +107,8 @@ const colorClasses: Record 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; - 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 hasEventDetail(item: TimelineItem): boolean { - return ( - (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) - ); -} - -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); @@ -187,12 +133,61 @@ function formatElapsedMs(ms: number): string { return `${minutes}m ${secs}s`; } -function formatEventForClipboard(item: TimelineItem): string { - const label = getEventLabel(item); - const summary = getEventSummary(item); - const date = item.created_at ? new Date(item.created_at) : null; - const timestamp = date && !Number.isNaN(date.getTime()) ? `[${date.toISOString()}] ` : ""; - return `${timestamp}[${label}] ${summary}`; +function formatRunTime(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +// ─── 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/80", mono && "font-mono"); + if (onCopy) { + return ( + + ); + } + return ( +
+ {label} + {value} +
+ ); } // ─── Main dialog ──────────────────────────────────────────────────────────── @@ -223,27 +218,29 @@ export function AgentTranscriptDialog({ const [copiedWorkdir, setCopiedWorkdir] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [runtimeInfo, setRuntimeInfo] = useState(null); - const [sessionFilterKeys, setSessionFilterKeys] = useState([]); - const [expandedSeqs, setExpandedSeqs] = useState>(() => new Set()); + // Row-level expand overrides. A row the user toggled follows the toggle; any + // other row follows the density preference (see traceEventDefaultExpanded). + // Switching density or task resets the overrides wholesale. + const [rowOverrides, setRowOverrides] = useState>(() => new Map()); const sortDirection = useTranscriptViewStore((s) => s.sortDirection); const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection); - const preserveFilters = useTranscriptViewStore((s) => s.preserveFilters); - const setPreserveFilters = useTranscriptViewStore((s) => s.setPreserveFilters); - const persistedFilterKeys = useTranscriptViewStore((s) => s.selectedFilterKeys); - const setPersistedFilterKeys = useTranscriptViewStore((s) => s.setSelectedFilterKeys); - const togglePersistedFilterKey = useTranscriptViewStore((s) => s.toggleFilterKey); - const clearPersistedFilterKeys = useTranscriptViewStore((s) => s.clearFilterKeys); - const defaultExpanded = useTranscriptViewStore((s) => s.defaultExpanded); - const setDefaultExpanded = useTranscriptViewStore((s) => s.setDefaultExpanded); + // 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 density = useTranscriptViewStore((s) => s.density); + const setDensity = useTranscriptViewStore((s) => s.setDensity); const virtuosoRef = useRef(null); - const autoExpandedSeqsRef = useRef>(new Set()); - const initializedTaskRef = useRef(null); - const previousDefaultExpandedRef = useRef(defaultExpanded); - const selectedFilterKeys = preserveFilters ? persistedFilterKeys : sessionFilterKeys; + + useEffect(() => { + setRowOverrides(new Map()); + }, [task.id, density]); // Derive filter options from each item: // tool_use / tool_result → filter value = tool, display = "tool:Bash" - // other types → display from getEventLabel + // other types → display from traceEventLabel const filterOptions = useMemo(() => { const options = new Map(); for (const item of items) { @@ -252,7 +249,7 @@ export function AgentTranscriptDialog({ if (!options.has(key)) options.set(key, key); } else { if (!options.has(key)) { - options.set(key, getEventLabel(item)); + options.set(key, traceEventLabel(item)); } } } @@ -301,37 +298,6 @@ export function AgentTranscriptDialog({ sortDirection === "newest_first" ? 1_000_000 - displayItems.length : 0; const listEpoch = `${task.id}:${sortDirection}:${activeFilterKeys.join(",")}`; - const detailSeqs = useMemo( - () => displayItems.filter(hasEventDetail).map((item) => item.seq), - [displayItems], - ); - - const allVisibleDetailsExpanded = - detailSeqs.length > 0 && detailSeqs.every((seq) => expandedSeqs.has(seq)); - - useEffect(() => { - const switchedDefaultOn = - defaultExpanded && previousDefaultExpandedRef.current !== defaultExpanded; - previousDefaultExpandedRef.current = defaultExpanded; - - if (initializedTaskRef.current !== task.id || switchedDefaultOn) { - initializedTaskRef.current = task.id; - autoExpandedSeqsRef.current = new Set(defaultExpanded ? detailSeqs : []); - setExpandedSeqs(defaultExpanded ? new Set(detailSeqs) : new Set()); - return; - } - - if (!defaultExpanded) return; - - const unseen = detailSeqs.filter((seq) => !autoExpandedSeqsRef.current.has(seq)); - if (unseen.length === 0) return; - - for (const seq of unseen) { - autoExpandedSeqsRef.current.add(seq); - } - setExpandedSeqs((prev) => new Set([...prev, ...unseen])); - }, [task.id, defaultExpanded, detailSeqs]); - // 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. @@ -400,9 +366,11 @@ export function AgentTranscriptDialog({ }, [task.relative_work_dir]); const handleCopyAll = useCallback(() => { + // Copy the full body of each event (not the truncated row summary), with + // the same secret redaction the detail view applies. const text = displayItems - .map(formatEventForClipboard) - .join("\n"); + .map((item) => redactSecrets(traceEventCopyText(item))) + .join("\n\n"); void copyText(text).then((ok) => { if (!ok) return; setCopied(true); @@ -410,68 +378,11 @@ export function AgentTranscriptDialog({ }); }, [displayItems]); - const toggleSessionFilterKey = useCallback((key: TranscriptFilterKey) => { - setSessionFilterKeys((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - return Array.from(next); - }); - }, []); - - const clearFilters = useCallback(() => { - if (preserveFilters) { - clearPersistedFilterKeys(); - return; - } - setSessionFilterKeys([]); - }, [clearPersistedFilterKeys, preserveFilters]); - - const toggleFilterKey = useCallback( - (key: TranscriptFilterKey) => { - if (preserveFilters) { - togglePersistedFilterKey(key); - return; - } - toggleSessionFilterKey(key); - }, - [preserveFilters, togglePersistedFilterKey, toggleSessionFilterKey], - ); - - const handlePreserveFiltersChange = useCallback( - (next: boolean) => { - if (next) { - setPersistedFilterKeys(sessionFilterKeys); - } else { - setSessionFilterKeys(persistedFilterKeys); - } - setPreserveFilters(next); - }, - [persistedFilterKeys, sessionFilterKeys, setPersistedFilterKeys, setPreserveFilters], - ); - - const handleToggleVisibleExpanded = useCallback(() => { - for (const seq of detailSeqs) { - autoExpandedSeqsRef.current.add(seq); - } - setExpandedSeqs((prev) => { - if (allVisibleDetailsExpanded) { - const next = new Set(prev); - for (const seq of detailSeqs) { - next.delete(seq); - } - return next; - } - return new Set([...prev, ...detailSeqs]); - }); - }, [allVisibleDetailsExpanded, detailSeqs]); const handleRowExpandedChange = useCallback((seq: number, expanded: boolean) => { - autoExpandedSeqsRef.current.add(seq); - setExpandedSeqs((prev) => { - const next = new Set(prev); - if (expanded) next.add(seq); - else next.delete(seq); + setRowOverrides((prev) => { + const next = new Map(prev); + next.set(seq, expanded); return next; }); }, []); @@ -484,34 +395,112 @@ export function AgentTranscriptDialog({ ? elapsed : null; - const toolCount = items.filter((i) => i.type === "tool_use").length; const copyTranscriptLabel = copied ? t(($) => $.transcript.copied) : activeFilterKeys.length > 0 ? t(($) => $.transcript.copy_filtered) : t(($) => $.transcript.copy_all); - // Status display - const statusBadge = isLive ? ( - - - {t(($) => $.transcript.status_running)} - - ) : task.status === "completed" ? ( - - - {t(($) => $.transcript.status_completed)} - - ) : task.status === "failed" ? ( - - - {t(($) => $.transcript.status_failed)} - - ) : ( - - {task.status} - - ); + // 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-xs font-medium"; + switch (effectiveStatus) { + case "running": + return ( + + + {t(($) => $.transcript.status_running)} + + ); + case "completed": + return ( + + + {t(($) => $.transcript.status_completed)} + + ); + case "failed": + return ( + + + {t(($) => $.transcript.status_failed)} + + ); + case "cancelled": + return ( + + + {t(($) => $.transcript.status_cancelled)} + + ); + case "queued": + return ( + + {t(($) => $.transcript.status_queued)} + + ); + case "dispatched": + return ( + + {t(($) => $.transcript.status_dispatched)} + + ); + case "waiting_local_directory": + return ( + + {t(($) => $.transcript.status_waiting)} + + ); + default: + return ( + + {task.status} + + ); + } + })(); + + // Trigger source: one word answering "why does this run exist" — more useful + // up front than the runtime/provider diagnostics, which move to the ⓘ popover. + const triggerLabel = task.parent_task_id + ? t(($) => $.transcript.trigger_retry) + : 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" || task.handoff_note + ? 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 ? formatProvider(runtimeInfo.provider) : null; + const createdLabel = task.created_at ? formatRunTime(task.created_at) : null; + const startedLabel = task.started_at ? formatRunTime(task.started_at) : null; + const completedLabel = task.completed_at ? formatRunTime(task.completed_at) : null; + // "When was this run created" — a read-before-you-read fact worth the toolbar + // surface (the ⓘ popover keeps the full-precision created/started/completed). + const createdShort = task.created_at + ? new Date(task.created_at).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : null; + const hasTriggeredBy = !!task.attribution?.initiator; + const hasRunDetails = + !!runtimeInfo || + !!task.relative_work_dir || + !!createdLabel || + !!startedLabel || + !!completedLabel; return ( @@ -521,213 +510,244 @@ export function AgentTranscriptDialog({ > {t(($) => $.transcript.dialog_title)} - {/* ── Header ─────────────────────────────────────────────── */} -
- {/* Top row: agent name, status, actions */} -
+ {/* ── Header: identity only ────────────────────────────────── + Tier 1 — everything a viewer needs BEFORE reading: outcome + (status anchors the left), who ran it, why it exists (trigger), + and who's accountable. All diagnostics move to the ⓘ popover. */} +
+
+ {statusBadge} + {/* Primary identity: the agent that ran this. It is the one + foreground entity — avatar + medium weight. */}
{task.agent_id ? ( - + ) : ( -
- +
+
)} - {agentName} + + {agentName || agentInfo?.name || ""} + +
+ {/* Provenance, one muted secondary unit set apart from the agent: + who triggered the run and how — reads as " · ", + not three peer entities. The person's avatar is dropped here so + two same-size faces don't read as two agents. */} +
+ {hasTriggeredBy && ( + <> + + + + )} + {triggerLabel}
- {statusBadge} - - {/* Accountable member (MUL-4302 §9): whose behalf this run is on. */} - - -
- {detailSeqs.length > 0 && ( - - )} - {items.length > 1 && ( - $.transcript.sort_chronological), - newestFirst: t(($) => $.transcript.sort_newest_first), - ariaLabel: t(($) => $.transcript.sort_label), - }} - /> - )} - {filterOptions.length > 0 && ( - - $.transcript.filter)} - className={cn( - "flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs transition-colors", - activeFilterKeys.length > 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", - )} +
+ {hasRunDetails && ( + + $.transcript.run_info)} + title={t(($) => $.transcript.run_info)} + className="text-muted-foreground" + /> + } > - - {t(($) => $.transcript.filter)} - {activeFilterKeys.length > 0 && ( - - {activeFilterKeys.length} - - )} - - - {filterOptions.map(([value, label]) => ( - toggleFilterKey(value)} - > - {label} - - ))} - - handlePreserveFiltersChange(checked === true)} - > - {t(($) => $.transcript.preserve_filters)} - - setDefaultExpanded(checked === true)} - > - {t(($) => $.transcript.default_expanded)} - - {selectedFilterKeys.length > 0 && ( - <> - - - {t(($) => $.transcript.clear_filters)} - - - )} - - + + + +
+ {t(($) => $.transcript.run_info)} +
+
+ {runtimeInfo && ( + $.transcript.details_runtime)} value={runtimeInfo.name} /> + )} + {providerLabel && ( + $.transcript.details_provider)} value={providerLabel} /> + )} + {runtimeInfo && ( + $.transcript.details_mode)} value={runtimeInfo.runtime_mode} /> + )} + {task.relative_work_dir && ( + $.transcript.details_workdir)} + value={task.relative_work_dir} + mono + onCopy={handleCopyWorkdir} + copied={copiedWorkdir} + copyTitle={t(($) => $.transcript.copy_workdir)} + /> + )} + {createdLabel && ( + $.transcript.details_created)} value={createdLabel} /> + )} + {startedLabel && ( + $.transcript.details_started)} value={startedLabel} /> + )} + {completedLabel && ( + $.transcript.details_completed)} value={completedLabel} /> + )} +
+
+
)} - - +
+
- {/* Metadata chips row */} -
- {/* Runtime provider */} - {runtimeInfo?.provider && ( - }> - {formatProvider(runtimeInfo.provider)} - + {/* ── List toolbar: read-before-you-read summary (left) + controls + (right). Duration + event count fill the left, so the row balances + instead of leaving dead space. ── */} +
+
+ {createdShort && ( + <> + {t(($) => $.transcript.fact_created, { time: createdShort })} + + )} - - {/* Runtime environment */} - {runtimeInfo && ( - : } - > - {runtimeInfo.name} - ({runtimeInfo.runtime_mode}) - - )} - - {/* Agent type / description */} - {agentInfo?.description && ( - }> - {agentInfo.description.length > 40 ? agentInfo.description.slice(0, 40) + "..." : agentInfo.description} - - )} - - {/* Duration */} {duration && ( - }> - {duration} - + <> + {t(($) => $.transcript.fact_took, { duration })} + + )} - - {/* Event counts */} - {toolCount > 0 && ( - {t(($) => $.transcript.tool_calls, { count: toolCount })} - )} - + {activeFilterKeys.length > 0 ? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length }) : t(($) => $.transcript.events, { count: items.length })} - - - {/* Working directory — server-derived display path. Falls back to - nothing when older backends omit the field rather than rendering - `work_dir` raw and leaking the user's home directory. The - absolute `task.work_dir` deliberately never reaches the DOM - anywhere — only `relative_work_dir` is safe to render / put in - title / copy to clipboard, because the server has already - stripped $HOME and the username out of it. The button - truncates because real workdir paths are routinely long - enough to push every other chip off the row. */} - {task.relative_work_dir && ( - + +
+
+ {items.length > 0 && ( + + $.transcript.density_label)} + className="text-muted-foreground" + /> + } + > + + + {density === "smart" + ? t(($) => $.transcript.density_smart) + : density === "expanded" + ? t(($) => $.transcript.density_expanded) + : t(($) => $.transcript.density_collapsed)} + + + + setDensity(value as TranscriptDetailDensity)} + > + {( + [ + ["smart", t(($) => $.transcript.density_smart), t(($) => $.transcript.density_smart_desc)], + ["expanded", t(($) => $.transcript.density_expanded), t(($) => $.transcript.density_expanded_desc)], + ["collapsed", t(($) => $.transcript.density_collapsed), t(($) => $.transcript.density_collapsed_desc)], + ] as const + ).map(([value, name, description]) => ( + + + {name} + + {description} + + + + ))} + + + )} - - {/* Created time */} - {task.created_at && ( - - {new Date(task.created_at).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - + {items.length > 1 && ( + $.transcript.sort_chronological), + newestFirst: t(($) => $.transcript.sort_newest_first), + ariaLabel: t(($) => $.transcript.sort_label), + }} + /> )} + {filterOptions.length > 0 && ( + + 0 ? "brand" : "ghost"} + size="sm" + aria-label={t(($) => $.transcript.filter)} + className={activeFilterKeys.length > 0 ? undefined : "text-muted-foreground"} + /> + } + > + + {t(($) => $.transcript.filter)} + {activeFilterKeys.length > 0 && ( + + {activeFilterKeys.length} + + )} + + + {filterOptions.map(([value, label]) => ( + toggleFilterKey(value)} + > + {label} + + ))} + {selectedFilterKeys.length > 0 && ( + <> + + + {t(($) => $.transcript.clear_filters)} + + + )} + + + )} +
@@ -783,7 +803,9 @@ export function AgentTranscriptDialog({ handleRowExpandedChange(item.seq, expanded)} /> )} @@ -803,54 +825,38 @@ interface SortDirectionToggleProps { labels: { chronological: string; newestFirst: string; ariaLabel: string }; } +// Sort is a two-state toggle, not a mode picker: one button showing the +// current direction that flips on click. Shares the toolbar button chassis so +// it reads as the same control family as density/filter/copy — no tab strip. function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) { + const isChronological = value === "chronological"; return ( -
onChange(isChronological ? "newest_first" : "chronological")} aria-label={labels.ariaLabel} - className="inline-flex shrink-0 items-center rounded border bg-muted/40 p-0.5 text-xs" + title={labels.ariaLabel} + className="text-muted-foreground" > - - -
+ )} + + {isChronological ? labels.chronological : labels.newestFirst} + + ); } -// ─── Metadata chip ────────────────────────────────────────────────────────── +// ─── Facts line separator ─────────────────────────────────────────────────── -function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) { +function FactDot() { return ( - - {icon} - {children} + + · ); } @@ -913,11 +919,11 @@ function TimelineBar({ )} 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)` : ""}`} + title={`${traceEventLabel(items[seg.startIdx]!)}${seg.count > 1 ? ` (+${seg.count - 1} more)` : ""}`} >
- {getEventLabel(items[seg.startIdx]!)} + {traceEventLabel(items[seg.startIdx]!)} {seg.count > 1 && +{seg.count - 1}}
@@ -943,21 +949,29 @@ const TranscriptEventRow = ({ expanded, onExpandedChange, }: TranscriptEventRowProps) => { + const { t } = useT("agents"); + const kind = traceEventKind(item); const color = getEventColor(item); - const label = getEventLabel(item); - const summary = getEventSummary(item); + const label = traceEventLabel(item); + const summary = traceEventSummary(item); const date = useMemo( () => (item.created_at ? new Date(item.created_at) : null), [item.created_at], ); - const hasDetail = hasEventDetail(item); + const hasDetail = traceEventHasDetail(item); + // Prose kinds swap the one-line summary for the full body in place when + // expanded (no box). Tool kinds keep the summary line and reveal the + // params/output surface below it. + const isProse = kind !== "tool_use" && kind !== "tool_result"; + const showInlineBody = isProse && hasDetail && expanded; return (
@@ -974,27 +988,63 @@ const TranscriptEventRow = ({ {label} - {/* Summary */} - -
- {hasDetail && ( - - )} - {summary || "(empty)"} + {showInlineBody ? ( +
+ + + +
+ {kind === "agent" ? ( + + ) : ( +
+ {item.content ?? ""} +
+ )} +
- + ) : ( + +
+ {hasDetail && ( + + )} + + {summary || t(($) => $.transcript.no_output)} + +
+
+ )} {/* Seq number / index */} @@ -1013,12 +1063,23 @@ const TranscriptEventRow = ({ )}
- {/* Expanded detail */} - {hasDetail && ( + {/* Expanded params/output for tool kinds — a quiet, borderless surface + aligned to the content column. */} + {!isProse && hasDetail && (
-
- +
+ 4000 + ? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)" + : redactSecrets(item.output) + : "" + } + />
@@ -1028,45 +1089,39 @@ const TranscriptEventRow = ({ ); }; -// ─── Event detail content ─────────────────────────────────────────────────── +// ─── Tool detail surface ──────────────────────────────────────────────────── -function EventDetailContent({ item }: { item: TimelineItem }) { - switch (item.type) { - case "tool_use": - return ( -
-          {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
-        
- ); - case "tool_result": - return ( -
-          {item.output
-            ? item.output.length > 4000
-              ? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
-              : redactSecrets(item.output)
-            : ""}
-        
- ); - case "thinking": - return ( -
-          {item.content ?? ""}
-        
- ); - case "text": - return ( -
-          {item.content ?? ""}
-        
- ); - case "error": - return ( -
-          {item.content ?? ""}
-        
- ); - default: - return null; - } +/** + * Long content fades out behind a "show all" affordance instead of trapping a + * nested scrollbar inside the virtualized list. + */ +function ToolDetailSurface({ text }: { text: string }) { + const { t } = useT("agents"); + const [showAll, setShowAll] = useState(false); + const isLong = text.length > 1600 || text.split("\n").length > 14; + + return ( +
+
+        {text}
+      
+ {isLong && !showAll && ( +
+ +
+ )} +
+ ); } + diff --git a/packages/views/common/task-transcript/task-transcript.css b/packages/views/common/task-transcript/task-transcript.css new file mode 100644 index 0000000000..019033200e --- /dev/null +++ b/packages/views/common/task-transcript/task-transcript.css @@ -0,0 +1,62 @@ +/* + * Log-scale markdown for transcript agent text (rendered via RichContent with + * className="transcript-prose"). Structure is preserved; the type scale is + * flattened to the transcript's data-panel rhythm: body 12.5px, mono 11px, + * headings demoted to body size. Selectors double up with .rich-text-editor + * (the RichContent root) so they outrank the shared base styles regardless of + * CSS load order. + */ + +.rich-text-editor.transcript-prose { + font-size: 12.5px; + line-height: 1.7; +} + +.rich-text-editor.transcript-prose > :first-child { + margin-top: 0; +} + +.rich-text-editor.transcript-prose > :last-child { + margin-bottom: 0; +} + +.rich-text-editor.transcript-prose p { + margin: 0.25rem 0; +} + +.rich-text-editor.transcript-prose ul, +.rich-text-editor.transcript-prose ol { + margin: 0.25rem 0; + padding-left: 1.25rem; +} + +.rich-text-editor.transcript-prose li { + margin: 0.1rem 0; +} + +/* A transcript is not an article: every heading demotes to emphasized body. */ +.rich-text-editor.transcript-prose h1, +.rich-text-editor.transcript-prose h2, +.rich-text-editor.transcript-prose h3, +.rich-text-editor.transcript-prose h4, +.rich-text-editor.transcript-prose h5, +.rich-text-editor.transcript-prose h6 { + font-size: 12.5px; + font-weight: 500; + margin: 0.5rem 0 0.15rem; +} + +.rich-text-editor.transcript-prose code { + font-size: 11px; +} + +.rich-text-editor.transcript-prose pre, +.rich-text-editor.transcript-prose .code-block-wrapper { + font-size: 11px; + margin: 0.35rem 0; +} + +.rich-text-editor.transcript-prose blockquote { + margin: 0.35rem 0; + font-size: 12.5px; +} diff --git a/packages/views/common/task-transcript/trace-event-presenter.test.ts b/packages/views/common/task-transcript/trace-event-presenter.test.ts new file mode 100644 index 0000000000..027fa4e265 --- /dev/null +++ b/packages/views/common/task-transcript/trace-event-presenter.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + stripShellWrapper, + traceEventCopyText, + traceEventDefaultExpanded, + traceEventHasDetail, + traceEventKind, + traceEventLabel, + traceEventSummary, + traceToolArgSummary, +} from "./trace-event-presenter"; + +describe("traceEventKind / traceEventLabel", () => { + it("maps the five persisted types and keeps unknown types as generic", () => { + expect(traceEventKind({ type: "text" })).toBe("agent"); + expect(traceEventKind({ type: "thinking" })).toBe("thinking"); + expect(traceEventKind({ type: "tool_use" })).toBe("tool_use"); + expect(traceEventKind({ type: "tool_result" })).toBe("tool_result"); + expect(traceEventKind({ type: "error" })).toBe("error"); + expect(traceEventKind({ type: "provider_custom" })).toBe("generic"); + }); + + it("shows provider-native tool names verbatim and surfaces raw unknown types", () => { + expect(traceEventLabel({ type: "tool_use", tool: "exec_command" })).toBe("exec_command"); + expect(traceEventLabel({ type: "tool_result", tool: "patch_apply" })).toBe("patch_apply"); + expect(traceEventLabel({ type: "tool_use" })).toBe("Tool"); + expect(traceEventLabel({ type: "provider_custom" })).toBe("provider_custom"); + }); +}); + +describe("stripShellWrapper", () => { + it("strips login-shell wrappers but keeps bare commands", () => { + expect(stripShellWrapper("/bin/zsh -lc 'rm ./reply.md'")).toBe("rm ./reply.md"); + expect(stripShellWrapper('/bin/bash -c "git status"')).toBe("git status"); + expect(stripShellWrapper("sh -c 'ls -la'")).toBe("ls -la"); + expect(stripShellWrapper("pnpm test")).toBe("pnpm test"); + // Mismatched quotes are not a wrapper match. + expect(stripShellWrapper("/bin/zsh -lc 'echo hi\"")).toBe("/bin/zsh -lc 'echo hi\""); + }); +}); + +describe("traceToolArgSummary", () => { + it("prefers query, then paths (shortened), then command with wrapper stripped", () => { + expect(traceToolArgSummary({ query: "flaky tests", command: "x" })).toBe("flaky tests"); + expect(traceToolArgSummary({ file_path: "/a/b/c/d/e.ts" })).toBe(".../d/e.ts"); + expect(traceToolArgSummary({ command: "/bin/zsh -lc 'kubectl get pods -n prd'" })).toBe( + "kubectl get pods -n prd", + ); + }); + + it("falls back to the first short string value and tolerates empty input", () => { + expect(traceToolArgSummary({ n: 3, note: "short value" })).toBe("short value"); + expect(traceToolArgSummary(undefined)).toBe(""); + expect(traceToolArgSummary({})).toBe(""); + }); +}); + +describe("traceEventSummary", () => { + it("takes the first non-empty line for agent text", () => { + expect(traceEventSummary({ type: "text", content: "\n\nFirst line\nrest" })).toBe( + "First line", + ); + }); + + it("collapses pretty-printed JSON output to a content preview, not a lone bracket", () => { + const output = '[\n {\n "id": "694c",\n "title": "x"\n }\n]'; + expect(traceEventSummary({ type: "tool_result", output })).toBe( + '[ { "id": "694c", "title": "x" } ]', + ); + }); + + it("retains unknown events instead of dropping them", () => { + expect(traceEventSummary({ type: "custom", content: "payload" })).toBe("payload"); + }); +}); + +describe("traceEventCopyText", () => { + it("copies the full untruncated body, not the one-line summary", () => { + const longOutput = "line 1\n".repeat(60); + expect(traceEventCopyText({ type: "tool_result", tool: "Bash", output: longOutput })).toBe( + `[Bash] ${longOutput}`, + ); + expect( + traceEventCopyText({ type: "tool_use", tool: "Bash", input: { command: "ls" } }), + ).toBe('[Bash] {\n "command": "ls"\n}'); + expect(traceEventCopyText({ type: "text", content: "full\nagent\nreply" })).toBe( + "[Agent] full\nagent\nreply", + ); + }); + + it("emits a bare label when the event has no body", () => { + expect(traceEventCopyText({ type: "tool_use", tool: "Bash" })).toBe("[Bash]"); + }); +}); + +describe("traceEventDefaultExpanded", () => { + const agent = { type: "text", content: "hello" }; + const error = { type: "error", content: "boom" }; + const thinking = { type: "thinking", content: "hmm" }; + const tool = { type: "tool_use", tool: "Bash", input: { command: "ls" } }; + + it("smart: agent and error read without a click, process noise stays folded", () => { + expect(traceEventDefaultExpanded(agent, "smart")).toBe(true); + expect(traceEventDefaultExpanded(error, "smart")).toBe(true); + expect(traceEventDefaultExpanded(thinking, "smart")).toBe(false); + expect(traceEventDefaultExpanded(tool, "smart")).toBe(false); + }); + + it("expanded/collapsed override the hierarchy wholesale", () => { + expect(traceEventDefaultExpanded(thinking, "expanded")).toBe(true); + expect(traceEventDefaultExpanded(agent, "collapsed")).toBe(false); + }); + + it("a row without detail never expands", () => { + expect(traceEventDefaultExpanded({ type: "text" }, "expanded")).toBe(false); + expect(traceEventHasDetail({ type: "tool_use", input: {} })).toBe(false); + }); +}); diff --git a/packages/views/common/task-transcript/trace-event-presenter.ts b/packages/views/common/task-transcript/trace-event-presenter.ts new file mode 100644 index 0000000000..6ccddda69d --- /dev/null +++ b/packages/views/common/task-transcript/trace-event-presenter.ts @@ -0,0 +1,206 @@ +// Trace Event Presenter — the pure readability layer for the execution +// transcript. Given one timeline event it decides visual kind, label, one-line +// summary, and default expansion, encoding the reading hierarchy: +// +// 1. Agent text is the primary layer and reads without a click. +// 2. Errors stand out and also read without a click. +// 3. Tool calls are compact — provider-native name + most-informative arg. +// 4. Tool results and thinking are de-emphasized and collapsed by default. +// 5. Unknown event types are retained as a generic event, never dropped. +// +// This module owns no React and no fetching, so it is unit-testable in +// isolation and independent of whichever list shell renders the events. + +import type { TranscriptDetailDensity } from "@multica/core/agents/stores"; + +export type { TranscriptDetailDensity }; + +export interface TraceEvent { + seq?: number; + type: string; + tool?: string; + content?: string; + input?: Record; + output?: string; + created_at?: string; +} + +/** Visual kind driving color/emphasis. `generic` covers any unknown `type`. */ +export type TraceEventKind = + | "agent" + | "thinking" + | "tool_use" + | "tool_result" + | "error" + | "generic"; + +export function traceEventKind(event: TraceEvent): TraceEventKind { + switch (event.type) { + case "text": + return "agent"; + case "thinking": + return "thinking"; + case "tool_use": + return "tool_use"; + case "tool_result": + return "tool_result"; + case "error": + return "error"; + default: + return "generic"; + } +} + +/** + * Human label. Tool events show the provider-native tool name verbatim + * (exec_command, patch_apply — never renamed); an unknown type shows its own + * raw type string so evidence is never mislabeled. + */ +export function traceEventLabel(event: TraceEvent): string { + switch (event.type) { + case "text": + return "Agent"; + case "thinking": + return "Thinking"; + case "tool_use": + return event.tool && event.tool.length > 0 ? event.tool : "Tool"; + case "tool_result": + return event.tool && event.tool.length > 0 ? event.tool : "Result"; + case "error": + return "Error"; + default: + return event.type && event.type.length > 0 ? event.type : "Event"; + } +} + +/** Shorten a long path to ".../parent/leaf" so a tool summary stays one line. */ +export function shortenTracePath(p: string): string { + const parts = p.split("/"); + if (parts.length <= 3) return p; + return ".../" + parts.slice(-2).join("/"); +} + +// Providers commonly wrap the real command in a login-shell invocation; the +// wrapper is pure noise in a one-line summary (the full original stays in the +// expanded params). Matches ` -lc ''` / `-c ""` forms. +const SHELL_WRAPPER_PATTERN = + /^(?:\/[\w./-]*\/)?(?:zsh|bash|sh|fish)\s+(?:-[a-z]+\s+)*(['"])([\s\S]+)\1$/; + +export function stripShellWrapper(command: string): string { + const match = SHELL_WRAPPER_PATTERN.exec(command.trim()); + return match?.[2] ?? command; +} + +function clip(value: string, max: number): string { + return value.length > max ? value.slice(0, max) + "..." : value; +} + +/** + * The single most informative argument of a tool call, as one line. Preference + * order matches what a reviewer scans for first, falling back to the first + * short string value. + */ +export function traceToolArgSummary(input: Record | undefined): string { + if (!input) return ""; + const str = (v: unknown): string => (typeof v === "string" ? v : ""); + if (str(input.query)) return str(input.query); + if (str(input.file_path)) return shortenTracePath(str(input.file_path)); + if (str(input.path)) return shortenTracePath(str(input.path)); + if (str(input.pattern)) return str(input.pattern); + if (str(input.description)) return str(input.description); + if (str(input.command)) return clip(stripShellWrapper(str(input.command)), 120); + if (str(input.prompt)) return clip(str(input.prompt), 120); + if (str(input.skill)) return str(input.skill); + for (const v of Object.values(input)) { + if (typeof v === "string" && v.length > 0 && v.length < 120) return v; + } + return ""; +} + +function firstLine(value: string | undefined): string { + return value?.split("\n").find((l) => l.trim().length > 0) ?? ""; +} + +/** + * Collapse all whitespace runs to single spaces. Unlike firstLine this keeps + * content that spans lines, so a pretty-printed JSON result previews as + * `[ { "id": ... } ]` instead of a lone opening bracket. + */ +function collapseWhitespace(value: string | undefined): string { + return (value ?? "").replace(/\s+/g, " ").trim(); +} + +/** One-line summary for the collapsed row — never contains a newline. */ +export function traceEventSummary(event: TraceEvent): string { + switch (traceEventKind(event)) { + case "thinking": + return clip(firstLine(event.content), 200); + case "tool_use": + return traceToolArgSummary(event.input); + case "tool_result": + return clip(collapseWhitespace(event.output), 200); + default: + return firstLine(event.content ?? event.output); + } +} + +/** + * Full, untruncated text for "copy all" — the complete body, not the one-line + * summary. Tool calls copy their full input JSON; results and prose copy their + * whole content. An RFC 3339 timestamp prefixes the line when the event has a + * valid `created_at` (#5873). Callers apply secret redaction on the result. + */ +export function traceEventCopyText(event: TraceEvent): string { + const label = traceEventLabel(event); + let body: string; + switch (traceEventKind(event)) { + case "tool_use": + body = event.input ? JSON.stringify(event.input, null, 2) : ""; + break; + case "tool_result": + body = event.output ?? ""; + break; + default: + body = event.content ?? ""; + } + const date = event.created_at ? new Date(event.created_at) : null; + const timestamp = date && !Number.isNaN(date.getTime()) ? `[${date.toISOString()}] ` : ""; + return body ? `${timestamp}[${label}] ${body}` : `${timestamp}[${label}]`; +} + +export function traceEventHasDetail(event: TraceEvent): boolean { + switch (traceEventKind(event)) { + case "tool_use": + return !!event.input && Object.keys(event.input).length > 0; + case "tool_result": + return !!event.output && event.output.length > 0; + default: + return !!event.content && event.content.length > 0; + } +} + +/** Whether a monospace face fits the collapsed summary (commands/output). */ +export function traceEventSummaryIsMono(kind: TraceEventKind): boolean { + return kind === "tool_use" || kind === "tool_result"; +} + +/** + * Default expansion under the `smart` density: the reading hierarchy itself. + * Agent text and errors read without a click; process noise stays folded. + */ +export function traceEventDefaultExpanded( + event: TraceEvent, + density: TranscriptDetailDensity, +): boolean { + if (!traceEventHasDetail(event)) return false; + switch (density) { + case "expanded": + return true; + case "collapsed": + return false; + case "smart": { + const kind = traceEventKind(event); + return kind === "agent" || kind === "error"; + } + } +} diff --git a/packages/views/issues/components/attribution-badge.test.tsx b/packages/views/issues/components/attribution-badge.test.tsx index 58be0ea7f3..8e0f9d9fab 100644 --- a/packages/views/issues/components/attribution-badge.test.tsx +++ b/packages/views/issues/components/attribution-badge.test.tsx @@ -96,6 +96,24 @@ describe("AttributionBadge", () => { expect(screen.getByText("On behalf of someone")).toBeInTheDocument(); }); + it("inline variant renders the bare name (no on-behalf-of wrapper), source in tooltip", () => { + const attribution: TaskAttribution = { + source: "direct_human", + precise: true, + initiator: { id: "u1", name: "Ada Lovelace" }, + }; + const { container } = renderWithI18n( + , + ); + + // The caller supplies the label; the value is just the person (the mocked + // avatar also echoes the name, hence getAllByText). + expect(screen.getAllByText("Ada Lovelace").length).toBeGreaterThan(0); + expect(screen.queryByText("On behalf of Ada Lovelace")).toBeNull(); + // Typography, not a chip: the inline shape must not render a Badge border. + expect(container.querySelector("[data-slot='badge']")).toBeNull(); + }); + it("renders nothing when no responsible member resolved (MUL-4765)", () => { const attribution: TaskAttribution = { source: "unattributed", diff --git a/packages/views/issues/components/attribution-badge.tsx b/packages/views/issues/components/attribution-badge.tsx index f16953ad4d..d31f72e2f0 100644 --- a/packages/views/issues/components/attribution-badge.tsx +++ b/packages/views/issues/components/attribution-badge.tsx @@ -31,7 +31,7 @@ function initialsOf(name: string): string { * earns no warning tone; its historical origin still shows in the tooltip and * the raw `source` field (MUL-4768). * - * Two shapes, both silent when no responsible member resolved (MUL-4765): + * Three shapes, all silent when no responsible member resolved (MUL-4765): * - `variant="badge"` (default): the full "on behalf of " chip. Renders * nothing when there's no accountable member, so an unassigned run reads as * plain rather than a warning. @@ -39,6 +39,10 @@ function initialsOf(name: string): string { * source in a hover tooltip. Compact enough for a dense task row. Renders * nothing when there's no accountable member — an avatar-only surface has * nothing meaningful to show for an unattributed run. + * - `variant="inline"`: the bare member name (borderless, avatar optional via + * `hideAvatar`) with the source in a tooltip — for a sentence-like identity + * row (transcript header) where the caller supplies the label. Same silence + * rule. * * Renders nothing when the task has no attribution at all (older backends) — * the caller should optional-chain `task.attribution`. @@ -47,10 +51,14 @@ export function AttributionBadge({ attribution, className, variant = "badge", + hideAvatar = false, }: { attribution?: TaskAttribution; className?: string; - variant?: "badge" | "avatar"; + variant?: "badge" | "avatar" | "inline"; + /** Inline variant only: render the name without the avatar, so it does not + * compete with a nearby primary avatar (the transcript identity row). */ + hideAvatar?: boolean; }) { const { t } = useT("issues"); if (!attribution) return null; @@ -104,6 +112,42 @@ export function AttributionBadge({ attribution.precise === false && attribution.source !== "backfill"; const initiator = attribution.initiator; + // Inline shape: avatar + bare name, no border and no "on behalf of" wrapper — + // the caller supplies the label ("Triggered by") so the value is just the + // person. The resolution source stays in the tooltip. + if (variant === "inline") { + const initiatorInline = attribution.initiator; + if (!initiatorInline) return null; + const name = initiatorInline.name || t(($) => $.execution_log.attribution.someone); + return ( + + + {!hideAvatar && ( + + )} + {name} + + } + /> + {sourceLabel} + + ); + } + // Avatar-only shape: just the accountable member's face, with the name + // source in a hover tooltip. Nothing to show without an accountable member. if (variant === "avatar") { diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 799d189e9f..d90736c498 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -765,9 +765,7 @@ "status_failed": "Failed", "filter": "Filter", "clear_filters": "Clear filters", - "tool_calls_one": "{{count}} tool call", - "tool_calls_other": "{{count}} tool calls", - "events_one": "{{count}} events", + "events_one": "{{count}} event", "events_other": "{{count}} events", "events_filtered": "{{shown}} of {{total}} events", "copy_all": "Copy all", @@ -779,10 +777,38 @@ "sort_label": "Sort", "sort_chronological": "Oldest first", "sort_newest_first": "Newest first", - "expand_visible": "Expand visible", - "collapse_visible": "Collapse visible", - "preserve_filters": "Preserve filters", - "default_expanded": "Open details by default" + "density_label": "Expand mode", + "density_smart": "Focus", + "density_smart_desc": "Replies and errors open; thinking and tool steps stay folded", + "density_expanded": "Expand all", + "density_expanded_desc": "Every row's full params and output, for step-by-step review", + "density_collapsed": "Collapse all", + "density_collapsed_desc": "One line per event — the most compact scan", + "no_output": "No output", + "show_all": "Show all", + "status_queued": "Queued", + "status_dispatched": "Dispatched", + "status_cancelled": "Cancelled", + "status_waiting": "Waiting", + "trigger_initial": "Initial run", + "trigger_comment": "From a comment", + "trigger_autopilot": "Autopilot", + "trigger_retry": "Retry", + "trigger_chat": "From chat", + "trigger_quick_create": "Quick create", + "trigger_direct": "Direct assignment", + "run_info": "Run details", + "details_runtime": "Runtime", + "details_provider": "Provider", + "details_mode": "Mode", + "details_workdir": "Workdir", + "details_created": "Created", + "details_started": "Started", + "details_completed": "Completed", + "copy_workdir": "Copy working directory", + "close": "Close", + "fact_created": "Created {{time}}", + "fact_took": "Took {{duration}}" }, "task_failure": { "agent_error": "Agent execution error", diff --git a/packages/views/locales/ja/agents.json b/packages/views/locales/ja/agents.json index 6f16a7f271..6883be7094 100644 --- a/packages/views/locales/ja/agents.json +++ b/packages/views/locales/ja/agents.json @@ -647,7 +647,6 @@ "status_failed": "失敗", "filter": "フィルター", "clear_filters": "フィルターをクリア", - "tool_calls_other": "ツール呼び出し {{count}} 回", "events_other": "イベント {{count}} 件", "events_filtered": "イベント {{total}} 件中 {{shown}} 件", "copy_all": "すべてコピー", @@ -659,10 +658,38 @@ "sort_label": "並び替え", "sort_chronological": "古い順", "sort_newest_first": "新しい順", - "expand_visible": "表示中を展開", - "collapse_visible": "表示中を折りたたむ", - "preserve_filters": "フィルターを保持", - "default_expanded": "詳細をデフォルトで展開" + "density_label": "展開モード", + "density_smart": "重点展開", + "density_smart_desc": "回答とエラーは展開、思考とツールの過程は折りたたみ", + "density_expanded": "すべて展開", + "density_expanded_desc": "各行のパラメータと出力を完全に展開し、逐次レビューに", + "density_collapsed": "すべて折りたたむ", + "density_collapsed_desc": "1 行 1 イベントの最もコンパクトな表示", + "no_output": "出力なし", + "show_all": "すべて表示", + "status_queued": "待機列", + "status_dispatched": "ディスパッチ済み", + "status_cancelled": "キャンセル", + "status_waiting": "待機中", + "trigger_initial": "初回実行", + "trigger_comment": "コメントから", + "trigger_autopilot": "Autopilot", + "trigger_retry": "再試行", + "trigger_chat": "チャットから", + "trigger_quick_create": "クイック作成", + "trigger_direct": "直接アサイン", + "run_info": "実行の詳細", + "details_runtime": "ランタイム", + "details_provider": "プロバイダー", + "details_mode": "モード", + "details_workdir": "作業ディレクトリ", + "details_created": "作成", + "details_started": "開始", + "details_completed": "完了", + "copy_workdir": "作業ディレクトリをコピー", + "close": "閉じる", + "fact_created": "作成 {{time}}", + "fact_took": "所要 {{duration}}" }, "task_failure": { "agent_error": "エージェント実行エラー", diff --git a/packages/views/locales/ko/agents.json b/packages/views/locales/ko/agents.json index 20e2ac8b76..75fe4483f7 100644 --- a/packages/views/locales/ko/agents.json +++ b/packages/views/locales/ko/agents.json @@ -655,7 +655,6 @@ "status_failed": "실패함", "filter": "필터", "clear_filters": "필터 지우기", - "tool_calls_other": "도구 호출 {{count}}회", "events_other": "이벤트 {{count}}개", "events_filtered": "이벤트 {{total}}개 중 {{shown}}개", "copy_all": "전체 복사", @@ -667,10 +666,38 @@ "sort_label": "정렬", "sort_chronological": "오래된 순", "sort_newest_first": "최신 순", - "expand_visible": "표시 항목 펼치기", - "collapse_visible": "표시 항목 접기", - "preserve_filters": "필터 유지", - "default_expanded": "기본으로 세부 정보 펼치기" + "density_label": "펼치기 방식", + "density_smart": "핵심 펼치기", + "density_smart_desc": "응답과 오류는 펼치고, 사고와 도구 과정은 접힌 상태 유지", + "density_expanded": "모두 펼치기", + "density_expanded_desc": "각 행의 전체 매개변수와 출력을 펼쳐 단계별 검토에 사용", + "density_collapsed": "모두 접기", + "density_collapsed_desc": "이벤트당 한 줄, 가장 간결한 보기", + "no_output": "출력 없음", + "show_all": "모두 보기", + "status_queued": "대기열", + "status_dispatched": "디스패치됨", + "status_cancelled": "취소됨", + "status_waiting": "대기 중", + "trigger_initial": "최초 실행", + "trigger_comment": "댓글에서", + "trigger_autopilot": "Autopilot", + "trigger_retry": "재시도", + "trigger_chat": "채팅에서", + "trigger_quick_create": "빠른 생성", + "trigger_direct": "직접 지정", + "run_info": "실행 세부정보", + "details_runtime": "런타임", + "details_provider": "제공자", + "details_mode": "모드", + "details_workdir": "작업 디렉터리", + "details_created": "생성", + "details_started": "시작", + "details_completed": "완료", + "copy_workdir": "작업 디렉터리 복사", + "close": "닫기", + "fact_created": "생성 {{time}}", + "fact_took": "소요 {{duration}}" }, "task_failure": { "agent_error": "에이전트 실행 오류", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index fad3e7230b..e316315e04 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -751,7 +751,6 @@ "status_failed": "失败", "filter": "筛选", "clear_filters": "清除筛选", - "tool_calls_other": "{{count}} 次工具调用", "events_other": "{{count}} 个事件", "events_filtered": "{{shown}} / {{total}} 个事件", "copy_all": "全部复制", @@ -761,12 +760,40 @@ "antigravity_live_unavailable": "Antigravity 暂不提供实时执行事件。task 完成后即可查看执行记录。", "no_data": "未记录执行数据。", "sort_label": "排序", - "sort_chronological": "时间顺序", + "sort_chronological": "最早在前", "sort_newest_first": "最新在前", - "expand_visible": "展开当前可见项", - "collapse_visible": "收起当前可见项", - "preserve_filters": "保留筛选", - "default_expanded": "默认展开详情" + "density_label": "展开方式", + "density_smart": "重点展开", + "density_smart_desc": "汇报与错误展开,思考和工具过程保持折叠", + "density_expanded": "全部展开", + "density_expanded_desc": "展开每一行的完整参数与输出,逐条审阅用", + "density_collapsed": "全部收起", + "density_collapsed_desc": "一行一事件,最紧凑的扫读视图", + "no_output": "无输出", + "show_all": "显示全部", + "status_queued": "排队中", + "status_dispatched": "已派发", + "status_cancelled": "已取消", + "status_waiting": "等待中", + "trigger_initial": "初次运行", + "trigger_comment": "评论触发", + "trigger_autopilot": "Autopilot", + "trigger_retry": "重试", + "trigger_chat": "来自对话", + "trigger_quick_create": "快速创建", + "trigger_direct": "直接指派", + "run_info": "运行详情", + "details_runtime": "运行时", + "details_provider": "提供方", + "details_mode": "模式", + "details_workdir": "工作目录", + "details_created": "创建", + "details_started": "开始", + "details_completed": "完成", + "copy_workdir": "复制工作目录", + "close": "关闭", + "fact_created": "创建于 {{time}}", + "fact_took": "用时 {{duration}}" }, "task_failure": { "agent_error": "智能体执行出错",