mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor(views): transcript toolbar uses the shared Button component + labels fix Toolbar controls were hand-rolled <button>s, so they never got the project's built-in aria-expanded active state — opening a menu left the trigger looking idle. Route every control through the shared Button: - Density / filter / ⓘ / close → Button variant="ghost" via the trigger render prop, so an open menu shows the muted active background for free. - Active filter → Button variant="brand" (the design-system ON state), replacing the ad-hoc blue classes. - Sort → ghost Button on the same chassis (was a segmented tab strip). - Copy → ghost Button. Wording: - Sort reads "Oldest first" / "Newest first" (was the ambiguous "chronological / time order"). - Expand-mode "Smart" → "Focus" — names the result, not a hollow adjective. Start/created/completed timestamps remain in the ⓘ Run-details popover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): filters always persist, drop "on behalf of" wording, surface run time Three follow-ups from review: - Remove the "Preserve filters" toggle. Persisting the filter selection is the only sensible behavior (a facet a run lacks already no-ops), so it should not be a user-facing switch. Filters now always persist; drop preserveFilters + the session-vs-persisted branching from the store and dialog. - Inline attribution showed "on behalf of <name>", which read backwards and doubled up under the ⓘ "Triggered by" label. Render just the avatar + name; the source stays in the tooltip. - Surface the run time (started ?? created) on the toolbar left next to duration and event count — "when did this happen" is read-before-you- read context. Full-precision timestamps remain in the ⓘ popover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(views): triggered-by back on the identity row, labeled toolbar facts - The accountable human returns to the identity row next to the trigger mechanism (status · agent · trigger · person), where it's visible rather than buried in the ⓘ popover. - Toolbar facts now carry explicit labels — "Created <time> · Took <duration> · N events" — instead of bare values the reader had to infer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): transcript identity row reads as one sentence, not three peers The row put the agent, the trigger word, and the person at near-equal weight with two same-size avatars, so it read as a jumble of names with "comment-triggered" floating between them. - The agent is the sole primary identity (avatar + medium weight). - Trigger + person collapse into one muted secondary unit set apart from the agent, ordered "<person> · <how>". - Drop the person's avatar here (new AttributionBadge `hideAvatar`) so two same-size faces don't read as two agents; the name alone, being muted, can't be confused with the agent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): copy-all exports full event bodies, not truncated summaries handleCopyAll built its text from traceEventSummary — the one-line, whitespace-collapsed, 200-char row preview — so every copied tool output and agent reply was truncated with "...". Add traceEventCopyText, which returns the complete body (full tool input JSON / result output / prose), redact it like the detail view, and join events with blank lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(views): remove orphaned tool_calls i18n keys, fix inline-variant JSDoc Review follow-ups (no behavior change): - Delete tool_calls_one/tool_calls_other from all four locales — the metadata chip that used them was removed earlier in this branch. - Correct the AttributionBadge inline-variant JSDoc: it renders the bare name (no "on behalf of" wrapper), matching the implementation and test. - en events_one grammar: "1 event" not "1 events". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(views): fold copy timestamps into full-body copy (merge #5873) Reconcile #5873's "timestamps in copied events" (merged to main) with this branch's full-body copy rewrite: traceEventCopyText now prefixes each line with the RFC 3339 timestamp when created_at is valid, on top of the complete (untruncated) body, events separated by a blank line. The two #5873 copy tests are updated to the full-body format; the orphaned formatEventForClipboard helper is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
207 lines
7.0 KiB
TypeScript
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";
|
|
}
|
|
}
|
|
}
|