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>
This commit is contained in:
Naiyuan Qing
2026-07-24 14:36:29 +08:00
parent 741028343b
commit 73cb7ff8d6
7 changed files with 54 additions and 53 deletions

View File

@@ -436,7 +436,6 @@ export function AgentTranscriptDialog({
? elapsed
: null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
const copyTranscriptLabel = copied
? t(($) => $.transcript.copied)
: activeFilterKeys.length > 0
@@ -526,7 +525,9 @@ export function AgentTranscriptDialog({
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;
const hasTriggeredBy = !!task.attribution?.initiator;
const hasRunDetails =
hasTriggeredBy ||
!!runtimeInfo ||
!!task.relative_work_dir ||
!!createdLabel ||
@@ -561,13 +562,10 @@ export function AgentTranscriptDialog({
{agentName || agentInfo?.name || ""}
</span>
</div>
{/* Trigger mechanism (why this run exists). The accountable
human is audit metadata, not read-time context — it lives in
the ⓘ popover as "triggered by", not on this row. */}
<span className="shrink-0 text-xs text-muted-foreground">{triggerLabel}</span>
{/* Accountable member (MUL-4302 §9): whose behalf this run is on. */}
<AttributionBadge
attribution={task.attribution}
variant="inline"
className="min-w-0"
/>
</div>
<div className="flex shrink-0 items-center gap-0.5">
@@ -585,6 +583,14 @@ export function AgentTranscriptDialog({
{t(($) => $.transcript.run_info)}
</div>
<div className="space-y-1 text-xs">
{hasTriggeredBy && (
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-center gap-3">
<span className="text-muted-foreground">
{t(($) => $.transcript.details_triggered_by)}
</span>
<AttributionBadge attribution={task.attribution} variant="inline" />
</div>
)}
{runtimeInfo && (
<RunDetailRow label={t(($) => $.transcript.details_runtime)} value={runtimeInfo.name} />
)}
@@ -645,12 +651,6 @@ export function AgentTranscriptDialog({
? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length })
: t(($) => $.transcript.events, { count: items.length })}
</span>
{toolCount > 0 && (
<>
<FactDot />
<span>{t(($) => $.transcript.tool_calls, { count: toolCount })}</span>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{items.length > 0 && (
@@ -837,44 +837,28 @@ 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 (
<div
role="group"
<button
type="button"
onClick={() => 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={isChronological ? labels.chronological : labels.newestFirst}
className="flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<button
type="button"
aria-pressed={value === "chronological"}
title={labels.chronological}
onClick={() => onChange("chronological")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "chronological"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{isChronological ? (
<ArrowDownNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.chronological}</span>
</button>
<button
type="button"
aria-pressed={value === "newest_first"}
title={labels.newestFirst}
onClick={() => onChange("newest_first")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "newest_first"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
) : (
<ArrowUpNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.newestFirst}</span>
</button>
</div>
)}
<span className="hidden sm:inline">
{isChronological ? labels.chronological : labels.newestFirst}
</span>
</button>
);
}

View File

@@ -55,12 +55,16 @@ describe("traceToolArgSummary", () => {
});
describe("traceEventSummary", () => {
it("takes the first non-empty line for agent text and tool output", () => {
it("takes the first non-empty line for agent text", () => {
expect(traceEventSummary({ type: "text", content: "\n\nFirst line\nrest" })).toBe(
"First line",
);
expect(traceEventSummary({ type: "tool_result", output: "line one\nline two" })).toBe(
"line one",
});
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" } ]',
);
});

View File

@@ -121,6 +121,15 @@ 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)) {
@@ -129,7 +138,7 @@ export function traceEventSummary(event: TraceEvent): string {
case "tool_use":
return traceToolArgSummary(event.input);
case "tool_result":
return clip(firstLine(event.output), 200);
return clip(collapseWhitespace(event.output), 200);
default:
return firstLine(event.content ?? event.output);
}

View File

@@ -808,7 +808,8 @@
"details_created": "Created",
"details_started": "Started",
"details_completed": "Completed",
"copy_workdir": "Copy working directory"
"copy_workdir": "Copy working directory",
"details_triggered_by": "Triggered by"
},
"task_failure": {
"agent_error": "Agent execution error",

View File

@@ -688,7 +688,8 @@
"details_created": "作成",
"details_started": "開始",
"details_completed": "完了",
"copy_workdir": "作業ディレクトリをコピー"
"copy_workdir": "作業ディレクトリをコピー",
"details_triggered_by": "トリガー"
},
"task_failure": {
"agent_error": "エージェント実行エラー",

View File

@@ -696,7 +696,8 @@
"details_created": "생성",
"details_started": "시작",
"details_completed": "완료",
"copy_workdir": "작업 디렉터리 복사"
"copy_workdir": "작업 디렉터리 복사",
"details_triggered_by": "트리거한 사람"
},
"task_failure": {
"agent_error": "에이전트 실행 오류",

View File

@@ -792,7 +792,8 @@
"details_created": "创建",
"details_started": "开始",
"details_completed": "完成",
"copy_workdir": "复制工作目录"
"copy_workdir": "复制工作目录",
"details_triggered_by": "触发人"
},
"task_failure": {
"agent_error": "智能体执行出错",