"use client"; import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import type { BranchPreview, SessionEntry, SessionTreeNode } from "@/lib/types"; import { useI18n } from "@/hooks/useI18n"; interface Props { tree: SessionTreeNode[]; activeLeafId: string | null; onLeafChange: (leafId: string | null) => void; /** When true, renders as a compact inline button for embedding in a top bar */ inline?: boolean; /** When inline, use this ref's bounding rect to size/position the dropdown */ containerRef?: React.RefObject; /** Controlled open state for inline mode */ open?: boolean; /** Called when the button is clicked in inline mode */ onToggle?: () => void; /** Whether a session is currently active (used to show appropriate empty reason) */ hasSession?: boolean; /** When inline, render icon-only (no text label) to save horizontal space */ compact?: boolean; /** Keep the inline dropdown mounted while another control supplies its trigger */ hideInlineButton?: boolean; } // Find the visible entry IDs on the path from root to activeLeafId. function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set { if (!targetId) return new Set(); const target = targetId; function search(nodes: SessionTreeNode[], path: string[]): string[] | null { for (const node of nodes) { const next = [...path, node.entry.id]; if (node.entry.id === target || node.compressedEntryIds?.includes(target)) { return next; } const found = search(node.children, next); if (found) return found; } return null; } return new Set(search(nodes, []) ?? []); } function isMessageEntry(entry: SessionEntry): boolean { return entry.type === "message" && "message" in entry; } // Compress a visible linear chain into the first branching/leaf node. // Server-side compressed IDs also count as skipped nodes. // branchPreview is the bounded preview of the first message on the source // chain. labelEntry keeps unprojected/test shapes working as a fallback. export function compressChain(node: SessionTreeNode): { node: SessionTreeNode; skipped: number; branchPreview?: BranchPreview; labelEntry: SessionEntry; } { let current = node; let branchPreview = current.branchPreview; let labelEntry: SessionEntry | null = isMessageEntry(current.entry) ? current.entry : null; let skipped = current.compressedEntryIds?.length ?? 0; while (current.children.length === 1) { current = current.children[0]; branchPreview ??= current.branchPreview; if (!labelEntry && isMessageEntry(current.entry)) labelEntry = current.entry; skipped += 1 + (current.compressedEntryIds?.length ?? 0); } return { node: current, skipped, branchPreview, labelEntry: labelEntry ?? current.entry }; } // Top-level rows of the panel: with multiple roots (a branch was started from // the very first message) the roots themselves are the branches; otherwise the // children of the first branching node. export function selectTopLevelBranches(tree: SessionTreeNode[]): SessionTreeNode[] { if (tree.length > 1) return tree; if (tree.length === 0) return []; const first = compressChain(tree[0]).node; return first.children.length > 1 ? first.children : []; } function getLabel(entry: SessionEntry): string { if (entry.type === "message" && "message" in entry) { const msg = entry.message as { role: string; content: unknown }; const content = msg.content; let text = ""; if (typeof content === "string") { text = content; } else if (Array.isArray(content)) { text = content .filter((b): b is { type: "text"; text: string } => b.type === "text") .map((b) => b.text) .join(" "); } if (text.length > 40) text = text.slice(0, 40) + "…"; if (text) return text; if (msg.role === "assistant") return "[assistant]"; } return entry.type; } // Does the tree have any branching at all? function hasBranch(nodes: SessionTreeNode[]): boolean { if (nodes.length > 1) return true; for (const node of nodes) { if (node.children.length > 1) return true; if (hasBranch(node.children)) return true; } return false; } interface TreeNodeProps { node: SessionTreeNode; activePathIds: Set; depth: number; isLast: boolean; parentLines: boolean[]; // whether ancestor at each depth has more siblings after onSelect: (id: string) => void; } function TreeNodeView({ node, activePathIds, depth, isLast, parentLines, onSelect }: TreeNodeProps) { const { node: rep, skipped, branchPreview, labelEntry } = compressChain(node); const isActive = activePathIds.has(rep.entry.id); const isOnPath = activePathIds.has(node.entry.id) || activePathIds.has(rep.entry.id); const label = branchPreview?.text ?? getLabel(labelEntry); const role = branchPreview ? branchPreview.role ?? null : isMessageEntry(labelEntry) ? (labelEntry as { message: { role: string } }).message.role : null; return (
{/* This node row */}
onSelect(rep.entry.id)} > {/* Indent guide lines */} {parentLines.map((hasLine, i) => (
{hasLine && (
)}
))} {/* Branch connector */}
{/* vertical line up (to parent) */}
{/* horizontal line to node */}
{/* Node dot */}
{/* Role badge */} {role && ( {role === "user" ? "U" : "A"} )} {/* Skipped indicator */} {skipped > 0 && ( +{skipped} )} {/* Label */} {label}
{/* Children */} {rep.children.map((child, idx) => ( ))}
); } export function BranchNavigator({ tree, activeLeafId, onLeafChange, inline, containerRef, open: openProp, onToggle, hasSession, compact, hideInlineButton }: Props) { const { t } = useI18n(); const [openInternal, setOpenInternal] = useState(false); const open = openProp !== undefined ? openProp : openInternal; const btnRef = useRef(null); const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number } | null>(null); useEffect(() => { if (!open || !inline) return; const anchor = containerRef?.current ?? btnRef.current; if (!anchor) return; const update = () => { const rect = anchor.getBoundingClientRect(); setDropdownPos({ top: rect.bottom, left: rect.left, width: rect.width }); }; update(); const ro = new ResizeObserver(update); ro.observe(anchor); return () => ro.disconnect(); }, [open, inline, containerRef]); const activePathIds = useMemo( () => buildActivePath(tree, activeLeafId), [tree, activeLeafId] ); const handleSelect = useCallback((id: string) => { onLeafChange(id); }, [onLeafChange]); const noBranchReason = !hasSession ? t("i18n.noActiveSession") : !hasBranch(tree) ? t("i18n.noBranches") : null; const topLevel = selectTopLevelBranches(tree); const hasContent = !noBranchReason && topLevel.length > 0; const branchIcon = ( ); const chevron = ( ); if (inline) { return (
{open && dropdownPos && (
{hasContent ? (
{topLevel.map((child, idx) => ( ))}
) : (
{noBranchReason}
)}
)}
); } return (
{/* Header toggle */} {/* Tree panel - overlay */} {open && (
{hasContent ? (
{topLevel.map((child, idx) => ( ))}
) : (
{noBranchReason ?? t("i18n.noBranches")}
)}
)}
); }