Files
multica/packages/views/common/task-transcript/trace-event-presenter.ts
Naiyuan Qing ba89bf5119 feat(views): revamp execution log — virtualized, readable, two-tier header
Combines the transcript work (previously split across #5860 virtual
scroll and #5871 reading hierarchy) into one branch on current main,
folding in the runtime-alias display from #5881.

- Virtualize the event list (react-virtuoso) so a multi-thousand-event
  run mounts a bounded number of DOM rows (#5733), with firstItemIndex
  anchoring for newest-first live prepends.
- Reading hierarchy via a pure trace-event-presenter: agent text and
  errors render expanded in place through RichContent (compact,
  log-scale markdown); thinking/tool rows fold to one line; tool detail
  expands into a quiet surface with "show all".
- Persisted 3-way expand density (smart/expanded/collapsed) with per-row
  overrides; legacy defaultExpanded boolean migrated. Filters always
  persist (dropped the preserve-filters toggle).
- Two-tier header: identity row (status, agent, trigger source,
  triggered-by) + list toolbar (created/duration/events facts left,
  shared Button controls right); runtime/provider/workdir/timestamps
  move to an ⓘ popover, runtime shown via runtimeDisplayName (#5881).
- Copy-all exports full event bodies (redacted) with RFC 3339 timestamps
  (#5873), not the truncated summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:33:50 +08:00

207 lines
7.0 KiB
TypeScript

// 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<string, unknown>;
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 `<shell> -lc '<cmd>'` / `-c "<cmd>"` 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<string, unknown> | 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";
}
}
}