mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -13,5 +13,6 @@ export {
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
type TranscriptFilterKey,
|
||||
type TranscriptSortDirection,
|
||||
} from "./transcript-view-store";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? []),
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "エージェント実行エラー",
|
||||
|
||||
@@ -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": "에이전트 실행 오류",
|
||||
|
||||
@@ -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": "智能体执行出错",
|
||||
|
||||
Reference in New Issue
Block a user