openclaw/apps/web/app/components/workspace/knowledge-tree.tsx
Mark 6d99d3c959 feat: Chrome-style tabs with curved connectors, new chat tab button, and link handling
- Tab bar uses distinct strip background with curved connectors on active tab
- "+" button creates new chat tabs (like Chrome new tab)
- Markdown links intercepted for in-app navigation and anchor scrolling
- Fix borderColor shorthand conflict in database-viewer spinner
- Align sidebar header height with tab bar

Made-with: Cursor
2026-03-12 14:13:47 -07:00

282 lines
8.2 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
};
// --- Icons (inline SVG for zero-dep) ---
function FolderIcon({ open }: { open?: boolean }) {
return open ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
);
}
function TableIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#42a97a" fillOpacity="0.15" stroke="#42a97a" strokeWidth="1.2" />
<path d="M1.5 6.5h13" stroke="#42a97a" strokeWidth="1.2" />
<path d="M6 6.5v7" stroke="#42a97a" strokeWidth="1.2" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="3.5" height="9.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="6.25" y="2.5" width="3.5" height="6.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="11" y="2.5" width="3.5" height="11" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
</svg>
);
}
function DocumentIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
</svg>
);
}
function FileIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
function DatabaseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function ReportIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: open ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 150ms ease",
}}
>
<path d="m9 18 6-6-6-6" />
</svg>
);
}
// --- Node Icon Resolver ---
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
switch (node.type) {
case "object":
return node.defaultView === "kanban" ? <KanbanIcon /> : <TableIcon />;
case "document":
return <DocumentIcon />;
case "folder":
return <FolderIcon open={open} />;
case "database":
return <DatabaseIcon />;
case "report":
return <ReportIcon />;
default:
return <FileIcon />;
}
}
// --- Tree Node Component ---
function TreeNodeItem({
node,
depth,
activePath,
onSelect,
expandedPaths,
onToggleExpand,
}: {
node: TreeNode;
depth: number;
activePath: string | null;
onSelect: (node: TreeNode) => void;
expandedPaths: Set<string>;
onToggleExpand: (path: string) => void;
}) {
const hasChildren = node.children && node.children.length > 0;
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
const isExpanded = expandedPaths.has(node.path);
const isActive = activePath === node.path;
const handleClick = () => {
onSelect(node);
if (isExpandable) {
onToggleExpand(node.path);
}
};
const typeColor =
node.type === "object"
? "var(--color-accent)"
: node.type === "document"
? "#60a5fa"
: node.type === "database"
? "#c084fc"
: node.type === "report"
? "#22c55e"
: "var(--color-text-muted)";
return (
<div>
<button
type="button"
onClick={handleClick}
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer"
style={{
paddingLeft: `${depth * 16 + 8}px`,
background: isActive ? "var(--color-surface-hover)" : "transparent",
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
}}
onMouseEnter={(e) => {
if (!isActive)
{(e.currentTarget as HTMLElement).style.background =
"var(--color-surface-hover)";}
}}
onMouseLeave={(e) => {
if (!isActive)
{(e.currentTarget as HTMLElement).style.background = "transparent";}
}}
>
{/* Expand/collapse chevron */}
<span
className="flex-shrink-0 w-4 h-4 flex items-center justify-center"
style={{ opacity: isExpandable ? 1 : 0 }}
>
{isExpandable && <ChevronIcon open={isExpanded} />}
</span>
{/* Icon */}
<span
className="flex-shrink-0 flex items-center"
style={{ color: typeColor }}
>
<NodeIcon node={node} open={isExpanded} />
</span>
{/* Label */}
<span className="truncate flex-1">
{node.name.replace(/\.md$/, "")}
</span>
</button>
{/* Children */}
{isExpanded && hasChildren && (
<div
className="relative"
style={{
borderLeft: depth > 0 ? "1px solid var(--color-border)" : "none",
marginLeft: `${depth * 16 + 16}px`,
}}
>
{node.children!.map((child) => (
<TreeNodeItem
key={child.path}
node={child}
depth={depth + 1}
activePath={activePath}
onSelect={onSelect}
expandedPaths={expandedPaths}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)}
</div>
);
}
// --- Exported Tree Component ---
export function KnowledgeTree({
tree,
activePath,
onSelect,
}: {
tree: TreeNode[];
activePath: string | null;
onSelect: (node: TreeNode) => void;
}) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
() => new Set(),
);
const handleToggleExpand = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {next.delete(path);}
else {next.add(path);}
return next;
});
}, []);
if (tree.length === 0) {
return (
<div className="px-4 py-6 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
No files in workspace
</div>
);
}
return (
<div className="py-1">
{tree.map((node) => (
<TreeNodeItem
key={node.path}
node={node}
depth={0}
activePath={activePath}
onSelect={onSelect}
expandedPaths={expandedPaths}
onToggleExpand={handleToggleExpand}
/>
))}
</div>
);
}