Files
multica/packages/views/issues/components/attribution-badge.tsx
Naiyuan Qing 887c9228b8 feat(views): transcript reading hierarchy — presenter, expand modes, log-scale markdown (#5871)
* 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>
2026-07-24 16:12:10 +08:00

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>
);
}