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>
This commit is contained in:
Naiyuan Qing
2026-07-24 14:28:47 +08:00
parent 0fc21f77e7
commit 741028343b
7 changed files with 335 additions and 137 deletions

View File

@@ -306,9 +306,9 @@ describe("AgentTranscriptDialog", () => {
renderDialog([], { task: liveTask, isLive: true });
// The runtime's display name lives in the facts line's hover title; the
// visible text is the runtime mode.
await screen.findByTitle("hermes runtime");
// Runtime detail now lives in the ⓘ popover; its trigger appearing proves
// the runtime loaded. The non-antigravity live state still waits.
await screen.findByRole("button", { name: "Run details" });
expect(screen.getByText("Waiting for events...")).toBeInTheDocument();
});

View File

@@ -14,18 +14,16 @@ import {
Clock,
Copy,
Check,
Monitor,
Cloud,
Cpu,
Filter,
Folder,
ArrowDownNarrowWide,
ArrowUpNarrowWide,
ListCollapse,
Info,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible";
import {
DropdownMenu,
@@ -133,6 +131,63 @@ function formatElapsedMs(ms: number): string {
return `${minutes}m ${secs}s`;
}
function formatRunTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// ─── Run detail row (ⓘ popover) ─────────────────────────────────────────────
// One labeled fact in the diagnostic popover. When `onCopy` is given the whole
// row is a copy button (used for the workdir path).
function RunDetailRow({
label,
value,
mono,
onCopy,
copied,
copyTitle,
}: {
label: string;
value: string;
mono?: boolean;
onCopy?: () => void;
copied?: boolean;
copyTitle?: string;
}) {
const valueClass = cn("min-w-0 select-text break-all text-foreground/80", mono && "font-mono");
if (onCopy) {
return (
<button
type="button"
onClick={onCopy}
title={copyTitle}
className="group -mx-1 grid w-[calc(100%+0.5rem)] grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-3 rounded px-1 py-0.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-muted-foreground">{label}</span>
<span className="flex min-w-0 items-start gap-1.5">
<span className={cn(valueClass, "flex-1")}>{value}</span>
{copied ? (
<Check className="mt-0.5 h-3 w-3 shrink-0 text-success" />
) : (
<Copy className="mt-0.5 h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</span>
</button>
);
}
return (
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-3">
<span className="text-muted-foreground">{label}</span>
<span className={valueClass}>{value}</span>
</div>
);
}
// ─── Main dialog ────────────────────────────────────────────────────────────
// Virtuoso mounts rows as direct children of its List element; carry the
@@ -388,27 +443,95 @@ export function AgentTranscriptDialog({
? t(($) => $.transcript.copy_filtered)
: t(($) => $.transcript.copy_all);
// Status display
const statusBadge = isLive ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-xs font-medium text-info">
<Loader2 className="h-3 w-3 animate-spin" />
{t(($) => $.transcript.status_running)}
</span>
) : task.status === "completed" ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">
<CheckCircle2 className="h-3 w-3" />
{t(($) => $.transcript.status_completed)}
</span>
) : task.status === "failed" ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium text-destructive">
<XCircle className="h-3 w-3" />
{t(($) => $.transcript.status_failed)}
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground capitalize">
{task.status}
</span>
);
// Status badge — full state machine, so queued/dispatched/cancelled render as
// proper labels instead of raw enum text.
const effectiveStatus = isLive ? "running" : task.status;
const statusBadge = (() => {
const base = "inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
switch (effectiveStatus) {
case "running":
return (
<span className={cn(base, "bg-info/15 text-info")}>
<Loader2 className="h-3 w-3 animate-spin" />
{t(($) => $.transcript.status_running)}
</span>
);
case "completed":
return (
<span className={cn(base, "bg-success/15 text-success")}>
<CheckCircle2 className="h-3 w-3" />
{t(($) => $.transcript.status_completed)}
</span>
);
case "failed":
return (
<span className={cn(base, "bg-destructive/15 text-destructive")}>
<XCircle className="h-3 w-3" />
{t(($) => $.transcript.status_failed)}
</span>
);
case "cancelled":
return (
<span className={cn(base, "bg-muted text-muted-foreground")}>
<XCircle className="h-3 w-3" />
{t(($) => $.transcript.status_cancelled)}
</span>
);
case "queued":
return (
<span className={cn(base, "bg-muted text-muted-foreground")}>
{t(($) => $.transcript.status_queued)}
</span>
);
case "dispatched":
return (
<span className={cn(base, "bg-info/15 text-info")}>
{t(($) => $.transcript.status_dispatched)}
</span>
);
case "waiting_local_directory":
return (
<span className={cn(base, "bg-muted text-muted-foreground")}>
{t(($) => $.transcript.status_waiting)}
</span>
);
default:
return (
<span className={cn(base, "bg-muted text-muted-foreground capitalize")}>
{task.status}
</span>
);
}
})();
// Trigger source: one word answering "why does this run exist" — more useful
// up front than the runtime/provider diagnostics, which move to the ⓘ popover.
const triggerLabel = task.parent_task_id
? t(($) => $.transcript.trigger_retry)
: task.kind === "comment" || task.trigger_comment_id
? t(($) => $.transcript.trigger_comment)
: task.kind === "autopilot" || task.autopilot_run_id
? t(($) => $.transcript.trigger_autopilot)
: task.kind === "chat" || task.chat_session_id
? t(($) => $.transcript.trigger_chat)
: task.kind === "quick_create"
? t(($) => $.transcript.trigger_quick_create)
: task.kind === "direct" || task.handoff_note
? t(($) => $.transcript.trigger_direct)
: t(($) => $.transcript.trigger_initial);
// Diagnostic detail for the ⓘ popover: everything a reader needs only when
// debugging this specific run, kept off the always-visible surface.
const providerLabel = runtimeInfo?.provider ? formatProvider(runtimeInfo.provider) : null;
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 hasRunDetails =
!!runtimeInfo ||
!!task.relative_work_dir ||
!!createdLabel ||
!!startedLabel ||
!!completedLabel;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -418,124 +541,118 @@ export function AgentTranscriptDialog({
>
<DialogTitle className="sr-only">{t(($) => $.transcript.dialog_title)}</DialogTitle>
{/* ── Header ─────────────────────────────────────────────── */}
<div className="border-b px-4 py-3 shrink-0 space-y-1.5">
{/* Identity row: outcome first, then who ran and on whose behalf.
Status is the one fact every viewer opens this dialog for, so it
anchors the left edge. Entities render through their existing
identity components (avatar + hover card), never ad-hoc chips. */}
{/* ── Header: identity only ──────────────────────────────────
Tier 1 — everything a viewer needs BEFORE reading: outcome
(status anchors the left), who ran it, why it exists (trigger),
and who's accountable. All diagnostics move to the ⓘ popover. */}
<div className="border-b px-4 py-3 shrink-0">
<div className="flex min-w-0 items-center gap-2.5">
{statusBadge}
<div className="flex min-w-0 items-center gap-2">
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size="sm" enableHoverCard />
) : (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3 w-3" />
</div>
)}
<span className="truncate font-medium text-sm">
{agentName || agentInfo?.name || ""}
</span>
<div className="flex min-w-0 flex-1 items-center gap-x-3 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size="sm" enableHoverCard />
) : (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3 w-3" />
</div>
)}
<span className="truncate font-medium text-sm">
{agentName || agentInfo?.name || ""}
</span>
</div>
<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>
{/* Accountable member (MUL-4302 §9): whose behalf this run is on. */}
<AttributionBadge
attribution={task.attribution}
variant="inline"
className="min-w-0"
/>
<button
type="button"
onClick={() => onOpenChange(false)}
className="ml-auto flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Facts line: quantitative run facts as plain dot-separated
typography — borders belong to controls, not to information.
Diagnostic details (hostname, workdir path) hide behind
hover/copy affordances instead of occupying the line. */}
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
{runtimeInfo?.provider && (
<div className="flex shrink-0 items-center gap-0.5">
{hasRunDetails && (
<Popover>
<PopoverTrigger
aria-label={t(($) => $.transcript.run_info)}
title={t(($) => $.transcript.run_info)}
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Info className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-80 max-w-[calc(100vw-2rem)] p-3">
<div className="mb-2 text-xs font-medium text-foreground">
{t(($) => $.transcript.run_info)}
</div>
<div className="space-y-1 text-xs">
{runtimeInfo && (
<RunDetailRow label={t(($) => $.transcript.details_runtime)} value={runtimeInfo.name} />
)}
{providerLabel && (
<RunDetailRow label={t(($) => $.transcript.details_provider)} value={providerLabel} />
)}
{runtimeInfo && (
<RunDetailRow label={t(($) => $.transcript.details_mode)} value={runtimeInfo.runtime_mode} />
)}
{task.relative_work_dir && (
<RunDetailRow
label={t(($) => $.transcript.details_workdir)}
value={task.relative_work_dir}
mono
onCopy={handleCopyWorkdir}
copied={copiedWorkdir}
copyTitle={t(($) => $.transcript.copy_workdir)}
/>
)}
{createdLabel && (
<RunDetailRow label={t(($) => $.transcript.details_created)} value={createdLabel} />
)}
{startedLabel && (
<RunDetailRow label={t(($) => $.transcript.details_started)} value={startedLabel} />
)}
{completedLabel && (
<RunDetailRow label={t(($) => $.transcript.details_completed)} value={completedLabel} />
)}
</div>
</PopoverContent>
</Popover>
)}
<button
type="button"
onClick={() => onOpenChange(false)}
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* ── List toolbar: read-before-you-read summary (left) + controls
(right). Duration + event count fill the left, so the row balances
instead of leaving dead space. ── */}
<div className="flex items-center gap-3 border-b px-4 py-1.5 shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-x-1.5 overflow-hidden whitespace-nowrap text-xs text-muted-foreground">
{duration && (
<span className="inline-flex items-center gap-1">
<Cpu className="h-3 w-3" />
{formatProvider(runtimeInfo.provider)}
<Clock className="h-3 w-3" />
{duration}
</span>
)}
{runtimeInfo && (
<>
<FactDot />
<span className="inline-flex items-center gap-1" title={runtimeInfo.name}>
{runtimeInfo.runtime_mode === "cloud" ? (
<Cloud className="h-3 w-3" />
) : (
<Monitor className="h-3 w-3" />
)}
{runtimeInfo.runtime_mode}
</span>
</>
)}
{duration && (
<>
<FactDot />
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" />
{duration}
</span>
</>
)}
{duration && <FactDot />}
<span>
{activeFilterKeys.length > 0
? 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>
</>
)}
<FactDot />
<span>
{activeFilterKeys.length > 0
? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length })
: t(($) => $.transcript.events, { count: items.length })}
</span>
{task.created_at && (
<>
<FactDot />
<span>
{new Date(task.created_at).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</>
)}
{/* Working directory — server-derived display path. Only
`relative_work_dir` is safe to render / put in title / copy:
the server has already stripped $HOME and the username out of
it, while the absolute `task.work_dir` never reaches the DOM.
The path itself stays out of the line (routinely long); the
icon copies it and the tooltip shows it. */}
{task.relative_work_dir && (
<button
type="button"
onClick={handleCopyWorkdir}
title={task.relative_work_dir}
className="inline-flex items-center gap-1 rounded p-0.5 text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
>
{copiedWorkdir ? (
<Check className="h-3 w-3 shrink-0 text-success" />
) : (
<Folder className="h-3 w-3 shrink-0" />
)}
</button>
)}
</div>
</div>
{/* ── List toolbar: every control operating the list below ── */}
<div className="flex flex-wrap items-center justify-end gap-1 border-b px-4 py-1.5 shrink-0">
<div className="flex shrink-0 items-center gap-1">
{items.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
@@ -643,6 +760,7 @@ export function AgentTranscriptDialog({
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
<span className="hidden sm:inline">{copyTranscriptLabel}</span>
</button>
</div>
</div>
{/* ── Timeline progress bar ─────────────────────────────── */}

View File

@@ -88,7 +88,7 @@ const SHELL_WRAPPER_PATTERN =
export function stripShellWrapper(command: string): string {
const match = SHELL_WRAPPER_PATTERN.exec(command.trim());
return match ? match[2] : command;
return match?.[2] ?? command;
}
function clip(value: string, max: number): string {

View File

@@ -788,7 +788,27 @@
"density_collapsed": "Collapse all",
"density_collapsed_desc": "One line per event — the most compact scan",
"no_output": "No output",
"show_all": "Show all"
"show_all": "Show all",
"status_queued": "Queued",
"status_dispatched": "Dispatched",
"status_cancelled": "Cancelled",
"status_waiting": "Waiting",
"trigger_initial": "Initial run",
"trigger_comment": "From a comment",
"trigger_autopilot": "Autopilot",
"trigger_retry": "Retry",
"trigger_chat": "From chat",
"trigger_quick_create": "Quick create",
"trigger_direct": "Direct assignment",
"run_info": "Run details",
"details_runtime": "Runtime",
"details_provider": "Provider",
"details_mode": "Mode",
"details_workdir": "Workdir",
"details_created": "Created",
"details_started": "Started",
"details_completed": "Completed",
"copy_workdir": "Copy working directory"
},
"task_failure": {
"agent_error": "Agent execution error",

View File

@@ -668,7 +668,27 @@
"density_collapsed": "すべて折りたたむ",
"density_collapsed_desc": "1 行 1 イベントの最もコンパクトな表示",
"no_output": "出力なし",
"show_all": "すべて表示"
"show_all": "すべて表示",
"status_queued": "待機列",
"status_dispatched": "ディスパッチ済み",
"status_cancelled": "キャンセル",
"status_waiting": "待機中",
"trigger_initial": "初回実行",
"trigger_comment": "コメントから",
"trigger_autopilot": "Autopilot",
"trigger_retry": "再試行",
"trigger_chat": "チャットから",
"trigger_quick_create": "クイック作成",
"trigger_direct": "直接アサイン",
"run_info": "実行の詳細",
"details_runtime": "ランタイム",
"details_provider": "プロバイダー",
"details_mode": "モード",
"details_workdir": "作業ディレクトリ",
"details_created": "作成",
"details_started": "開始",
"details_completed": "完了",
"copy_workdir": "作業ディレクトリをコピー"
},
"task_failure": {
"agent_error": "エージェント実行エラー",

View File

@@ -676,7 +676,27 @@
"density_collapsed": "모두 접기",
"density_collapsed_desc": "이벤트당 한 줄, 가장 간결한 보기",
"no_output": "출력 없음",
"show_all": "모두 보기"
"show_all": "모두 보기",
"status_queued": "대기열",
"status_dispatched": "디스패치됨",
"status_cancelled": "취소됨",
"status_waiting": "대기 중",
"trigger_initial": "최초 실행",
"trigger_comment": "댓글에서",
"trigger_autopilot": "Autopilot",
"trigger_retry": "재시도",
"trigger_chat": "채팅에서",
"trigger_quick_create": "빠른 생성",
"trigger_direct": "직접 지정",
"run_info": "실행 세부정보",
"details_runtime": "런타임",
"details_provider": "제공자",
"details_mode": "모드",
"details_workdir": "작업 디렉터리",
"details_created": "생성",
"details_started": "시작",
"details_completed": "완료",
"copy_workdir": "작업 디렉터리 복사"
},
"task_failure": {
"agent_error": "에이전트 실행 오류",

View File

@@ -772,7 +772,27 @@
"density_collapsed": "全部收起",
"density_collapsed_desc": "一行一事件,最紧凑的扫读视图",
"no_output": "无输出",
"show_all": "显示全部"
"show_all": "显示全部",
"status_queued": "排队中",
"status_dispatched": "已派发",
"status_cancelled": "已取消",
"status_waiting": "等待中",
"trigger_initial": "初次运行",
"trigger_comment": "评论触发",
"trigger_autopilot": "Autopilot",
"trigger_retry": "重试",
"trigger_chat": "来自对话",
"trigger_quick_create": "快速创建",
"trigger_direct": "直接指派",
"run_info": "运行详情",
"details_runtime": "运行时",
"details_provider": "提供方",
"details_mode": "模式",
"details_workdir": "工作目录",
"details_created": "创建",
"details_started": "开始",
"details_completed": "完成",
"copy_workdir": "复制工作目录"
},
"task_failure": {
"agent_error": "智能体执行出错",