Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
a3755cd5ea feat(transcript): add sort direction toggle to agent transcript dialog (MUL-2368)
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 <github@multica.ai>
2026-05-19 15:50:19 +08:00
6 changed files with 153 additions and 9 deletions

View File

@@ -3,3 +3,7 @@ export {
type AgentsScope,
type AgentsViewState,
} from "./view-store";
export {
useTranscriptViewStore,
type TranscriptSortDirection,
} from "./transcript-view-store";

View File

@@ -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");
});
});

View File

@@ -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<TranscriptViewState>()(
persist(
(set) => ({
sortDirection: "chronological",
setSortDirection: (sortDirection) => set({ sortDirection }),
}),
{
name: "multica_transcript_view",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ sortDirection: state.sortDirection }),
},
),
);

View File

@@ -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<Agent | null>(null);
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const sortDirection = useTranscriptViewStore((s) => s.sortDirection);
const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection);
const eventRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(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}
<div className="ml-auto flex items-center gap-1">
{items.length > 1 && (
<SortDirectionToggle
value={sortDirection}
onChange={handleSortDirectionChange}
labels={{
chronological: t(($) => $.transcript.sort_chronological),
newestFirst: t(($) => $.transcript.sort_newest_first),
ariaLabel: t(($) => $.transcript.sort_label),
}}
/>
)}
{filterOptions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
@@ -449,10 +486,10 @@ export function AgentTranscriptDialog({
</div>
{/* ── Timeline progress bar ─────────────────────────────── */}
{filteredItems.length > 0 && (
{displayItems.length > 0 && (
<div className="border-b px-4 py-2.5 shrink-0">
<TimelineBar
items={filteredItems}
items={displayItems}
selectedSeq={selectedSeq}
onSegmentClick={handleSegmentClick}
/>
@@ -471,7 +508,7 @@ export function AgentTranscriptDialog({
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
>
{filteredItems.length === 0 ? (
{displayItems.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{isLive ? (
<div className="flex items-center gap-2">
@@ -484,7 +521,7 @@ export function AgentTranscriptDialog({
</div>
) : (
<div className="divide-y">
{filteredItems.map((item) => (
{displayItems.map((item) => (
<TranscriptEventRow
key={item.seq}
ref={(el) => {
@@ -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 (
<div
role="group"
aria-label={labels.ariaLabel}
className="inline-flex items-center rounded border bg-muted/40 p-0.5 text-xs"
>
<button
type="button"
aria-pressed={value === "chronological"}
title={labels.chronological}
onClick={() => onChange("chronological")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "chronological"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<ArrowDownNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.chronological}</span>
</button>
<button
type="button"
aria-pressed={value === "newest_first"}
title={labels.newestFirst}
onClick={() => onChange("newest_first")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "newest_first"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<ArrowUpNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.newestFirst}</span>
</button>
</div>
);
}
// ─── Metadata chip ──────────────────────────────────────────────────────────
function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {

View File

@@ -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",

View File

@@ -381,7 +381,10 @@
"copy_filtered": "复制筛选结果",
"copied": "已复制",
"waiting_events": "等待事件中...",
"no_data": "未记录执行数据。"
"no_data": "未记录执行数据。",
"sort_label": "排序",
"sort_chronological": "时间顺序",
"sort_newest_first": "最新在前"
},
"task_failure": {
"agent_error": "智能体执行出错",