mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 23:49:22 +02:00
Compare commits
1 Commits
feat/react
...
agent/j/57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3755cd5ea |
@@ -3,3 +3,7 @@ export {
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
type TranscriptSortDirection,
|
||||
} from "./transcript-view-store";
|
||||
|
||||
22
packages/core/agents/stores/transcript-view-store.test.ts
Normal file
22
packages/core/agents/stores/transcript-view-store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
26
packages/core/agents/stores/transcript-view-store.ts
Normal file
26
packages/core/agents/stores/transcript-view-store.ts
Normal 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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -381,7 +381,10 @@
|
||||
"copy_filtered": "复制筛选结果",
|
||||
"copied": "已复制",
|
||||
"waiting_events": "等待事件中...",
|
||||
"no_data": "未记录执行数据。"
|
||||
"no_data": "未记录执行数据。",
|
||||
"sort_label": "排序",
|
||||
"sort_chronological": "时间顺序",
|
||||
"sort_newest_first": "最新在前"
|
||||
},
|
||||
"task_failure": {
|
||||
"agent_error": "智能体执行出错",
|
||||
|
||||
Reference in New Issue
Block a user