mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" } ]',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -688,7 +688,8 @@
|
||||
"details_created": "作成",
|
||||
"details_started": "開始",
|
||||
"details_completed": "完了",
|
||||
"copy_workdir": "作業ディレクトリをコピー"
|
||||
"copy_workdir": "作業ディレクトリをコピー",
|
||||
"details_triggered_by": "トリガー"
|
||||
},
|
||||
"task_failure": {
|
||||
"agent_error": "エージェント実行エラー",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"details_created": "생성",
|
||||
"details_started": "시작",
|
||||
"details_completed": "완료",
|
||||
"copy_workdir": "작업 디렉터리 복사"
|
||||
"copy_workdir": "작업 디렉터리 복사",
|
||||
"details_triggered_by": "트리거한 사람"
|
||||
},
|
||||
"task_failure": {
|
||||
"agent_error": "에이전트 실행 오류",
|
||||
|
||||
@@ -792,7 +792,8 @@
|
||||
"details_created": "创建",
|
||||
"details_started": "开始",
|
||||
"details_completed": "完成",
|
||||
"copy_workdir": "复制工作目录"
|
||||
"copy_workdir": "复制工作目录",
|
||||
"details_triggered_by": "触发人"
|
||||
},
|
||||
"task_failure": {
|
||||
"agent_error": "智能体执行出错",
|
||||
|
||||
Reference in New Issue
Block a user