MUL-4014: persist transcript filters and expansion (#4884)

* feat(transcript): persist log view preferences

Co-authored-by: multica-agent <github@multica.ai>

* fix(transcript): wrap modal header controls

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-03 16:45:53 +08:00
committed by GitHub
parent 4d968ba875
commit dd9996d0aa
9 changed files with 569 additions and 59 deletions

View File

@@ -13,5 +13,6 @@ export {
} from "./view-store";
export {
useTranscriptViewStore,
type TranscriptFilterKey,
type TranscriptSortDirection,
} from "./transcript-view-store";

View File

@@ -2,12 +2,20 @@ import { beforeEach, describe, expect, it } from "vitest";
import { useTranscriptViewStore } from "./transcript-view-store";
beforeEach(() => {
useTranscriptViewStore.setState({ sortDirection: "chronological" });
useTranscriptViewStore.setState({
sortDirection: "chronological",
preserveFilters: false,
selectedFilterKeys: [],
defaultExpanded: false,
});
});
describe("useTranscriptViewStore", () => {
it("defaults to chronological so existing readers see no behavior change", () => {
it("defaults to chronological, unfiltered, and collapsed", () => {
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
expect(useTranscriptViewStore.getState().preserveFilters).toBe(false);
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual([]);
expect(useTranscriptViewStore.getState().defaultExpanded).toBe(false);
});
it("setSortDirection switches between the two known directions", () => {
@@ -19,4 +27,39 @@ describe("useTranscriptViewStore", () => {
setSortDirection("chronological");
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
it("stores filter preferences as unique serializable keys", () => {
const { setPreserveFilters, setSelectedFilterKeys, toggleFilterKey, clearFilterKeys } =
useTranscriptViewStore.getState();
setPreserveFilters(true);
setSelectedFilterKeys(["thinking", "tool:terminal", "thinking", ""]);
expect(useTranscriptViewStore.getState().preserveFilters).toBe(true);
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual([
"thinking",
"tool:terminal",
]);
toggleFilterKey("thinking");
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual(["tool:terminal"]);
toggleFilterKey("text");
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual([
"tool:terminal",
"text",
]);
clearFilterKeys();
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual([]);
});
it("stores the default-expanded preference", () => {
const { setDefaultExpanded } = useTranscriptViewStore.getState();
setDefaultExpanded(true);
expect(useTranscriptViewStore.getState().defaultExpanded).toBe(true);
setDefaultExpanded(false);
expect(useTranscriptViewStore.getState().defaultExpanded).toBe(false);
});
});

View File

@@ -5,22 +5,67 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
export type TranscriptSortDirection = "chronological" | "newest_first";
export type TranscriptFilterKey = string;
interface TranscriptViewState {
sortDirection: TranscriptSortDirection;
preserveFilters: boolean;
selectedFilterKeys: TranscriptFilterKey[];
defaultExpanded: boolean;
setSortDirection: (dir: TranscriptSortDirection) => void;
setPreserveFilters: (preserve: boolean) => void;
setSelectedFilterKeys: (keys: TranscriptFilterKey[]) => void;
toggleFilterKey: (key: TranscriptFilterKey) => void;
clearFilterKeys: () => void;
setDefaultExpanded: (expanded: boolean) => void;
}
const DEFAULTS = {
sortDirection: "chronological" as TranscriptSortDirection,
preserveFilters: false,
selectedFilterKeys: [] as TranscriptFilterKey[],
defaultExpanded: false,
};
function uniqueFilterKeys(keys: TranscriptFilterKey[]): TranscriptFilterKey[] {
return Array.from(new Set(keys.filter((key) => key.length > 0)));
}
export const useTranscriptViewStore = create<TranscriptViewState>()(
persist(
(set) => ({
sortDirection: "chronological",
...DEFAULTS,
setSortDirection: (sortDirection) => set({ sortDirection }),
setPreserveFilters: (preserveFilters) => set({ preserveFilters }),
setSelectedFilterKeys: (selectedFilterKeys) =>
set({ selectedFilterKeys: uniqueFilterKeys(selectedFilterKeys) }),
toggleFilterKey: (key) =>
set((state) => ({
selectedFilterKeys: state.selectedFilterKeys.includes(key)
? state.selectedFilterKeys.filter((candidate) => candidate !== key)
: [...state.selectedFilterKeys, key],
})),
clearFilterKeys: () => set({ selectedFilterKeys: [] }),
setDefaultExpanded: (defaultExpanded) => set({ defaultExpanded }),
}),
{
name: "multica_transcript_view",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ sortDirection: state.sortDirection }),
partialize: (state) => ({
sortDirection: state.sortDirection,
preserveFilters: state.preserveFilters,
selectedFilterKeys: state.selectedFilterKeys,
defaultExpanded: state.defaultExpanded,
}),
merge: (persisted, current) => {
if (!persisted) return { ...current, ...DEFAULTS };
const p = persisted as Partial<TranscriptViewState>;
return {
...current,
...p,
selectedFilterKeys: uniqueFilterKeys(p.selectedFilterKeys ?? []),
};
},
},
),
);

View File

@@ -0,0 +1,240 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import type { AgentTask } from "@multica/core/types/agent";
import { useTranscriptViewStore } from "@multica/core/agents/stores";
import { renderWithI18n } from "../../test/i18n";
import { AgentTranscriptDialog } from "./agent-transcript-dialog";
import type { TimelineItem } from "./build-timeline";
vi.mock("@multica/core/api", () => ({
api: {
getAgent: vi.fn().mockResolvedValue(null),
listRuntimes: vi.fn().mockResolvedValue([]),
},
}));
vi.mock("../actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <>{children}</> : null,
DialogContent: ({ children }: { children: ReactNode }) => (
<div role="dialog">{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuCheckboxItem: ({
checked,
onCheckedChange,
children,
}: {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
children: ReactNode;
}) => (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked === true}
onClick={() => onCheckedChange?.(checked !== true)}
>
{children}
</button>
),
DropdownMenuItem: ({
children,
onClick,
className: _className,
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/ui/collapsible", async () => {
const React = await import("react");
const Context = React.createContext<{
open: boolean;
onOpenChange?: (open: boolean) => void;
}>({ open: false });
return {
Collapsible: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) => (
<Context.Provider value={{ open, onOpenChange }}>{children}</Context.Provider>
),
CollapsibleTrigger: ({
disabled,
children,
className: _className,
}: ButtonHTMLAttributes<HTMLButtonElement>) => {
const ctx = React.useContext(Context);
return (
<button
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) ctx.onOpenChange?.(!ctx.open);
}}
>
{children}
</button>
);
},
CollapsibleContent: ({ children }: { children: ReactNode }) => {
const ctx = React.useContext(Context);
return ctx.open ? <div>{children}</div> : null;
},
};
});
const baseTask: AgentTask = {
id: "task-1",
agent_id: "",
runtime_id: "",
issue_id: "issue-1",
status: "completed",
priority: 0,
dispatched_at: null,
started_at: "2026-06-08T08:00:00Z",
completed_at: "2026-06-08T08:01:00Z",
result: null,
error: null,
created_at: "2026-06-08T08:00:00Z",
};
const items: TimelineItem[] = [
{
seq: 1,
type: "text",
content: "Agent summary\nAgent hidden detail",
},
{
seq: 2,
type: "thinking",
content: "Thinking summary\nThinking hidden detail",
},
{
seq: 3,
type: "tool_use",
tool: "terminal",
input: { command: "pnpm test" },
},
];
function renderDialog(dialogItems: TimelineItem[] = items) {
return renderWithI18n(
<AgentTranscriptDialog
open
onOpenChange={vi.fn()}
task={baseTask}
items={dialogItems}
agentName="Codex"
/>,
);
}
beforeEach(() => {
cleanup();
useTranscriptViewStore.setState({
sortDirection: "chronological",
preserveFilters: false,
selectedFilterKeys: [],
defaultExpanded: false,
});
});
afterEach(() => {
cleanup();
});
describe("AgentTranscriptDialog", () => {
it("preserves selected filters across dialog remounts when enabled", () => {
const first = renderDialog();
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Thinking" }));
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Preserve filters" }));
expect(screen.queryByText("Agent summary")).not.toBeInTheDocument();
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
expect(useTranscriptViewStore.getState().selectedFilterKeys).toEqual(["thinking"]);
first.unmount();
renderDialog();
expect(screen.queryByText("Agent summary")).not.toBeInTheDocument();
expect(screen.getByText(/Thinking summary/)).toBeInTheDocument();
});
it("ignores stale persisted filter keys that are not available in the current transcript", () => {
useTranscriptViewStore.setState({
preserveFilters: true,
selectedFilterKeys: ["thinking"],
});
renderDialog([
{
seq: 1,
type: "text",
content: "Only agent summary\nOnly agent hidden detail",
},
]);
expect(screen.getByText("Only agent summary")).toBeInTheDocument();
expect(screen.queryByText("No execution data recorded.")).not.toBeInTheDocument();
});
it("expands and collapses every currently visible detailed row", () => {
renderDialog();
expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument();
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Expand visible" }));
expect(screen.getByText(/Agent hidden detail/)).toBeInTheDocument();
expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Collapse visible" }));
expect(screen.queryByText(/Agent hidden detail/)).not.toBeInTheDocument();
expect(screen.queryByText(/"command": "pnpm test"/)).not.toBeInTheDocument();
});
it("uses the default-expanded preference for newly opened transcripts", () => {
useTranscriptViewStore.setState({ defaultExpanded: true });
renderDialog();
expect(screen.getByText(/Agent hidden detail/)).toBeInTheDocument();
expect(screen.getByText(/"command": "pnpm test"/)).toBeInTheDocument();
});
});

View File

@@ -35,7 +35,11 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { ActorAvatar } from "../actor-avatar";
import { api } from "@multica/core/api";
import { useTranscriptViewStore, type TranscriptSortDirection } from "@multica/core/agents/stores";
import {
useTranscriptViewStore,
type TranscriptFilterKey,
type TranscriptSortDirection,
} from "@multica/core/agents/stores";
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
import { redactSecrets } from "./redact";
import type { TimelineItem } from "./build-timeline";
@@ -105,6 +109,12 @@ function getEventLabel(item: TimelineItem): string {
}
}
function getItemFilterKey(item: TimelineItem): TranscriptFilterKey {
return item.tool && (item.type === "tool_use" || item.type === "tool_result")
? `tool:${item.tool}`
: item.type;
}
function getEventSummary(item: TimelineItem): string {
switch (item.type) {
case "text":
@@ -142,6 +152,16 @@ function getEventSummary(item: TimelineItem): string {
}
}
function hasEventDetail(item: TimelineItem): boolean {
return (
(item.type === "tool_use" && !!item.input && Object.keys(item.input).length > 0) ||
(item.type === "tool_result" && !!item.output && item.output.length > 0) ||
(item.type === "thinking" && !!item.content && item.content.length > 0) ||
(item.type === "text" && !!item.content && item.content.length > 0) ||
(item.type === "error" && !!item.content && item.content.length > 0)
);
}
function shortenPath(p: string): string {
const parts = p.split("/");
if (parts.length <= 3) return p;
@@ -183,11 +203,24 @@ export function AgentTranscriptDialog({
const [copiedWorkdir, setCopiedWorkdir] = useState(false);
const [agentInfo, setAgentInfo] = useState<Agent | null>(null);
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [sessionFilterKeys, setSessionFilterKeys] = useState<TranscriptFilterKey[]>([]);
const [expandedSeqs, setExpandedSeqs] = useState<Set<number>>(() => new Set());
const sortDirection = useTranscriptViewStore((s) => s.sortDirection);
const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection);
const preserveFilters = useTranscriptViewStore((s) => s.preserveFilters);
const setPreserveFilters = useTranscriptViewStore((s) => s.setPreserveFilters);
const persistedFilterKeys = useTranscriptViewStore((s) => s.selectedFilterKeys);
const setPersistedFilterKeys = useTranscriptViewStore((s) => s.setSelectedFilterKeys);
const togglePersistedFilterKey = useTranscriptViewStore((s) => s.toggleFilterKey);
const clearPersistedFilterKeys = useTranscriptViewStore((s) => s.clearFilterKeys);
const defaultExpanded = useTranscriptViewStore((s) => s.defaultExpanded);
const setDefaultExpanded = useTranscriptViewStore((s) => s.setDefaultExpanded);
const eventRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
const autoExpandedSeqsRef = useRef<Set<number>>(new Set());
const initializedTaskRef = useRef<string | null>(null);
const previousDefaultExpandedRef = useRef(defaultExpanded);
const selectedFilterKeys = preserveFilters ? persistedFilterKeys : sessionFilterKeys;
// Derive filter options from each item:
// tool_use / tool_result → filter value = tool, display = "tool:Bash"
@@ -195,30 +228,35 @@ export function AgentTranscriptDialog({
const filterOptions = useMemo(() => {
const options = new Map<string, string>();
for (const item of items) {
const key = getItemFilterKey(item);
if (item.tool && (item.type === "tool_use" || item.type === "tool_result")) {
const key = `tool:${item.tool}`;
if (!options.has(key)) options.set(key, key);
} else {
const value = item.type;
if (!options.has(value)) {
options.set(value, getEventLabel(item));
if (!options.has(key)) {
options.set(key, getEventLabel(item));
}
}
}
return Array.from(options.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [items]);
// Resolve filter key for each item — mirrors filterOptions derivation exactly
const itemFilterKey = (item: TimelineItem) =>
item.tool && (item.type === "tool_use" || item.type === "tool_result")
? `tool:${item.tool}`
: item.type;
const filterOptionKeys = useMemo(
() => new Set(filterOptions.map(([value]) => value)),
[filterOptions],
);
const activeFilterKeys = useMemo(
() => selectedFilterKeys.filter((key) => filterOptionKeys.has(key)),
[selectedFilterKeys, filterOptionKeys],
);
const activeFilterSet = useMemo(() => new Set(activeFilterKeys), [activeFilterKeys]);
// Strict filter
const filteredItems = useMemo(() => {
if (selectedTools.size === 0) return items;
return items.filter((item) => selectedTools.has(itemFilterKey(item)));
}, [items, selectedTools]);
if (activeFilterSet.size === 0) return items;
return items.filter((item) => activeFilterSet.has(getItemFilterKey(item)));
}, [items, activeFilterSet]);
// Apply user-chosen sort direction. Reverse is a pure presentation concern —
// the underlying timeline (and its seq numbers) is untouched, so copy/filter
@@ -228,6 +266,37 @@ export function AgentTranscriptDialog({
[filteredItems, sortDirection],
);
const detailSeqs = useMemo(
() => displayItems.filter(hasEventDetail).map((item) => item.seq),
[displayItems],
);
const allVisibleDetailsExpanded =
detailSeqs.length > 0 && detailSeqs.every((seq) => expandedSeqs.has(seq));
useEffect(() => {
const switchedDefaultOn =
defaultExpanded && previousDefaultExpandedRef.current !== defaultExpanded;
previousDefaultExpandedRef.current = defaultExpanded;
if (initializedTaskRef.current !== task.id || switchedDefaultOn) {
initializedTaskRef.current = task.id;
autoExpandedSeqsRef.current = new Set(defaultExpanded ? detailSeqs : []);
setExpandedSeqs(defaultExpanded ? new Set(detailSeqs) : new Set());
return;
}
if (!defaultExpanded) return;
const unseen = detailSeqs.filter((seq) => !autoExpandedSeqsRef.current.has(seq));
if (unseen.length === 0) return;
for (const seq of unseen) {
autoExpandedSeqsRef.current.add(seq);
}
setExpandedSeqs((prev) => new Set([...prev, ...unseen]));
}, [task.id, defaultExpanded, detailSeqs]);
// Toggling direction is a manual user action; jump the scroll container back
// to the top so the newest end of the timeline (per the chosen direction) is
// immediately visible. Avoids stranding the user mid-scroll on the wrong end.
@@ -303,18 +372,70 @@ export function AgentTranscriptDialog({
});
}, [displayItems]);
// Toggle tool filter
const toggleTool = useCallback((tool: string) => {
setSelectedTools((prev) => {
const toggleSessionFilterKey = useCallback((key: TranscriptFilterKey) => {
setSessionFilterKeys((prev) => {
const next = new Set(prev);
if (next.has(tool)) next.delete(tool);
else next.add(tool);
return next;
if (next.has(key)) next.delete(key);
else next.add(key);
return Array.from(next);
});
}, []);
const clearFilters = useCallback(() => {
setSelectedTools(new Set());
if (preserveFilters) {
clearPersistedFilterKeys();
return;
}
setSessionFilterKeys([]);
}, [clearPersistedFilterKeys, preserveFilters]);
const toggleFilterKey = useCallback(
(key: TranscriptFilterKey) => {
if (preserveFilters) {
togglePersistedFilterKey(key);
return;
}
toggleSessionFilterKey(key);
},
[preserveFilters, togglePersistedFilterKey, toggleSessionFilterKey],
);
const handlePreserveFiltersChange = useCallback(
(next: boolean) => {
if (next) {
setPersistedFilterKeys(sessionFilterKeys);
} else {
setSessionFilterKeys(persistedFilterKeys);
}
setPreserveFilters(next);
},
[persistedFilterKeys, sessionFilterKeys, setPersistedFilterKeys, setPreserveFilters],
);
const handleToggleVisibleExpanded = useCallback(() => {
for (const seq of detailSeqs) {
autoExpandedSeqsRef.current.add(seq);
}
setExpandedSeqs((prev) => {
if (allVisibleDetailsExpanded) {
const next = new Set(prev);
for (const seq of detailSeqs) {
next.delete(seq);
}
return next;
}
return new Set([...prev, ...detailSeqs]);
});
}, [allVisibleDetailsExpanded, detailSeqs]);
const handleRowExpandedChange = useCallback((seq: number, expanded: boolean) => {
autoExpandedSeqsRef.current.add(seq);
setExpandedSeqs((prev) => {
const next = new Set(prev);
if (expanded) next.add(seq);
else next.delete(seq);
return next;
});
}, []);
// Duration
@@ -326,25 +447,30 @@ export function AgentTranscriptDialog({
: null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
const copyTranscriptLabel = copied
? t(($) => $.transcript.copied)
: activeFilterKeys.length > 0
? t(($) => $.transcript.copy_filtered)
: t(($) => $.transcript.copy_all);
// Status display
const statusBadge = isLive ? (
<span className="inline-flex items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-xs font-medium text-info">
<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 items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">
<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 items-center gap-1 rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium text-destructive">
<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 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground capitalize">
<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>
);
@@ -360,21 +486,45 @@ export function AgentTranscriptDialog({
{/* ── Header ─────────────────────────────────────────────── */}
<div className="border-b px-4 py-3 shrink-0 space-y-2">
{/* Top row: agent name, status, actions */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 items-center gap-2">
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size={24} />
) : (
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-info/10 text-info">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3.5 w-3.5" />
</div>
)}
<span className="font-medium text-sm">{agentName}</span>
<span className="truncate font-medium text-sm">{agentName}</span>
</div>
{statusBadge}
<div className="ml-auto flex items-center gap-1">
<div className="flex w-full max-w-full flex-wrap items-center justify-end gap-1 sm:ml-auto sm:w-auto">
{detailSeqs.length > 0 && (
<button
type="button"
onClick={handleToggleVisibleExpanded}
aria-label={
allVisibleDetailsExpanded
? t(($) => $.transcript.collapse_visible)
: t(($) => $.transcript.expand_visible)
}
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"
>
<ChevronRight
className={cn(
"h-3 w-3 transition-transform",
!allVisibleDetailsExpanded && "rotate-90",
)}
/>
<span className="hidden sm:inline">
{allVisibleDetailsExpanded
? t(($) => $.transcript.collapse_visible)
: t(($) => $.transcript.expand_visible)}
</span>
</button>
)}
{items.length > 1 && (
<SortDirectionToggle
value={sortDirection}
@@ -389,18 +539,19 @@ export function AgentTranscriptDialog({
{filterOptions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t(($) => $.transcript.filter)}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
selectedTools.size > 0
"flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
activeFilterKeys.length > 0
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
: "text-muted-foreground hover:text-foreground hover:bg-accent",
)}
>
<Filter className="h-3 w-3" />
{t(($) => $.transcript.filter)}
{selectedTools.size > 0 && (
<span className="hidden sm:inline">{t(($) => $.transcript.filter)}</span>
{activeFilterKeys.length > 0 && (
<span className="ml-0.5 rounded-full bg-blue-500/20 px-1.5 py-0 text-[10px] font-medium">
{selectedTools.size}
{activeFilterKeys.length}
</span>
)}
</DropdownMenuTrigger>
@@ -408,13 +559,26 @@ export function AgentTranscriptDialog({
{filterOptions.map(([value, label]) => (
<DropdownMenuCheckboxItem
key={value}
checked={selectedTools.has(value)}
onCheckedChange={() => toggleTool(value)}
checked={selectedFilterKeys.includes(value)}
onCheckedChange={() => toggleFilterKey(value)}
>
{label}
</DropdownMenuCheckboxItem>
))}
{selectedTools.size > 0 && (
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={preserveFilters}
onCheckedChange={(checked) => handlePreserveFiltersChange(checked === true)}
>
{t(($) => $.transcript.preserve_filters)}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={defaultExpanded}
onCheckedChange={(checked) => setDefaultExpanded(checked === true)}
>
{t(($) => $.transcript.default_expanded)}
</DropdownMenuCheckboxItem>
{selectedFilterKeys.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={clearFilters} className="text-muted-foreground">
@@ -428,15 +592,16 @@ export function AgentTranscriptDialog({
<button
type="button"
onClick={handleCopyAll}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
aria-label={copyTranscriptLabel}
className="flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? t(($) => $.transcript.copied) : selectedTools.size > 0 ? t(($) => $.transcript.copy_filtered) : t(($) => $.transcript.copy_all)}
<span className="hidden sm:inline">{copyTranscriptLabel}</span>
</button>
<button
type="button"
onClick={() => onOpenChange(false)}
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
className="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>
@@ -481,7 +646,7 @@ export function AgentTranscriptDialog({
<MetadataChip>{t(($) => $.transcript.tool_calls, { count: toolCount })}</MetadataChip>
)}
<MetadataChip>
{selectedTools.size > 0
{activeFilterKeys.length > 0
? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length })
: t(($) => $.transcript.events, { count: items.length })}
</MetadataChip>
@@ -570,6 +735,8 @@ export function AgentTranscriptDialog({
}}
item={item}
isSelected={selectedSeq === item.seq}
expanded={expandedSeqs.has(item.seq)}
onExpandedChange={(expanded) => handleRowExpandedChange(item.seq, expanded)}
/>
))}
</div>
@@ -593,7 +760,7 @@ function SortDirectionToggle({ value, onChange, labels }: SortDirectionTogglePro
<div
role="group"
aria-label={labels.ariaLabel}
className="inline-flex items-center rounded border bg-muted/40 p-0.5 text-xs"
className="inline-flex shrink-0 items-center rounded border bg-muted/40 p-0.5 text-xs"
>
<button
type="button"
@@ -718,14 +885,17 @@ function TimelineBar({
interface TranscriptEventRowProps {
item: TimelineItem;
isSelected: boolean;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
}
const TranscriptEventRow = ({
ref,
item,
isSelected,
expanded,
onExpandedChange,
}: TranscriptEventRowProps & { ref?: React.Ref<HTMLDivElement> }) => {
const [expanded, setExpanded] = useState(false);
const color = getEventColor(item);
const label = getEventLabel(item);
const summary = getEventSummary(item);
@@ -734,12 +904,7 @@ const TranscriptEventRow = ({
[item.created_at],
);
const hasDetail =
(item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) ||
(item.type === "tool_result" && item.output && item.output.length > 0) ||
(item.type === "thinking" && item.content && item.content.length > 0) ||
(item.type === "text" && item.content && item.content.length > 0) ||
(item.type === "error" && item.content && item.content.length > 0);
const hasDetail = hasEventDetail(item);
return (
<div
@@ -749,7 +914,7 @@ const TranscriptEventRow = ({
isSelected && "bg-accent/50",
)}
>
<Collapsible open={expanded} onOpenChange={setExpanded}>
<Collapsible open={expanded} onOpenChange={onExpandedChange}>
<div className="flex items-start gap-2 px-4 py-2">
{/* Type label badge */}
<span

View File

@@ -516,7 +516,11 @@
"no_data": "No execution data recorded.",
"sort_label": "Sort",
"sort_chronological": "Oldest first",
"sort_newest_first": "Newest first"
"sort_newest_first": "Newest first",
"expand_visible": "Expand visible",
"collapse_visible": "Collapse visible",
"preserve_filters": "Preserve filters",
"default_expanded": "Open details by default"
},
"task_failure": {
"agent_error": "Agent execution error",

View File

@@ -493,7 +493,11 @@
"no_data": "記録された実行データがありません。",
"sort_label": "並び替え",
"sort_chronological": "古い順",
"sort_newest_first": "新しい順"
"sort_newest_first": "新しい順",
"expand_visible": "表示中を展開",
"collapse_visible": "表示中を折りたたむ",
"preserve_filters": "フィルターを保持",
"default_expanded": "詳細をデフォルトで展開"
},
"task_failure": {
"agent_error": "エージェント実行エラー",

View File

@@ -501,7 +501,11 @@
"no_data": "기록된 실행 데이터가 없습니다.",
"sort_label": "정렬",
"sort_chronological": "오래된 순",
"sort_newest_first": "최신 순"
"sort_newest_first": "최신 순",
"expand_visible": "표시 항목 펼치기",
"collapse_visible": "표시 항목 접기",
"preserve_filters": "필터 유지",
"default_expanded": "기본으로 세부 정보 펼치기"
},
"task_failure": {
"agent_error": "에이전트 실행 오류",

View File

@@ -501,7 +501,11 @@
"no_data": "未记录执行数据。",
"sort_label": "排序",
"sort_chronological": "时间顺序",
"sort_newest_first": "最新在前"
"sort_newest_first": "最新在前",
"expand_visible": "展开当前可见项",
"collapse_visible": "收起当前可见项",
"preserve_filters": "保留筛选",
"default_expanded": "默认展开详情"
},
"task_failure": {
"agent_error": "智能体执行出错",