mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 03:29:33 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user