trajectory_rag / webapp /components /tree-visualizer /TreeVisualizerPage.tsx
gaaaaaaaaaaa's picture
Publish trajectory_rag
a414867 verified
Raw History Blame Contribute Delete
24.9 kB
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { FileJson, Loader2, Save, X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
import { CompactTreeNavigator, type CompactNavigatorFocusMode } from "@/components/tree-visualizer/CompactTreeNavigator";
import { TreeCanvas, type TreeCanvasFocusRequest } from "@/components/tree-visualizer/TreeCanvas";
import { TreeCommandBar } from "@/components/tree-visualizer/TreeCommandBar";
import { TreeLibraryRail } from "@/components/tree-visualizer/TreeLibraryRail";
import { CreateTreeDialog } from "@/components/tree-visualizer/CreateTreeDialog";
import { ModelStreamPanel } from "@/components/tree-visualizer/ModelStreamPanel";
import { NodeInspector } from "@/components/tree-visualizer/NodeInspector";
import type { TreeNodeViewMode } from "@/components/tree-visualizer/viewMode";
import {
createTreeFromRaw,
deleteTree,
deleteTrees,
fetchTree,
fetchTrees,
updateTree,
validateTreePayload
} from "@/lib/api";
import { getTreeNavigationState } from "@/lib/tree/navigation";
import { parseTrajectoryText } from "@/lib/tree/parser";
import { visibleNodeIdsForStep } from "@/lib/tree/playback";
import type { TrajectoryTree, TrajectoryTreeNode, ValidationResult } from "@/lib/tree/types";
import { cn } from "@/lib/utils";
const defaultRailWidth = 300;
const defaultInspectorWidth = 420;
const railWidthKey = "tree-visualizer.layout.railWidth";
const inspectorWidthKey = "tree-visualizer.layout.inspectorWidth";
const nodeViewModeKey = "tree-visualizer.nodeViewMode";
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function storedWidth(key: string, fallback: number, min: number, max: number) {
if (typeof window === "undefined") return fallback;
const value = Number(window.localStorage.getItem(key));
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
}
function storedNodeViewMode() {
if (typeof window === "undefined") return "full" as const;
return window.localStorage.getItem(nodeViewModeKey) === "atom" ? "atom" : "full";
}
function stableTreeJson(tree: TrajectoryTree | null) {
if (!tree) return "";
return JSON.stringify({
title: tree.title,
raw_text: tree.raw_text,
nodes: tree.nodes
});
}
function safeFilePart(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 80);
}
function downloadText(filename: string, content: string, type = "text/plain;charset=utf-8") {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function GeneratedTreeSaveDialog({
open,
tree,
isSaving,
error,
onClose,
onSave
}: {
open: boolean;
tree: TrajectoryTree | null;
isSaving: boolean;
error: Error | null;
onClose: () => void;
onSave: (title: string) => void;
}) {
const [title, setTitle] = useState("");
useEffect(() => {
if (!open) return;
setTitle(tree?.title || "Model Stream");
}, [open, tree?.title]);
const preview = useMemo(
() =>
JSON.stringify(
{
title: title.trim() || tree?.title || "Model Stream",
rawText: tree?.raw_text ?? "",
nodes: tree?.nodes ?? []
},
null,
2
),
[title, tree]
);
return (
<AnimatePresence>
{open && tree && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/24 px-4 py-6 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.section
initial={{ opacity: 0, scale: 0.96, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 8 }}
transition={{ duration: 0.18 }}
className="flex max-h-[88vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl border border-paper-300 bg-white/94 shadow-soft backdrop-blur-xl"
>
<div className="flex items-center justify-between border-b border-paper-300 px-5 py-4">
<div className="flex min-w-0 items-center gap-2">
<FileJson className="h-4 w-4 shrink-0 text-claude-500" />
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink-900">Save generated tree</h2>
<p className="mt-1 text-sm text-ink-500">{tree.nodes.length} generated nodes</p>
</div>
</div>
<Button size="icon" variant="ghost" onClick={onClose} title="Close">
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-auto p-5 scrollbar-thin lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<label className="block text-xs font-semibold uppercase text-ink-500">
Tree name
<Input
className="mt-2"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Generated reasoning tree"
disabled={isSaving}
/>
</label>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error.message}
</div>
)}
</div>
<label className="block min-h-0 text-xs font-semibold uppercase text-ink-500">
Raw JSON to save
<Textarea
className="mt-2 h-[min(58vh,540px)] min-h-0 resize-none font-mono text-xs leading-5"
value={preview}
readOnly
/>
</label>
</div>
<div className="flex items-center justify-end gap-2 border-t border-paper-300 px-5 py-4">
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button variant="primary" onClick={() => onSave(title)} disabled={isSaving || tree.nodes.length === 0}>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save tree
</Button>
</div>
</motion.section>
</motion.div>
)}
</AnimatePresence>
);
}
function TreeVisualizerInner() {
const queryClient = useQueryClient();
const [selectedTreeId, setSelectedTreeId] = useState<string | null>(null);
const [tree, setTree] = useState<TrajectoryTree | null>(null);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [streamOpen, setStreamOpen] = useState(false);
const [generatedSaveOpen, setGeneratedSaveOpen] = useState(false);
const [liveStreamTreeId, setLiveStreamTreeId] = useState<string | null>(null);
const liveStreamTreeIdRef = useRef<string | null>(null);
const [lastSavedJson, setLastSavedJson] = useState("");
const [revealCount, setRevealCount] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [fitNonce, setFitNonce] = useState(0);
const [focusRequest, setFocusRequest] = useState<TreeCanvasFocusRequest | null>(null);
const [autoReadEnabled, setAutoReadEnabled] = useState(false);
const [createdReplayTreeId, setCreatedReplayTreeId] = useState<string | null>(null);
const [darkMode, setDarkMode] = useState(false);
const [railWidth, setRailWidth] = useState(defaultRailWidth);
const [inspectorWidth, setInspectorWidth] = useState(defaultInspectorWidth);
const [resizingPanel, setResizingPanel] = useState<"rail" | "inspector" | null>(null);
const [nodeViewMode, setNodeViewMode] = useState<TreeNodeViewMode>("full");
const treesQuery = useQuery({
queryKey: ["trees"],
queryFn: fetchTrees
});
useEffect(() => {
setDarkMode(window.localStorage.getItem("tree-visualizer.theme") === "dark");
setRailWidth(storedWidth(railWidthKey, defaultRailWidth, 240, 460));
setInspectorWidth(storedWidth(inspectorWidthKey, defaultInspectorWidth, 340, 620));
setNodeViewMode(storedNodeViewMode());
}, []);
useEffect(() => {
window.localStorage.setItem("tree-visualizer.theme", darkMode ? "dark" : "light");
}, [darkMode]);
useEffect(() => {
window.localStorage.setItem(railWidthKey, String(railWidth));
}, [railWidth]);
useEffect(() => {
window.localStorage.setItem(inspectorWidthKey, String(inspectorWidth));
}, [inspectorWidth]);
useEffect(() => {
window.localStorage.setItem(nodeViewModeKey, nodeViewMode);
}, [nodeViewMode]);
useEffect(() => {
if (!resizingPanel) return;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (event: PointerEvent) => {
if (resizingPanel === "rail") {
setRailWidth(clamp(event.clientX, 240, 460));
return;
}
setInspectorWidth(clamp(window.innerWidth - event.clientX, 340, 620));
};
const stopResize = () => setResizingPanel(null);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize, { once: true });
window.addEventListener("pointercancel", stopResize, { once: true });
return () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
};
}, [resizingPanel]);
useEffect(() => {
if (selectedTreeId || !treesQuery.data?.trees.length) return;
setSelectedTreeId(treesQuery.data.trees[0].id);
}, [selectedTreeId, treesQuery.data?.trees]);
const treeQuery = useQuery({
queryKey: ["tree", selectedTreeId],
queryFn: () => fetchTree(selectedTreeId as string),
enabled: Boolean(selectedTreeId)
});
useEffect(() => {
if (!treeQuery.data?.tree) return;
if (liveStreamTreeIdRef.current) return;
const loadedTree = treeQuery.data.tree;
setTree(loadedTree);
setValidation(treeQuery.data.validation);
setLastSavedJson(stableTreeJson(loadedTree));
liveStreamTreeIdRef.current = null;
setLiveStreamTreeId(null);
setSelectedNodeId(null);
setAutoReadEnabled(false);
if (createdReplayTreeId === loadedTree.id) {
setRevealCount(0);
setIsPlaying(true);
setCreatedReplayTreeId(null);
} else {
setRevealCount(loadedTree.nodes.length);
setIsPlaying(false);
}
}, [createdReplayTreeId, treeQuery.data]);
useEffect(() => {
if (!tree || !isPlaying) return;
if (revealCount >= tree.nodes.length) {
setIsPlaying(false);
return;
}
const timer = window.setTimeout(() => {
setRevealCount((current) => Math.min(tree.nodes.length, current + 1));
}, 380);
return () => window.clearTimeout(timer);
}, [isPlaying, revealCount, tree]);
useEffect(() => {
if (!tree || !autoReadEnabled) return;
const visibleIds = visibleNodeIdsForStep(tree.nodes, revealCount);
const navigation = getTreeNavigationState(tree.nodes, visibleIds, selectedNodeId);
const target = selectedNodeId ? navigation.nextNode : navigation.currentNode;
if (!target) {
setAutoReadEnabled(false);
return;
}
const timer = window.setTimeout(() => {
setSelectedNodeId(target.id);
setFocusRequest({ nodeId: target.id, mode: "node", nonce: Date.now() });
}, selectedNodeId ? 2600 : 120);
return () => window.clearTimeout(timer);
}, [autoReadEnabled, revealCount, selectedNodeId, tree]);
const saveMutation = useMutation({
mutationFn: async (input?: { title?: string }) => {
if (!tree) throw new Error("No tree loaded.");
if (liveStreamTreeId && tree.id === liveStreamTreeId) {
return createTreeFromRaw({
title: input?.title?.trim() || tree.title,
rawText: tree.raw_text,
nodes: tree.nodes
});
}
return updateTree(tree.id, {
title: tree.title,
raw_text: tree.raw_text,
nodes: tree.nodes
});
},
onSuccess: ({ tree: savedTree, validation: nextValidation }) => {
setTree(savedTree);
setValidation(nextValidation);
setLastSavedJson(stableTreeJson(savedTree));
liveStreamTreeIdRef.current = null;
setLiveStreamTreeId(null);
setSelectedTreeId(savedTree.id);
setGeneratedSaveOpen(false);
queryClient.invalidateQueries({ queryKey: ["trees"] });
queryClient.invalidateQueries({ queryKey: ["tree", savedTree.id] });
}
});
const validateMutation = useMutation({
mutationFn: async () => {
if (!tree) throw new Error("No tree loaded.");
return validateTreePayload({ tree });
},
onSuccess: ({ validation: nextValidation }) => {
setValidation(nextValidation);
}
});
const deleteMutation = useMutation({
mutationFn: deleteTree,
onSuccess: (_result, deletedId) => {
queryClient.invalidateQueries({ queryKey: ["trees"] });
queryClient.removeQueries({ queryKey: ["tree", deletedId] });
if (selectedTreeId === deletedId) {
setSelectedTreeId(null);
setTree(null);
setValidation(null);
setSelectedNodeId(null);
setAutoReadEnabled(false);
}
}
});
const bulkDeleteMutation = useMutation({
mutationFn: deleteTrees,
onSuccess: ({ deletedIds }) => {
const deleted = new Set(deletedIds);
queryClient.invalidateQueries({ queryKey: ["trees"] });
for (const deletedId of deleted) {
queryClient.removeQueries({ queryKey: ["tree", deletedId] });
}
if (selectedTreeId && deleted.has(selectedTreeId)) {
setSelectedTreeId(null);
setTree(null);
setValidation(null);
setSelectedNodeId(null);
setAutoReadEnabled(false);
}
}
});
const isDirty = Boolean(tree && stableTreeJson(tree) !== lastSavedJson);
const selectedNode = useMemo(
() => tree?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[selectedNodeId, tree]
);
const isLiveStreamTree = Boolean(tree && liveStreamTreeId && tree.id === liveStreamTreeId);
const hasActionableTree = Boolean(tree && (!isLiveStreamTree || tree.nodes.length > 0));
const updateNodes = (nodes: TrajectoryTreeNode[]) => {
setTree((current) => (current ? { ...current, nodes } : current));
};
const updateNode = (nextNode: TrajectoryTreeNode) => {
setTree((current) =>
current
? {
...current,
nodes: current.nodes.map((node) => (node.id === nextNode.id ? nextNode : node))
}
: current
);
};
const handleCreated = (createdTree: TrajectoryTree) => {
setCreatedReplayTreeId(createdTree.id);
setSelectedTreeId(createdTree.id);
setTree(createdTree);
setValidation(null);
setSelectedNodeId(null);
setAutoReadEnabled(false);
liveStreamTreeIdRef.current = null;
setLiveStreamTreeId(null);
setRevealCount(0);
setIsPlaying(true);
setLastSavedJson(stableTreeJson(createdTree));
queryClient.invalidateQueries({ queryKey: ["trees"] });
queryClient.invalidateQueries({ queryKey: ["tree", createdTree.id] });
};
const handleModelStreamStart = () => {
const nextId = `model_stream_${Date.now()}`;
const now = new Date().toISOString();
const nextTree: TrajectoryTree = {
version: 1,
id: nextId,
title: "Model Stream",
createdAt: now,
updatedAt: now,
raw_text: "",
nodes: []
};
if (selectedTreeId) {
void queryClient.cancelQueries({ queryKey: ["tree", selectedTreeId] });
}
liveStreamTreeIdRef.current = nextId;
setLiveStreamTreeId(nextId);
setSelectedTreeId(null);
setTree(nextTree);
setValidation(null);
setSelectedNodeId(null);
setAutoReadEnabled(false);
setRevealCount(0);
setIsPlaying(false);
setLastSavedJson("");
};
const handleModelStreamRawText = (rawText: string, done: boolean) => {
const parse = parseTrajectoryText(rawText);
const now = new Date().toISOString();
const streamTreeId = liveStreamTreeIdRef.current ?? liveStreamTreeId ?? `model_stream_${Date.now()}`;
if (!liveStreamTreeIdRef.current) liveStreamTreeIdRef.current = streamTreeId;
if (!liveStreamTreeId) setLiveStreamTreeId(streamTreeId);
if (parse.nodes.length === 0) {
setTree((current) =>
current && current.id === streamTreeId
? {
...current,
raw_text: rawText,
updatedAt: now
}
: current
);
setValidation(parse.validation);
return;
}
const previousCount = tree?.id === streamTreeId ? tree.nodes.length : 0;
const nextTree: TrajectoryTree = {
version: 1,
id: streamTreeId,
title: parse.title || "Model Stream",
createdAt: tree?.id === streamTreeId ? tree.createdAt : now,
updatedAt: now,
raw_text: rawText,
nodes: parse.nodes
};
setTree(nextTree);
setValidation(parse.validation);
setRevealCount(parse.nodes.length);
setIsPlaying(false);
if (parse.nodes.length > previousCount || done) {
const newest = parse.nodes[parse.nodes.length - 1];
if (newest) {
setSelectedNodeId(newest.id);
setFocusRequest({ nodeId: newest.id, mode: "node", nonce: Date.now() });
}
}
};
const handleModelStreamError = (message: string) => {
setValidation((current) => ({
ok: false,
issues: [
{
level: "error",
code: "model_stream_error",
message
},
...(current?.issues ?? [])
],
rootIds: current?.rootIds ?? [],
finalNodeId: current?.finalNodeId ?? null
}));
};
const handleSave = () => {
if (!tree) return;
if (isLiveStreamTree) {
setGeneratedSaveOpen(true);
return;
}
saveMutation.mutate(undefined);
};
const replay = () => {
if (!tree) return;
setSelectedNodeId(null);
setAutoReadEnabled(false);
setRevealCount(0);
setIsPlaying(true);
};
const step = () => {
if (!tree) return;
setAutoReadEnabled(false);
setIsPlaying(false);
setRevealCount((current) => Math.min(tree.nodes.length, current + 1));
};
const exportRawMessage = () => {
if (!tree) return;
const filenameBase = safeFilePart(tree.title || tree.id) || tree.id || "trajectory_tree";
const rawText = tree.raw_text?.trim()
? tree.raw_text
: JSON.stringify(
{
title: tree.title,
nodes: tree.nodes
},
null,
2
);
downloadText(`${filenameBase}_raw_trajectory.txt`, rawText);
};
const navigateToNode = (nodeId: string, mode: CompactNavigatorFocusMode) => {
setAutoReadEnabled(false);
setIsPlaying(false);
setSelectedNodeId(nodeId);
setFocusRequest({ nodeId, mode, nonce: Date.now() });
};
const toggleAutoRead = () => {
if (!tree) return;
setIsPlaying(false);
setRevealCount(tree.nodes.length);
setAutoReadEnabled((current) => !current);
};
return (
<main className={cn("flex h-screen overflow-hidden text-ink-900", darkMode && "tree-dark")}>
<TreeLibraryRail
trees={treesQuery.data?.trees ?? []}
selectedTreeId={selectedTreeId}
isLoading={treesQuery.isLoading}
isDeleting={deleteMutation.isPending || bulkDeleteMutation.isPending}
darkMode={darkMode}
width={railWidth}
onResizeStart={(event) => {
event.preventDefault();
setResizingPanel("rail");
}}
onToggleDarkMode={() => setDarkMode((current) => !current)}
onCreate={() => setCreateOpen(true)}
onSelect={(treeId) => {
liveStreamTreeIdRef.current = null;
setLiveStreamTreeId(null);
setSelectedTreeId(treeId);
setSelectedNodeId(null);
setAutoReadEnabled(false);
}}
onDelete={(treeId) => {
if (window.confirm("Delete this saved tree?")) deleteMutation.mutate(treeId);
}}
onBulkDelete={(treeIds) => bulkDeleteMutation.mutate({ ids: treeIds })}
onDeleteAll={() => bulkDeleteMutation.mutate({ all: true })}
/>
<section className="relative flex min-w-0 flex-1 flex-col">
<TreeCommandBar
hasTree={hasActionableTree}
isDirty={isDirty}
isPlaying={isPlaying}
isSaving={saveMutation.isPending}
isValidating={validateMutation.isPending}
nodeViewMode={nodeViewMode}
onCreate={() => setCreateOpen(true)}
onImport={() => setCreateOpen(true)}
onModelStream={() => setStreamOpen(true)}
onSave={handleSave}
onExportRaw={exportRawMessage}
onValidate={() => validateMutation.mutate()}
onReplay={replay}
onPause={() => setIsPlaying(false)}
onStep={step}
onFitView={() => setFitNonce((current) => current + 1)}
onNodeViewModeChange={setNodeViewMode}
/>
<TreeCanvas
tree={tree}
selectedNodeId={selectedNodeId}
focusRequest={focusRequest}
revealCount={revealCount}
fitNonce={fitNonce}
nodeViewMode={nodeViewMode}
onSelectNode={setSelectedNodeId}
onTreeNodesChange={updateNodes}
onCreate={() => setCreateOpen(true)}
onImport={() => setCreateOpen(true)}
/>
<CompactTreeNavigator
tree={tree}
revealCount={revealCount}
selectedNodeId={selectedNodeId}
autoReadEnabled={autoReadEnabled}
onNavigate={navigateToNode}
onToggleAutoRead={toggleAutoRead}
/>
<ModelStreamPanel
open={streamOpen}
onClose={() => setStreamOpen(false)}
onStreamStart={handleModelStreamStart}
onStreamRawText={handleModelStreamRawText}
onStreamError={handleModelStreamError}
/>
<GeneratedTreeSaveDialog
open={generatedSaveOpen}
tree={isLiveStreamTree ? tree : null}
isSaving={saveMutation.isPending}
error={saveMutation.error instanceof Error ? saveMutation.error : null}
onClose={() => setGeneratedSaveOpen(false)}
onSave={(title) => saveMutation.mutate({ title })}
/>
<AnimatePresence>
{(treeQuery.isFetching || deleteMutation.isPending || bulkDeleteMutation.isPending) && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-full border border-paper-300 bg-white/90 px-4 py-2 text-sm text-ink-600 shadow-panel backdrop-blur"
>
<Loader2 className="h-4 w-4 animate-spin text-claude-500" />
Syncing tree
</motion.div>
)}
</AnimatePresence>
</section>
<NodeInspector
node={selectedNode}
validation={validation}
isSaving={saveMutation.isPending}
width={inspectorWidth}
onResizeStart={(event) => {
event.preventDefault();
setResizingPanel("inspector");
}}
onUpdateNode={updateNode}
onSaveTree={handleSave}
onClose={() => setSelectedNodeId(null)}
/>
<CreateTreeDialog open={createOpen} onClose={() => setCreateOpen(false)} onCreated={handleCreated} />
</main>
);
}
export function TreeVisualizerPage() {
const [client] = useState(() => new QueryClient());
return (
<QueryClientProvider client={client}>
<TreeVisualizerInner />
</QueryClientProvider>
);
}