From a3755cd5ea7064ab403d911ff5066539c3abed44 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 19 May 2026 15:50:19 +0800 Subject: [PATCH] feat(transcript): add sort direction toggle to agent transcript dialog (MUL-2368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a header toggle that lets users flip the agent transcript between chronological (oldest first, current behavior) and newest-first. The preference is persisted via a small Zustand store. Default stays chronological so existing readers see no behavior change. Sort is a pure presentation concern — the underlying timeline (seq numbers, filter keys, segment navigation) is untouched. Toggling resets the scroll container to the top so the user lands on the newest end of the chosen direction. Copy-all respects the displayed order so the exported text matches what's on screen. Scope is limited to the task transcript dialog per the MVP plan; the issue execution log and agent activity tab are out of scope and may be revisited once this interaction validates. Closes GH #2736. Co-authored-by: multica-agent --- packages/core/agents/stores/index.ts | 4 + .../stores/transcript-view-store.test.ts | 22 ++++ .../agents/stores/transcript-view-store.ts | 26 +++++ .../agent-transcript-dialog.tsx | 100 ++++++++++++++++-- packages/views/locales/en/agents.json | 5 +- packages/views/locales/zh-Hans/agents.json | 5 +- 6 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 packages/core/agents/stores/transcript-view-store.test.ts create mode 100644 packages/core/agents/stores/transcript-view-store.ts diff --git a/packages/core/agents/stores/index.ts b/packages/core/agents/stores/index.ts index c3e44e495..e646ead23 100644 --- a/packages/core/agents/stores/index.ts +++ b/packages/core/agents/stores/index.ts @@ -3,3 +3,7 @@ export { type AgentsScope, type AgentsViewState, } from "./view-store"; +export { + useTranscriptViewStore, + type TranscriptSortDirection, +} from "./transcript-view-store"; diff --git a/packages/core/agents/stores/transcript-view-store.test.ts b/packages/core/agents/stores/transcript-view-store.test.ts new file mode 100644 index 000000000..4c89e3848 --- /dev/null +++ b/packages/core/agents/stores/transcript-view-store.test.ts @@ -0,0 +1,22 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useTranscriptViewStore } from "./transcript-view-store"; + +beforeEach(() => { + useTranscriptViewStore.setState({ sortDirection: "chronological" }); +}); + +describe("useTranscriptViewStore", () => { + it("defaults to chronological so existing readers see no behavior change", () => { + expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological"); + }); + + it("setSortDirection switches between the two known directions", () => { + const { setSortDirection } = useTranscriptViewStore.getState(); + + setSortDirection("newest_first"); + expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first"); + + setSortDirection("chronological"); + expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological"); + }); +}); diff --git a/packages/core/agents/stores/transcript-view-store.ts b/packages/core/agents/stores/transcript-view-store.ts new file mode 100644 index 000000000..97baba148 --- /dev/null +++ b/packages/core/agents/stores/transcript-view-store.ts @@ -0,0 +1,26 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { defaultStorage } from "../../platform/storage"; + +export type TranscriptSortDirection = "chronological" | "newest_first"; + +interface TranscriptViewState { + sortDirection: TranscriptSortDirection; + setSortDirection: (dir: TranscriptSortDirection) => void; +} + +export const useTranscriptViewStore = create()( + persist( + (set) => ({ + sortDirection: "chronological", + setSortDirection: (sortDirection) => set({ sortDirection }), + }), + { + name: "multica_transcript_view", + storage: createJSONStorage(() => defaultStorage), + partialize: (state) => ({ sortDirection: state.sortDirection }), + }, + ), +); diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 7f4853d01..e28f62656 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -17,6 +17,8 @@ import { Cloud, Cpu, Filter, + ArrowDownNarrowWide, + ArrowUpNarrowWide, } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; @@ -31,6 +33,7 @@ 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 type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent"; import { redactSecrets } from "./redact"; import type { TimelineItem } from "./build-timeline"; @@ -178,6 +181,8 @@ export function AgentTranscriptDialog({ const [agentInfo, setAgentInfo] = useState(null); const [runtimeInfo, setRuntimeInfo] = useState(null); const [selectedTools, setSelectedTools] = useState>(new Set()); + const sortDirection = useTranscriptViewStore((s) => s.sortDirection); + const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection); const eventRefs = useRef>(new Map()); const scrollContainerRef = useRef(null); @@ -212,6 +217,26 @@ export function AgentTranscriptDialog({ return items.filter((item) => selectedTools.has(itemFilterKey(item))); }, [items, selectedTools]); + // Apply user-chosen sort direction. Reverse is a pure presentation concern — + // the underlying timeline (and its seq numbers) is untouched, so copy/filter + // and segment navigation continue to work against the same data. + const displayItems = useMemo( + () => (sortDirection === "newest_first" ? [...filteredItems].reverse() : filteredItems), + [filteredItems, sortDirection], + ); + + // 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. + const handleSortDirectionChange = useCallback( + (dir: typeof sortDirection) => { + if (dir === sortDirection) return; + setSortDirection(dir); + scrollContainerRef.current?.scrollTo({ top: 0 }); + }, + [sortDirection, setSortDirection], + ); + // Fetch agent and runtime metadata when dialog opens useEffect(() => { if (!open) return; @@ -249,9 +274,10 @@ export function AgentTranscriptDialog({ eventRefs.current.get(seq)?.scrollIntoView({ behavior: "smooth", block: "center" }); }, []); - // Copy all events as text (uses filtered items) + // Copy all events as text. Use the displayed order so users get the same + // sequence they see on screen — matters when sort is set to newest-first. const handleCopyAll = useCallback(() => { - const text = filteredItems + const text = displayItems .map((item) => { const label = getEventLabel(item); const summary = getEventSummary(item); @@ -262,7 +288,7 @@ export function AgentTranscriptDialog({ setCopied(true); setTimeout(() => setCopied(false), 2000); }); - }, [filteredItems]); + }, [displayItems]); // Toggle tool filter const toggleTool = useCallback((tool: string) => { @@ -336,6 +362,17 @@ export function AgentTranscriptDialog({ {statusBadge}
+ {items.length > 1 && ( + $.transcript.sort_chronological), + newestFirst: t(($) => $.transcript.sort_newest_first), + ariaLabel: t(($) => $.transcript.sort_label), + }} + /> + )} {filterOptions.length > 0 && ( {/* ── Timeline progress bar ─────────────────────────────── */} - {filteredItems.length > 0 && ( + {displayItems.length > 0 && (
@@ -471,7 +508,7 @@ export function AgentTranscriptDialog({ ref={scrollContainerRef} className="flex-1 overflow-y-auto min-h-0" > - {filteredItems.length === 0 ? ( + {displayItems.length === 0 ? (
{isLive ? (
@@ -484,7 +521,7 @@ export function AgentTranscriptDialog({
) : (
- {filteredItems.map((item) => ( + {displayItems.map((item) => ( { @@ -503,6 +540,55 @@ export function AgentTranscriptDialog({ ); } +// ─── Sort direction toggle ────────────────────────────────────────────────── + +interface SortDirectionToggleProps { + value: TranscriptSortDirection; + onChange: (dir: TranscriptSortDirection) => void; + labels: { chronological: string; newestFirst: string; ariaLabel: string }; +} + +function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) { + return ( +
+ + +
+ ); +} + // ─── Metadata chip ────────────────────────────────────────────────────────── function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) { diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 7dc69f654..4a7917fe5 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -391,7 +391,10 @@ "copy_filtered": "Copy filtered", "copied": "Copied", "waiting_events": "Waiting for events...", - "no_data": "No execution data recorded." + "no_data": "No execution data recorded.", + "sort_label": "Sort", + "sort_chronological": "Oldest first", + "sort_newest_first": "Newest first" }, "task_failure": { "agent_error": "Agent execution error", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 25b7b81b3..793ca1227 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -381,7 +381,10 @@ "copy_filtered": "复制筛选结果", "copied": "已复制", "waiting_events": "等待事件中...", - "no_data": "未记录执行数据。" + "no_data": "未记录执行数据。", + "sort_label": "排序", + "sort_chronological": "时间顺序", + "sort_newest_first": "最新在前" }, "task_failure": { "agent_error": "智能体执行出错",