mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +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>
227 lines
8.5 KiB
TypeScript
227 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import type { TaskAttribution } from "@multica/core/types";
|
|
import { Badge } from "@multica/ui/components/ui/badge";
|
|
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useT } from "../../i18n";
|
|
|
|
/** First + last initial, for the avatar fallback when there's no picture. */
|
|
function initialsOf(name: string): string {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
const first = parts[0];
|
|
if (!first) return "?";
|
|
const last = parts[parts.length - 1];
|
|
if (parts.length === 1 || !last) return first.slice(0, 2).toUpperCase();
|
|
return (first.charAt(0) + last.charAt(0)).toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* AttributionBadge renders who an agent run is accountable to (MUL-4302 §9):
|
|
* the "on behalf of <member>" provenance, with the resolution source in a
|
|
* tooltip and a cautionary tone ONLY when the named human may not be the real
|
|
* responsible person — a fallback guess (owner_fallback). A backfilled
|
|
* attribution is a historical, after-the-fact record (non-realtime, not
|
|
* compliance-grade), but that does not make the displayed name wrong, so it
|
|
* earns no warning tone; its historical origin still shows in the tooltip and
|
|
* the raw `source` field (MUL-4768).
|
|
*
|
|
* Three shapes, all silent when no responsible member resolved (MUL-4765):
|
|
* - `variant="badge"` (default): the full "on behalf of <name>" chip. Renders
|
|
* nothing when there's no accountable member, so an unassigned run reads as
|
|
* plain rather than a warning.
|
|
* - `variant="avatar"`: just the accountable member's avatar, with the name +
|
|
* 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`.
|
|
*/
|
|
export function AttributionBadge({
|
|
attribution,
|
|
className,
|
|
variant = "badge",
|
|
hideAvatar = false,
|
|
}: {
|
|
attribution?: TaskAttribution;
|
|
className?: string;
|
|
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;
|
|
|
|
// Human-readable resolution source, defaulting to the raw label so a
|
|
// server-added source degrades gracefully instead of showing blank.
|
|
let sourceLabel: string;
|
|
switch (attribution.source) {
|
|
case "direct_human":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_direct_human);
|
|
break;
|
|
case "delegation":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_delegation);
|
|
break;
|
|
case "comment_source":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_comment_source);
|
|
break;
|
|
case "trigger_owner":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_trigger_owner);
|
|
break;
|
|
case "rule_owner":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_rule_owner);
|
|
break;
|
|
case "owner_fallback":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_owner_fallback);
|
|
break;
|
|
case "backfill":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_backfill);
|
|
break;
|
|
case "unattributed":
|
|
sourceLabel = t(($) => $.execution_log.attribution.source_unattributed);
|
|
break;
|
|
default:
|
|
sourceLabel = attribution.source;
|
|
}
|
|
|
|
// The backend's `precise` flag is an attribution-*coverage* health bit:
|
|
// owner_fallback, backfill, and unattributed all fail it. But coverage is an
|
|
// ops metric, not a reader-facing signal. The only thing a viewer of "on
|
|
// behalf of <name>" cares about is whether that named human might NOT actually
|
|
// be who's responsible — true only for a fallback guess (owner_fallback:
|
|
// nothing resolved, so we defaulted to the agent owner). Backfill is a
|
|
// historical, after-the-fact record (non-realtime, not compliance-grade), but
|
|
// that does not make the displayed name wrong — so it warrants no warning tone;
|
|
// its historical origin stays visible in the tooltip and the raw `source` field
|
|
// (MUL-4768). The cautionary tone therefore fires for any non-precise source
|
|
// EXCEPT backfill; keeping the `precise === false` base means a future unknown
|
|
// degraded source still warns (fail-safe) instead of silently reading as
|
|
// confident.
|
|
const uncertain =
|
|
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 (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span
|
|
className={cn(
|
|
"inline-flex min-w-0 items-center gap-1.5 text-xs",
|
|
uncertain ? "text-warning" : "text-foreground/80",
|
|
className
|
|
)}
|
|
>
|
|
{!hideAvatar && (
|
|
<ActorAvatar
|
|
name={name}
|
|
initials={initialsOf(name)}
|
|
avatarUrl={initiatorInline.avatar_url}
|
|
size="xs"
|
|
className="shrink-0"
|
|
/>
|
|
)}
|
|
<span className="min-w-0 truncate">{name}</span>
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent>{sourceLabel}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// 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") {
|
|
if (!initiator) return null;
|
|
const name = initiator.name || t(($) => $.execution_log.attribution.someone);
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span
|
|
className={cn(
|
|
"inline-flex shrink-0",
|
|
// A subtle ring flags a fallback guess so an owner-fallback face
|
|
// never silently reads as a confidently resolved responsible member.
|
|
uncertain && "rounded-full ring-1 ring-warning/60",
|
|
className
|
|
)}
|
|
>
|
|
<ActorAvatar
|
|
name={name}
|
|
initials={initialsOf(name)}
|
|
avatarUrl={initiator.avatar_url}
|
|
size="xs"
|
|
/>
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent>
|
|
<div className="flex flex-col">
|
|
<span>
|
|
{t(($) => $.execution_log.attribution.on_behalf_of, { name })}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"text-[11px]",
|
|
uncertain ? "text-warning" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
{sourceLabel}
|
|
</span>
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// No resolved responsible member: render nothing rather than a warning chip.
|
|
// An empty accountable member is a normal state (e.g. an unassigned task), not
|
|
// something to flag — so the badge variant stays silent, matching the avatar
|
|
// variant above (MUL-4765).
|
|
if (!initiator) return null;
|
|
|
|
const name = initiator.name || t(($) => $.execution_log.attribution.someone);
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"max-w-40 min-w-0 gap-1 font-normal",
|
|
uncertain ? "text-warning" : "text-muted-foreground",
|
|
className
|
|
)}
|
|
title={sourceLabel}
|
|
>
|
|
<ActorAvatar
|
|
name={name}
|
|
initials={initialsOf(name)}
|
|
avatarUrl={initiator.avatar_url}
|
|
size="xs"
|
|
className="shrink-0"
|
|
/>
|
|
<span className="min-w-0 truncate">
|
|
{t(($) => $.execution_log.attribution.on_behalf_of, { name })}
|
|
</span>
|
|
</Badge>
|
|
);
|
|
}
|