openclaw/apps/web/app/components/workspace/knowledge-tree.tsx
kumarabhirup 8341c6048c
feat(web): full UI redesign with light/dark theme, TanStack data tables, media rendering, and gateway-routed agent execution
Overhaul the Dench web app with a comprehensive visual redesign and several
major feature additions across the chat interface, workspace, and agent
runtime layer.

Theme & Design System
- Replace the dark-only palette with a full light/dark theme system that
  respects system preference via localStorage + inline script (no FOUC).
- Introduce new design tokens: glassmorphism surfaces, semantic colors
  (success/warning/error/info), object-type chip palettes, and a tiered
  shadow scale (sm/md/lg/xl).
- Add Instrument Serif + Inter via Google Fonts for a refined typographic
  hierarchy; headings use the serif face, body uses Inter.
- Rebrand UI from "Ironclaw" to "Dench" across the landing page and
  metadata.

Chat & Chain-of-Thought
- Rewrite the chain-of-thought component with inline media detection and
  rendering — images, video, audio, and PDFs referenced in agent output
  are now displayed directly in the conversation thread.
- Add status indicator parts (e.g. "Preparing response...",
  "Optimizing session context...") that render as subtle activity badges
  instead of verbose reasoning blocks.
- Integrate react-markdown with remark-gfm for proper markdown rendering
  in assistant messages (tables, strikethrough, autolinks, etc.).
- Improve report-block splitting and lazy-loaded ReportCard rendering.

Workspace
- Introduce @tanstack/react-table for the object table, replacing the
  hand-rolled table with full column sorting, fuzzy filtering via
  match-sorter-utils, row selection, and bulk actions.
- Add a new media viewer component for in-workspace image/video/PDF
  preview.
- New API routes: bulk-delete entries, field management (CRUD + reorder),
  raw-file serving endpoint for media assets.
- Redesign workspace sidebar, empty state, and entry detail modal with
  the new theme tokens and improved layout.

Agent Runtime
- Switch web agent execution from --local to gateway-routed mode so
  concurrent chat threads share the gateway's lane-based concurrency
  system, eliminating cross-process file-lock contention.
- Advertise "tool-events" capability during WebSocket handshake so the
  gateway streams tool start/update/result events to the UI.
- Add new agent callback hooks: onLifecycleStart, onCompactionStart/End,
  and onToolUpdate for richer real-time feedback.
- Forward media URLs emitted by agent events into the chat stream.

Dependencies
- Add @tanstack/match-sorter-utils and @tanstack/react-table to the web
  app.

Published as ironclaw@2026.2.10-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 11:17:23 -08:00

290 lines
8.4 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
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 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="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>
{/* Type badge for objects */}
{node.type === "object" && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
{node.defaultView === "kanban" ? "board" : "table"}
</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>
);
}