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": "智能体执行出错",