Files
pi-agent-integrated/pi-web/components/BranchNavigator.tsx
T

403 lines
13 KiB
TypeScript

"use client";
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import type { 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<HTMLElement | null>;
/** 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;
}
// Find the visible entry IDs on the path from root to activeLeafId.
function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set<string> {
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, []) ?? []);
}
// Compress a visible linear chain into the first branching/leaf node.
// Server-side compressed IDs also count as skipped nodes.
function compress(node: SessionTreeNode): { node: SessionTreeNode; skipped: number } {
let current = node;
let skipped = current.compressedEntryIds?.length ?? 0;
while (current.children.length === 1) {
current = current.children[0];
skipped += 1 + (current.compressedEntryIds?.length ?? 0);
}
return { node: current, skipped };
}
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 {
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<string>;
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 } = compress(node);
const isActive = activePathIds.has(rep.entry.id);
const isOnPath = activePathIds.has(node.entry.id) || activePathIds.has(rep.entry.id);
const label = getLabel(rep.entry);
const role = rep.entry.type === "message" && "message" in rep.entry
? (rep.entry.message as { role: string }).role
: null;
return (
<div>
{/* This node row */}
<div
style={{
display: "flex",
alignItems: "center",
height: 24,
cursor: "pointer",
}}
onClick={() => onSelect(rep.entry.id)}
>
{/* Indent guide lines */}
{parentLines.map((hasLine, i) => (
<div key={i} style={{ width: 16, flexShrink: 0, position: "relative", height: "100%", alignSelf: "stretch" }}>
{hasLine && (
<div style={{
position: "absolute",
left: 7,
top: 0,
bottom: 0,
width: 1,
background: "var(--border)",
}} />
)}
</div>
))}
{/* Branch connector */}
<div style={{ width: 16, flexShrink: 0, position: "relative", height: "100%", alignSelf: "stretch" }}>
{/* vertical line up (to parent) */}
<div style={{
position: "absolute",
left: 7,
top: 0,
bottom: isLast ? "50%" : 0,
width: 1,
background: "var(--border)",
}} />
{/* horizontal line to node */}
<div style={{
position: "absolute",
left: 7,
top: "50%",
width: 9,
height: 1,
background: "var(--border)",
}} />
</div>
{/* Node dot */}
<div style={{
width: 7,
height: 7,
borderRadius: "50%",
flexShrink: 0,
background: isActive ? "var(--accent)" : isOnPath ? "var(--text-muted)" : "var(--border)",
border: isActive ? "none" : "1px solid var(--text-dim)",
marginRight: 6,
transition: "background 0.12s",
}} />
{/* Role badge */}
{role && (
<span style={{
fontSize: 9,
fontFamily: "var(--font-mono)",
color: role === "user" ? "var(--accent)" : "var(--text-dim)",
background: role === "user" ? "rgba(37,99,235,0.08)" : "var(--bg-hover)",
border: `1px solid ${role === "user" ? "rgba(37,99,235,0.2)" : "var(--border)"}`,
borderRadius: 3,
padding: "0 4px",
marginRight: 5,
flexShrink: 0,
lineHeight: "16px",
}}>
{role === "user" ? "U" : "A"}
</span>
)}
{/* Skipped indicator */}
{skipped > 0 && (
<span style={{ fontSize: 10, color: "var(--text-dim)", marginRight: 5, flexShrink: 0 }}>
+{skipped}
</span>
)}
{/* Label */}
<span style={{
fontSize: 11,
color: isActive ? "var(--text)" : isOnPath ? "var(--text-muted)" : "var(--text-dim)",
fontWeight: isActive ? 500 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}>
{label}
</span>
</div>
{/* Children */}
{rep.children.map((child, idx) => (
<TreeNodeView
key={child.entry.id}
node={child}
activePathIds={activePathIds}
depth={depth + 1}
isLast={idx === rep.children.length - 1}
parentLines={[...parentLines, !isLast]}
onSelect={onSelect}
/>
))}
</div>
);
}
export function BranchNavigator({ tree, activeLeafId, onLeafChange, inline, containerRef, open: openProp, onToggle, hasSession, compact }: Props) {
const { t } = useI18n();
const [openInternal, setOpenInternal] = useState(false);
const open = openProp !== undefined ? openProp : openInternal;
const btnRef = useRef<HTMLButtonElement>(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;
// Find first meaningful node (skip pure linear prefix)
const compressed = tree.length > 0 ? compress(tree[0]) : null;
const firstNode = compressed?.node ?? null;
const hasContent = !noBranchReason && firstNode && firstNode.children.length > 1;
const branchIcon = (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: hasContent ? "var(--accent)" : "var(--text-dim)", flexShrink: 0 }}>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
);
const chevron = (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: 2, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
<polyline points="2 3.5 5 6.5 8 3.5" />
</svg>
);
if (inline) {
return (
<div style={{ height: "100%", display: "flex", alignItems: "stretch" }}>
<button
ref={btnRef}
onClick={() => onToggle ? onToggle() : setOpenInternal((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
height: "100%",
padding: "0 12px",
background: open ? "var(--bg-selected)" : "none",
border: "none",
borderTop: open ? "2px solid var(--accent)" : "2px solid transparent",
borderRight: "1px solid var(--border)",
cursor: "pointer",
color: open ? "var(--text)" : "var(--text-muted)",
fontSize: 11,
whiteSpace: "nowrap",
transition: "color 0.1s, background 0.1s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = open ? "var(--text)" : "var(--text-muted)"; }}
title={t("i18n.branches")}
aria-label={t("i18n.branches")}
aria-pressed={open}
>
{branchIcon}
{!compact && <span>{t("i18n.branches")}</span>}
</button>
{open && dropdownPos && (
<div style={{
position: "fixed",
top: dropdownPos.top,
left: dropdownPos.left,
width: dropdownPos.width,
background: "var(--bg-panel)",
borderBottom: "1px solid var(--border)",
zIndex: 500,
}}>
{hasContent && firstNode ? (
<div style={{ padding: "4px 12px 8px 12px", maxHeight: 260, overflowY: "auto" }}>
{firstNode.children.map((child, idx) => (
<TreeNodeView
key={child.entry.id}
node={child}
activePathIds={activePathIds}
depth={0}
isLast={idx === firstNode.children.length - 1}
parentLines={[]}
onSelect={handleSelect}
/>
))}
</div>
) : (
<div style={{ padding: "10px 16px", fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
{noBranchReason}
</div>
)}
</div>
)}
</div>
);
}
return (
<div style={{ borderBottom: "1px solid var(--border)", background: "var(--bg)", flexShrink: 0, position: "relative" }}>
{/* Header toggle */}
<button
onClick={() => setOpenInternal((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
width: "100%",
padding: "5px 12px",
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
fontSize: 11,
textAlign: "left",
}}
>
{branchIcon}
<span style={{ color: "var(--text-muted)" }}>{t("i18n.branches")}</span>
{chevron}
</button>
{/* Tree panel - overlay */}
{open && (
<div style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
background: "var(--bg)",
borderBottom: "1px solid var(--border)",
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
zIndex: 100,
}}>
{hasContent && firstNode ? (
<div style={{ padding: "4px 12px 8px 12px", maxHeight: 260, overflowY: "auto" }}>
{firstNode.children.map((child, idx) => (
<TreeNodeView
key={child.entry.id}
node={child}
activePathIds={activePathIds}
depth={0}
isLast={idx === firstNode.children.length - 1}
parentLines={[]}
onSelect={handleSelect}
/>
))}
</div>
) : (
<div style={{ padding: "10px 16px", fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
{noBranchReason ?? t("i18n.noBranches")}
</div>
)}
</div>
)}
</div>
);
}