👌 IMPROVE: dench workspace

This commit is contained in:
kumarabhirup 2026-02-11 16:45:07 -08:00
parent 04da69c89b
commit f74327445e
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
22 changed files with 4677 additions and 12 deletions

View File

@ -0,0 +1,116 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { resolveDenchRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export type WorkspaceContext = {
exists: boolean;
organization?: {
id?: string;
name?: string;
slug?: string;
};
members?: Array<{
id: string;
name: string;
email: string;
role: string;
}>;
defaults?: {
default_view?: string;
date_format?: string;
naming_convention?: string;
};
};
/**
* Parse workspace_context.yaml with basic YAML extraction.
* Handles the specific structure defined by the Dench skill.
*/
function parseWorkspaceContext(content: string): WorkspaceContext {
const ctx: WorkspaceContext = { exists: true };
// Extract organization block
const orgMatch = content.match(
/organization:\s*\n((?:\s{2,}.+\n)*)/,
);
if (orgMatch) {
const orgBlock = orgMatch[1];
const org: Record<string, string> = {};
for (const line of orgBlock.split("\n")) {
const kv = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
if (kv) {org[kv[1]] = kv[2].trim();}
}
ctx.organization = {
id: org.id,
name: org.name,
slug: org.slug,
};
}
// Extract members list
const membersMatch = content.match(
/members:\s*\n((?:\s{2,}.+\n)*)/,
);
if (membersMatch) {
const membersBlock = membersMatch[1];
const members: WorkspaceContext["members"] = [];
let current: Record<string, string> = {};
for (const line of membersBlock.split("\n")) {
const itemStart = line.match(/^\s+-\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
const propLine = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
if (itemStart) {
if (current.id) {members.push(current as never);}
current = { [itemStart[1]]: itemStart[2].trim() };
} else if (propLine && !line.trim().startsWith("-")) {
current[propLine[1]] = propLine[2].trim();
}
}
if (current.id) {members.push(current as never);}
ctx.members = members;
}
// Extract defaults block
const defaultsMatch = content.match(
/defaults:\s*\n((?:\s{2,}.+\n)*)/,
);
if (defaultsMatch) {
const defaultsBlock = defaultsMatch[1];
const defaults: Record<string, string> = {};
for (const line of defaultsBlock.split("\n")) {
const kv = line.match(/^\s+(\w[\w_]*)\s*:\s*(.+)/);
if (kv) {defaults[kv[1]] = kv[2].trim();}
}
ctx.defaults = {
default_view: defaults.default_view,
date_format: defaults.date_format,
naming_convention: defaults.naming_convention,
};
}
return ctx;
}
export async function GET() {
const root = resolveDenchRoot();
if (!root) {
return Response.json({ exists: false } satisfies WorkspaceContext);
}
const ctxPath = join(root, "workspace_context.yaml");
if (!existsSync(ctxPath)) {
return Response.json({ exists: true } satisfies WorkspaceContext);
}
try {
const content = readFileSync(ctxPath, "utf-8");
const parsed = parseWorkspaceContext(content);
return Response.json(parsed);
} catch {
return Response.json({ exists: true } satisfies WorkspaceContext);
}
}

View File

@ -0,0 +1,26 @@
import { readWorkspaceFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(req: Request) {
const url = new URL(req.url);
const path = url.searchParams.get("path");
if (!path) {
return Response.json(
{ error: "Missing 'path' query parameter" },
{ status: 400 },
);
}
const file = readWorkspaceFile(path);
if (!file) {
return Response.json(
{ error: "File not found or access denied" },
{ status: 404 },
);
}
return Response.json(file);
}

View File

@ -0,0 +1,171 @@
import { duckdbQuery, duckdbPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type ObjectRow = {
id: string;
name: string;
description?: string;
icon?: string;
default_view?: string;
immutable?: boolean;
created_at?: string;
updated_at?: string;
};
type FieldRow = {
id: string;
name: string;
type: string;
description?: string;
required?: boolean;
enum_values?: string;
enum_colors?: string;
enum_multiple?: boolean;
related_object_id?: string;
relationship_type?: string;
sort_order?: number;
};
type StatusRow = {
id: string;
name: string;
color?: string;
sort_order?: number;
is_default?: boolean;
};
type EavRow = {
entry_id: string;
created_at: string;
updated_at: string;
field_name: string;
value: string | null;
};
/**
* Pivot raw EAV rows into one object per entry with field names as keys.
* Input: [{ entry_id, field_name, value }, ...]
* Output: [{ entry_id, "Full Name": "Sarah", "Email": "sarah@..." }, ...]
*/
function pivotEavRows(
rows: EavRow[],
): Record<string, unknown>[] {
const grouped = new Map<
string,
Record<string, unknown>
>();
for (const row of rows) {
let entry = grouped.get(row.entry_id);
if (!entry) {
entry = {
entry_id: row.entry_id,
created_at: row.created_at,
updated_at: row.updated_at,
};
grouped.set(row.entry_id, entry);
}
if (row.field_name) {
entry[row.field_name] = row.value;
}
}
return Array.from(grouped.values());
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB database not found" },
{ status: 404 },
);
}
// Sanitize name to prevent injection (only allow alphanumeric + underscore)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
// Fetch object metadata
const objects = duckdbQuery<ObjectRow>(
`SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const obj = objects[0];
// Fetch fields for this object
const fields = duckdbQuery<FieldRow>(
`SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`,
);
// Fetch statuses for this object
const statuses = duckdbQuery<StatusRow>(
`SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
);
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
let entries: Record<string, unknown>[] = [];
// Attempt PIVOT view
const pivotEntries = duckdbQuery(
`SELECT * FROM v_${name} ORDER BY created_at DESC LIMIT 200`,
);
if (pivotEntries.length > 0) {
entries = pivotEntries;
} else {
// Fallback: raw EAV query, then pivot in JS
const rawRows = duckdbQuery<EavRow>(
`SELECT e.id as entry_id, e.created_at, e.updated_at,
f.name as field_name, ef.value
FROM entries e
JOIN entry_fields ef ON ef.entry_id = e.id
JOIN fields f ON f.id = ef.field_id
WHERE e.object_id = '${obj.id}'
ORDER BY e.created_at DESC
LIMIT 5000`,
);
entries = pivotEavRows(rawRows);
}
// Parse enum JSON strings in fields
const parsedFields = fields.map((f) => ({
...f,
enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined,
enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined,
}));
return Response.json({
object: obj,
fields: parsedFields,
statuses,
entries,
});
}
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") {return value;}
try {
return JSON.parse(value);
} catch {
return value;
}
}

View File

@ -0,0 +1,43 @@
import { duckdbQuery } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(req: Request) {
let body: { sql?: string };
try {
body = await req.json();
} catch {
return Response.json(
{ error: "Invalid JSON body" },
{ status: 400 },
);
}
const { sql } = body;
if (!sql || typeof sql !== "string") {
return Response.json(
{ error: "Missing 'sql' field in request body" },
{ status: 400 },
);
}
// Basic SQL safety: reject obviously dangerous statements
const upper = sql.toUpperCase().trim();
if (
upper.startsWith("DROP") ||
upper.startsWith("DELETE") ||
upper.startsWith("INSERT") ||
upper.startsWith("UPDATE") ||
upper.startsWith("ALTER") ||
upper.startsWith("CREATE")
) {
return Response.json(
{ error: "Only SELECT queries are allowed" },
{ status: 403 },
);
}
const rows = duckdbQuery(sql);
return Response.json({ rows });
}

View File

@ -0,0 +1,170 @@
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
import { join } from "node:path";
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export type TreeNode = {
name: string;
path: string; // relative to dench/
type: "object" | "document" | "folder" | "file";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
};
type DbObject = {
name: string;
icon?: string;
default_view?: string;
};
/** Read .object.yaml metadata from a directory if it exists. */
function readObjectMeta(
dirPath: string,
): { icon?: string; defaultView?: string } | null {
const yamlPath = join(dirPath, ".object.yaml");
if (!existsSync(yamlPath)) {return null;}
try {
const content = readFileSync(yamlPath, "utf-8");
const parsed = parseSimpleYaml(content);
return {
icon: parsed.icon as string | undefined,
defaultView: parsed.default_view as string | undefined,
};
} catch {
return null;
}
}
/**
* Query DuckDB for all objects so we can identify object directories
* even when .object.yaml files are missing.
*/
function loadDbObjects(): Map<string, DbObject> {
const map = new Map<string, DbObject>();
const rows = duckdbQuery<DbObject>(
"SELECT name, icon, default_view FROM objects",
);
for (const row of rows) {
map.set(row.name, row);
}
return map;
}
/** Recursively build a tree of the knowledge/ directory. */
function buildTree(
absDir: string,
relativeBase: string,
dbObjects: Map<string, DbObject>,
): TreeNode[] {
const nodes: TreeNode[] = [];
let entries: Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return nodes;
}
// Sort: directories first, then files, alphabetical within each group
const sorted = entries
.filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml")
.toSorted((a, b) => {
if (a.isDirectory() && !b.isDirectory()) {return -1;}
if (!a.isDirectory() && b.isDirectory()) {return 1;}
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
// Skip hidden files except .object.yaml (but don't list it as a node)
if (entry.name === ".object.yaml") {continue;}
if (entry.name.startsWith(".")) {continue;}
const absPath = join(absDir, entry.name);
const relPath = relativeBase
? `${relativeBase}/${entry.name}`
: entry.name;
if (entry.isDirectory()) {
const objectMeta = readObjectMeta(absPath);
const dbObject = dbObjects.get(entry.name);
const children = buildTree(absPath, relPath, dbObjects);
if (objectMeta || dbObject) {
// This directory represents a CRM object (from .object.yaml OR DuckDB)
nodes.push({
name: entry.name,
path: relPath,
type: "object",
icon: objectMeta?.icon ?? dbObject?.icon,
defaultView:
((objectMeta?.defaultView ?? dbObject?.default_view) as
| "table"
| "kanban") ?? "table",
children: children.length > 0 ? children : undefined,
});
} else {
// Regular folder
nodes.push({
name: entry.name,
path: relPath,
type: "folder",
children: children.length > 0 ? children : undefined,
});
}
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
nodes.push({
name: entry.name,
path: relPath,
type: isDocument ? "document" : "file",
});
}
}
return nodes;
}
export async function GET() {
const root = resolveDenchRoot();
if (!root) {
return Response.json({ tree: [], exists: false });
}
// Load objects from DuckDB for smart directory detection
const dbObjects = loadDbObjects();
const knowledgeDir = join(root, "knowledge");
const tree: TreeNode[] = [];
// Build knowledge tree
if (existsSync(knowledgeDir)) {
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
}
// Add top-level files (WORKSPACE.md, workspace_context.yaml, etc.)
try {
const topLevel = readdirSync(root, { withFileTypes: true });
for (const entry of topLevel) {
if (!entry.isFile()) {continue;}
if (entry.name.startsWith(".")) {continue;}
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
tree.push({
name: entry.name,
path: entry.name,
type: isDocument ? "document" : "file",
});
}
} catch {
// skip if root unreadable
}
return Response.json({ tree, exists: true });
}

View File

@ -24,7 +24,16 @@ type MemoryFile = {
sizeBytes: number;
};
type SidebarSection = "chats" | "skills" | "memories";
type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
};
type SidebarSection = "chats" | "skills" | "memories" | "workspace";
type SidebarProps = {
onSessionSelect?: (sessionId: string) => void;
@ -38,11 +47,11 @@ type SidebarProps = {
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 60) {return `${seconds}s ago`;}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 60) {return `${minutes}m ago`;}
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
if (hours < 24) {return `${hours}h ago`;}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
@ -200,6 +209,170 @@ function MemoriesSection({
);
}
// --- Workspace Section ---
function WorkspaceTreeNode({
node,
depth,
expanded,
onToggle,
}: {
node: TreeNode;
depth: number;
expanded: Set<string>;
onToggle: (path: string) => void;
}) {
const hasChildren = node.children && node.children.length > 0;
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
const isOpen = expanded.has(node.path);
const iconColor =
node.type === "object"
? "var(--color-accent)"
: node.type === "document"
? "#60a5fa"
: "var(--color-text-muted)";
return (
<div>
<div
className="flex items-center gap-1.5 py-1 px-2 rounded-md text-sm cursor-pointer transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ paddingLeft: `${depth * 14 + 8}px`, color: "var(--color-text-muted)" }}
onClick={() => {
if (isExpandable) {onToggle(node.path);}
// Navigate to workspace page for actionable items
if (node.type === "object" || node.type === "document" || node.type === "file") {
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
}
}}
>
{/* Chevron */}
<span className="w-3.5 flex-shrink-0 flex items-center justify-center" style={{ opacity: isExpandable ? 1 : 0 }}>
{isExpandable && (
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 150ms" }}
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</span>
{/* Icon */}
<span className="flex-shrink-0" style={{ color: iconColor }}>
{node.type === "object" ? (
node.defaultView === "kanban" ? (
<svg width="14" height="14" 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>
) : (
<svg width="14" height="14" 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>
)
) : node.type === "document" ? (
<svg width="14" height="14" 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>
) : node.type === "folder" ? (
<svg width="14" height="14" 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>
) : (
<svg width="14" height="14" 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>
)}
</span>
{/* Name */}
<span className="truncate flex-1 text-xs">{node.name.replace(/\.md$/, "")}</span>
{/* Type badge for objects */}
{node.type === "object" && (
<span
className="text-[9px] px-1 py-0 rounded flex-shrink-0"
style={{ background: "rgba(232,93,58,0.15)", color: "var(--color-accent)" }}
>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
</div>
{isOpen && hasChildren && (
<div>
{node.children!.map((child) => (
<WorkspaceTreeNode
key={child.path}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
function WorkspaceSection({ tree }: { tree: TreeNode[] }) {
const [expanded, setExpanded] = useState<Set<string>>(() => {
// Auto-expand first level
const initial = new Set<string>();
for (const node of tree) {
if (node.children && node.children.length > 0) {
initial.add(node.path);
}
}
return initial;
});
const toggle = (path: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) {next.delete(path);}
else {next.add(path);}
return next;
});
};
if (tree.length === 0) {
return (
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
No workspace data yet.
</p>
);
}
return (
<div className="space-y-0.5">
{tree.map((node) => (
<WorkspaceTreeNode
key={node.path}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
/>
))}
{/* Full workspace link */}
<a
href="/workspace"
className="flex items-center gap-1.5 mx-2 mt-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-accent)" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" x2="21" y1="14" y2="3" />
</svg>
Open full workspace
</a>
</div>
);
}
// --- Collapsible Header ---
function SectionHeader({
@ -246,18 +419,19 @@ export function Sidebar({
activeSessionId,
refreshKey,
}: SidebarProps) {
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats"]));
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats", "workspace"]));
const [webSessions, setWebSessions] = useState<WebSession[]>([]);
const [skills, setSkills] = useState<SkillEntry[]>([]);
const [mainMemory, setMainMemory] = useState<string | null>(null);
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(true);
const toggleSection = (section: SidebarSection) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
if (next.has(section)) {next.delete(section);}
else {next.add(section);}
return next;
});
};
@ -267,15 +441,17 @@ export function Sidebar({
async function load() {
setLoading(true);
try {
const [webSessionsRes, skillsRes, memoriesRes] = await Promise.all([
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes] = await Promise.all([
fetch("/api/web-sessions").then((r) => r.json()),
fetch("/api/skills").then((r) => r.json()),
fetch("/api/memories").then((r) => r.json()),
fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
]);
setWebSessions(webSessionsRes.sessions ?? []);
setSkills(skillsRes.skills ?? []);
setMainMemory(memoriesRes.mainMemory ?? null);
setDailyLogs(memoriesRes.dailyLogs ?? []);
setWorkspaceTree(workspaceRes.tree ?? []);
} catch (err) {
console.error("Failed to load sidebar data:", err);
} finally {
@ -339,6 +515,21 @@ export function Sidebar({
)}
</div>
{/* Workspace */}
{workspaceTree.length > 0 && (
<div>
<SectionHeader
title="Workspace"
count={workspaceTree.length}
isOpen={openSections.has("workspace")}
onToggle={() => toggleSection("workspace")}
/>
{openSections.has("workspace") && (
<WorkspaceSection tree={workspaceTree} />
)}
</div>
)}
{/* Skills */}
<div>
<SectionHeader

View File

@ -0,0 +1,86 @@
"use client";
type BreadcrumbsProps = {
path: string;
onNavigate: (path: string) => void;
};
export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
const segments = path.split("/").filter(Boolean);
return (
<nav className="flex items-center gap-1 text-sm py-2">
<button
type="button"
onClick={() => onNavigate("")}
className="px-1.5 py-0.5 rounded transition-colors cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--color-text)";
(e.currentTarget as HTMLElement).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color =
"var(--color-text-muted)";
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
workspace
</button>
{segments.map((segment, idx) => {
const partialPath = segments.slice(0, idx + 1).join("/");
const isLast = idx === segments.length - 1;
return (
<span key={partialPath} className="flex items-center gap-1">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-text-muted)", opacity: 0.4 }}
>
<path d="m9 18 6-6-6-6" />
</svg>
{isLast ? (
<span
className="px-1.5 py-0.5"
style={{ color: "var(--color-text)" }}
>
{segment.replace(/\.md$/, "")}
</span>
) : (
<button
type="button"
onClick={() => onNavigate(partialPath)}
className="px-1.5 py-0.5 rounded transition-colors cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color =
"var(--color-text)";
(e.currentTarget as HTMLElement).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color =
"var(--color-text-muted)";
(e.currentTarget as HTMLElement).style.background =
"transparent";
}}
>
{segment}
</button>
)}
</span>
);
})}
</nav>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import dynamic from "next/dynamic";
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
const MarkdownContent = dynamic(
() =>
import("./markdown-content").then((mod) => mod.MarkdownContent),
{
ssr: false,
loading: () => (
<div className="animate-pulse space-y-3 py-4">
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
</div>
),
},
);
type DocumentViewProps = {
content: string;
title?: string;
};
export function DocumentView({ content, title }: DocumentViewProps) {
// Strip YAML frontmatter if present
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
// Extract title from first H1 if no title provided
const h1Match = body.match(/^#\s+(.+)/m);
const displayTitle = title ?? h1Match?.[1];
const markdownBody =
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
return (
<div className="max-w-3xl mx-auto px-6 py-8">
{displayTitle && (
<h1
className="text-3xl font-bold mb-6"
style={{ color: "var(--color-text)" }}
>
{displayTitle}
</h1>
)}
<div className="workspace-prose">
<MarkdownContent content={markdownBody} />
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
"use client";
export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
{/* Icon */}
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
</div>
{/* Text */}
<div className="text-center max-w-md">
<h2
className="text-lg font-semibold mb-2"
style={{ color: "var(--color-text)" }}
>
{workspaceExists
? "Workspace is empty"
: "No workspace found"}
</h2>
<p
className="text-sm leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{workspaceExists ? (
<>
The Dench workspace exists but has no knowledge tree yet.
Ask the CRM agent to create objects and documents to populate it.
</>
) : (
<>
The Dench workspace directory was not found. To initialize it,
start a conversation with the CRM agent and it will create the
workspace structure automatically.
</>
)}
</p>
</div>
{/* Hint */}
<div
className="flex items-center gap-2 px-4 py-3 rounded-lg text-sm"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-accent)", flexShrink: 0 }}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<span>
Expected location:{" "}
<code
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: "var(--color-bg)" }}
>
~/.openclaw/workspace/dench/
</code>
</span>
</div>
{/* Back link */}
<a
href="/"
className="flex items-center gap-2 text-sm mt-2 transition-colors"
style={{ color: "var(--color-accent)" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Back to Chat
</a>
</div>
);
}

View File

@ -0,0 +1,168 @@
"use client";
type FileViewerProps = {
content: string;
filename: string;
type: "yaml" | "text";
};
export function FileViewer({ content, filename, type }: FileViewerProps) {
const lines = content.split("\n");
return (
<div className="max-w-4xl mx-auto px-6 py-8">
{/* File header */}
<div
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-text-muted)" }}
>
<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>
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{filename}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded ml-auto"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
{type.toUpperCase()}
</span>
</div>
{/* File content */}
<div
className="rounded-b-lg border overflow-x-auto"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
}}
>
<pre className="text-sm leading-6" style={{ margin: 0 }}>
<code>
{lines.map((line, idx) => (
<div
key={idx}
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
>
{/* Line number */}
<span
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
minWidth: "3rem",
userSelect: "none",
}}
>
{idx + 1}
</span>
{/* Line content */}
<span
className="pr-4 flex-1"
style={{ color: "var(--color-text)" }}
>
{type === "yaml" ? (
<YamlLine line={line} />
) : (
line || " "
)}
</span>
</div>
))}
</code>
</pre>
</div>
</div>
);
}
/** Simple YAML syntax highlighting */
function YamlLine({ line }: { line: string }) {
// Comment
if (line.trim().startsWith("#")) {
return <span style={{ color: "var(--color-text-muted)" }}>{line}</span>;
}
// Key: value
const kvMatch = line.match(/^(\s*)([\w][\w_-]*)\s*(:)(.*)/);
if (kvMatch) {
const [, indent, key, colon, value] = kvMatch;
return (
<>
<span>{indent}</span>
<span style={{ color: "#60a5fa" }}>{key}</span>
<span style={{ color: "var(--color-text-muted)" }}>{colon}</span>
<YamlValue value={value} />
</>
);
}
// List item
const listMatch = line.match(/^(\s*)(-)(\s*)(.*)/);
if (listMatch) {
const [, indent, dash, space, value] = listMatch;
return (
<>
<span>{indent}</span>
<span style={{ color: "var(--color-accent)" }}>{dash}</span>
<span>{space}</span>
<span style={{ color: "var(--color-text)" }}>{value}</span>
</>
);
}
return <span>{line || " "}</span>;
}
function YamlValue({ value }: { value: string }) {
const trimmed = value.trim();
// String in quotes
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return <span style={{ color: "#a5d6a7" }}> {trimmed}</span>;
}
// Boolean
if (trimmed === "true" || trimmed === "false") {
return <span style={{ color: "#f59e0b" }}> {trimmed}</span>;
}
// Number
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return <span style={{ color: "#c084fc" }}> {trimmed}</span>;
}
// Null
if (trimmed === "null") {
return (
<span style={{ color: "var(--color-text-muted)", fontStyle: "italic" }}>
{" "}
{trimmed}
</span>
);
}
return <span style={{ color: "var(--color-text)" }}> {value}</span>;
}

View File

@ -0,0 +1,261 @@
"use client";
import { useState, useCallback } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file";
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 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} />;
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"
: "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: "rgba(232, 93, 58, 0.15)",
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>
);
}

View File

@ -0,0 +1,12 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
);
}

View File

@ -0,0 +1,332 @@
"use client";
import { useMemo } from "react";
type Field = {
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
};
type Status = {
id: string;
name: string;
color?: string;
sort_order?: number;
};
type ObjectKanbanProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
statuses: Status[];
members?: Array<{ id: string; name: string }>;
};
// --- Card component ---
function KanbanCard({
entry,
fields,
members,
}: {
entry: Record<string, unknown>;
fields: Field[];
members?: Array<{ id: string; name: string }>;
}) {
// Show first 4 non-status fields
const displayFields = fields
.filter(
(f) =>
f.type !== "richtext" &&
entry[f.name] !== null &&
entry[f.name] !== undefined &&
entry[f.name] !== "",
)
.slice(0, 4);
// Find a "name" or "title" field for the card header
const titleField = fields.find(
(f) =>
f.name.toLowerCase().includes("name") ||
f.name.toLowerCase().includes("title"),
);
const title = titleField
? String(entry[titleField.name] ?? "Untitled")
: String(entry[fields[0]?.name] ?? "Untitled");
return (
<div
className="rounded-lg p-3 mb-2 transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-text-muted)";
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-border)";
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
}}
>
<div
className="text-sm font-medium mb-1.5 truncate"
style={{ color: "var(--color-text)" }}
>
{title}
</div>
<div className="space-y-1">
{displayFields
.filter((f) => f !== titleField)
.slice(0, 3)
.map((field) => {
const val = entry[field.name];
if (!val) {return null;}
// Resolve user fields
let displayVal = String(val);
if (field.type === "user") {
const member = members?.find((m) => m.id === displayVal);
if (member) {displayVal = member.name;}
}
return (
<div key={field.id} className="flex items-center gap-1.5 text-xs">
<span style={{ color: "var(--color-text-muted)" }}>
{field.name}:
</span>
{field.type === "enum" ? (
<EnumBadgeMini
value={displayVal}
enumValues={field.enum_values}
enumColors={field.enum_colors}
/>
) : (
<span
className="truncate"
style={{ color: "var(--color-text)" }}
>
{displayVal}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
function EnumBadgeMini({
value,
enumValues,
enumColors,
}: {
value: string;
enumValues?: string[];
enumColors?: string[];
}) {
const idx = enumValues?.indexOf(value) ?? -1;
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
return (
<span
className="inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium"
style={{
background: `${color}20`,
color: color,
}}
>
{value}
</span>
);
}
// --- Kanban Board ---
export function ObjectKanban({
objectName,
fields,
entries,
statuses,
members,
}: ObjectKanbanProps) {
// Find the grouping field: prefer a "Status" enum field, fallback to first enum
const groupField = useMemo(() => {
const statusField = fields.find(
(f) =>
f.type === "enum" &&
f.name.toLowerCase().includes("status"),
);
if (statusField) {return statusField;}
return fields.find((f) => f.type === "enum") ?? null;
}, [fields]);
// Determine columns: from statuses table, or from enum_values, or from unique values
const columns = useMemo(() => {
if (statuses.length > 0) {
return statuses.map((s) => ({
name: s.name,
color: s.color ?? "#94a3b8",
}));
}
if (groupField?.enum_values) {
return groupField.enum_values.map((v, i) => ({
name: v,
color: groupField.enum_colors?.[i] ?? "#94a3b8",
}));
}
// Fallback: derive from data
const unique = new Set<string>();
for (const e of entries) {
const val = groupField ? e[groupField.name] : undefined;
if (val) {unique.add(String(val));}
}
return Array.from(unique).map((v) => ({ name: v, color: "#94a3b8" }));
}, [statuses, groupField, entries]);
// Group entries by column
const grouped = useMemo(() => {
const groups: Record<string, Record<string, unknown>[]> = {};
for (const col of columns) {groups[col.name] = [];}
// Ungrouped bucket
groups["_ungrouped"] = [];
for (const entry of entries) {
const val = groupField ? String(entry[groupField.name] ?? "") : "";
if (groups[val]) {
groups[val].push(entry);
} else {
groups["_ungrouped"].push(entry);
}
}
return groups;
}, [columns, entries, groupField]);
// Non-grouping fields for cards
const cardFields = fields.filter((f) => f !== groupField);
if (!groupField) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No enum field found for kanban grouping in{" "}
<span className="font-medium" style={{ color: "var(--color-text)" }}>
{objectName}
</span>
</p>
</div>
);
}
return (
<div className="flex gap-4 overflow-x-auto pb-4 px-1" style={{ minHeight: "400px" }}>
{columns.map((col) => {
const items = grouped[col.name] ?? [];
return (
<div
key={col.name}
className="flex-shrink-0 flex flex-col rounded-xl"
style={{
width: "280px",
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
}}
>
{/* Column header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: col.color }}
/>
<span
className="text-sm font-medium flex-1"
style={{ color: "var(--color-text)" }}
>
{col.name}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
}}
>
{items.length}
</span>
</div>
{/* Cards */}
<div className="flex-1 overflow-y-auto p-2">
{items.length === 0 ? (
<div
className="flex items-center justify-center py-8 rounded-lg border border-dashed text-xs"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-muted)",
}}
>
No entries
</div>
) : (
items.map((entry, idx) => (
<KanbanCard
key={String(entry.entry_id ?? idx)}
entry={entry}
fields={cardFields}
members={members}
/>
))
)}
</div>
</div>
);
})}
{/* Ungrouped entries */}
{grouped["_ungrouped"]?.length > 0 && (
<div
className="flex-shrink-0 flex flex-col rounded-xl"
style={{
width: "280px",
background: "var(--color-bg)",
border: "1px dashed var(--color-border)",
}}
>
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
<span className="text-sm font-medium" style={{ color: "var(--color-text-muted)" }}>
Ungrouped
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
}}
>
{grouped["_ungrouped"].length}
</span>
</div>
<div className="flex-1 overflow-y-auto p-2">
{grouped["_ungrouped"].map((entry, idx) => (
<KanbanCard
key={String(entry.entry_id ?? idx)}
entry={entry}
fields={cardFields}
members={members}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,275 @@
"use client";
import { useState, useMemo } from "react";
type Field = {
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
enum_multiple?: boolean;
sort_order?: number;
};
type ObjectTableProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
members?: Array<{ id: string; name: string }>;
};
// --- Sort helpers ---
type SortState = {
column: string;
direction: "asc" | "desc";
} | null;
function SortIcon({ active, direction }: { active: boolean; direction: "asc" | "desc" }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: active ? 1 : 0.3 }}
>
{direction === "asc" ? (
<path d="m5 12 7-7 7 7" />
) : (
<path d="m19 12-7 7-7-7" />
)}
</svg>
);
}
// --- Cell Renderers ---
function EnumBadge({
value,
enumValues,
enumColors,
}: {
value: string;
enumValues?: string[];
enumColors?: string[];
}) {
const idx = enumValues?.indexOf(value) ?? -1;
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
return (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`,
}}
>
{value}
</span>
);
}
function BooleanCell({ value }: { value: unknown }) {
const isTrue =
value === true || value === "true" || value === "1" || value === "yes";
return (
<span style={{ color: isTrue ? "#22c55e" : "var(--color-text-muted)" }}>
{isTrue ? "Yes" : "No"}
</span>
);
}
function UserCell({
value,
members,
}: {
value: unknown;
members?: Array<{ id: string; name: string }>;
}) {
const memberId = String(value);
const member = members?.find((m) => m.id === memberId);
return (
<span className="flex items-center gap-1.5">
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-medium flex-shrink-0"
style={{
background: "var(--color-accent)",
color: "white",
}}
>
{(member?.name ?? memberId).charAt(0).toUpperCase()}
</span>
<span className="truncate">{member?.name ?? memberId}</span>
</span>
);
}
function CellValue({
value,
field,
members,
}: {
value: unknown;
field: Field;
members?: Array<{ id: string; name: string }>;
}) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
--
</span>
);
}
switch (field.type) {
case "enum":
return (
<EnumBadge
value={String(value)}
enumValues={field.enum_values}
enumColors={field.enum_colors}
/>
);
case "boolean":
return <BooleanCell value={value} />;
case "user":
return <UserCell value={value} members={members} />;
case "email":
return (
<a
href={`mailto:${value}`}
className="underline underline-offset-2"
style={{ color: "#60a5fa" }}
>
{String(value)}
</a>
);
case "date":
return <span>{String(value)}</span>;
case "number":
return <span className="tabular-nums">{String(value)}</span>;
default:
return <span className="truncate block max-w-[300px]">{String(value)}</span>;
}
}
// --- Table Component ---
export function ObjectTable({
objectName,
fields,
entries,
members,
}: ObjectTableProps) {
const [sort, setSort] = useState<SortState>(null);
const handleSort = (column: string) => {
setSort((prev) => {
if (prev?.column === column) {
return prev.direction === "asc"
? { column, direction: "desc" }
: null;
}
return { column, direction: "asc" };
});
};
const sortedEntries = useMemo(() => {
if (!sort) {return entries;}
return [...entries].toSorted((a, b) => {
const aVal = String(a[sort.column] ?? "");
const bVal = String(b[sort.column] ?? "");
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
return sort.direction === "asc" ? cmp : -cmp;
});
}, [entries, sort]);
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
</svg>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No entries in <span className="font-medium" style={{ color: "var(--color-text)" }}>{objectName}</span>
</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
<tr>
{fields.map((field) => (
<th
key={field.id}
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider cursor-pointer select-none whitespace-nowrap border-b"
style={{
color: "var(--color-text-muted)",
borderColor: "var(--color-border)",
background: "var(--color-surface)",
position: "sticky",
top: 0,
zIndex: 1,
}}
onClick={() => handleSort(field.name)}
>
<span className="flex items-center gap-1">
{field.name}
<SortIcon
active={sort?.column === field.name}
direction={sort?.column === field.name ? sort.direction : "asc"}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sortedEntries.map((entry, idx) => (
<tr
key={String(entry.entry_id ?? idx)}
className="transition-colors duration-75"
style={{
background:
idx % 2 === 0 ? "transparent" : "var(--color-surface)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background =
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
}}
>
{fields.map((field) => (
<td
key={field.id}
className="px-3 py-2 border-b whitespace-nowrap"
style={{ borderColor: "var(--color-border)" }}
>
<CellValue
value={entry[field.name]}
field={field}
members={members}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,118 @@
"use client";
import { KnowledgeTree, type TreeNode } from "./knowledge-tree";
type WorkspaceSidebarProps = {
tree: TreeNode[];
activePath: string | null;
onSelect: (node: TreeNode) => void;
orgName?: string;
loading?: boolean;
};
function WorkspaceLogo() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
}
function BackIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
</svg>
);
}
export function WorkspaceSidebar({
tree,
activePath,
onSelect,
orgName,
loading,
}: WorkspaceSidebarProps) {
return (
<aside
className="flex flex-col h-screen border-r flex-shrink-0"
style={{
width: "260px",
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
{/* Header */}
<div
className="flex items-center gap-2.5 px-4 py-3 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<span style={{ color: "var(--color-accent)" }}>
<WorkspaceLogo />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{orgName || "Workspace"}
</div>
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Dench CRM
</div>
</div>
</div>
{/* Section label */}
<div
className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
Knowledge
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : (
<KnowledgeTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
/>
)}
</div>
{/* Footer */}
<div
className="px-3 py-2.5 border-t"
style={{ borderColor: "var(--color-border)" }}
>
<a
href="/"
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<BackIcon />
Back to Chat
</a>
</div>
</aside>
);
}

View File

@ -37,3 +37,188 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* ========================================
Workspace Prose (markdown document view)
======================================== */
.workspace-prose {
color: var(--color-text);
line-height: 1.75;
font-size: 0.9375rem;
}
.workspace-prose h1,
.workspace-prose h2,
.workspace-prose h3,
.workspace-prose h4,
.workspace-prose h5,
.workspace-prose h6 {
color: var(--color-text);
font-weight: 600;
margin-top: 2em;
margin-bottom: 0.75em;
line-height: 1.3;
}
.workspace-prose h1 {
font-size: 1.75rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.5rem;
}
.workspace-prose h2 {
font-size: 1.375rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.4rem;
}
.workspace-prose h3 { font-size: 1.125rem; }
.workspace-prose h4 { font-size: 1rem; }
.workspace-prose p {
margin-bottom: 1em;
}
.workspace-prose a {
color: #60a5fa;
text-decoration: underline;
text-underline-offset: 2px;
}
.workspace-prose a:hover {
color: #93bbfd;
}
.workspace-prose strong {
color: var(--color-text);
font-weight: 600;
}
.workspace-prose em {
font-style: italic;
}
.workspace-prose ul,
.workspace-prose ol {
margin-bottom: 1em;
padding-left: 1.5em;
}
.workspace-prose ul {
list-style-type: disc;
}
.workspace-prose ol {
list-style-type: decimal;
}
.workspace-prose li {
margin-bottom: 0.25em;
}
.workspace-prose li > ul,
.workspace-prose li > ol {
margin-top: 0.25em;
margin-bottom: 0;
}
.workspace-prose blockquote {
border-left: 3px solid var(--color-accent);
padding: 0.5em 1em;
margin: 1em 0;
background: var(--color-surface);
border-radius: 0 0.5rem 0.5rem 0;
color: var(--color-text-muted);
}
.workspace-prose code {
font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace;
font-size: 0.85em;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15em 0.35em;
}
.workspace-prose pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1em;
overflow-x: auto;
margin: 1em 0;
}
.workspace-prose pre code {
background: transparent;
border: none;
padding: 0;
font-size: 0.85em;
line-height: 1.6;
}
.workspace-prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2em 0;
}
.workspace-prose table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 0.875rem;
}
.workspace-prose th {
text-align: left;
font-weight: 600;
padding: 0.6em 0.75em;
border-bottom: 2px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.workspace-prose td {
padding: 0.5em 0.75em;
border-bottom: 1px solid var(--color-border);
}
.workspace-prose tr:hover td {
background: var(--color-surface-hover);
}
.workspace-prose img {
max-width: 100%;
border-radius: 0.5rem;
margin: 1em 0;
}
/* Task list (GFM) */
.workspace-prose input[type="checkbox"] {
appearance: none;
width: 1em;
height: 1em;
border: 1.5px solid var(--color-border);
border-radius: 0.2em;
vertical-align: middle;
margin-right: 0.4em;
position: relative;
}
.workspace-prose input[type="checkbox"]:checked {
background: var(--color-accent);
border-color: var(--color-accent);
}
.workspace-prose input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 3px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}

View File

@ -0,0 +1,650 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
import { type TreeNode } from "../components/workspace/knowledge-tree";
import { ObjectTable } from "../components/workspace/object-table";
import { ObjectKanban } from "../components/workspace/object-kanban";
import { DocumentView } from "../components/workspace/document-view";
import { FileViewer } from "../components/workspace/file-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { EmptyState } from "../components/workspace/empty-state";
// --- Types ---
type WorkspaceContext = {
exists: boolean;
organization?: { id?: string; name?: string; slug?: string };
members?: Array<{ id: string; name: string; email: string; role: string }>;
};
type ObjectData = {
object: {
id: string;
name: string;
description?: string;
icon?: string;
default_view?: string;
};
fields: Array<{
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
enum_multiple?: boolean;
sort_order?: number;
}>;
statuses: Array<{
id: string;
name: string;
color?: string;
sort_order?: number;
}>;
entries: Record<string, unknown>[];
};
type FileData = {
content: string;
type: "markdown" | "yaml" | "text";
};
type ContentState =
| { kind: "none" }
| { kind: "loading" }
| { kind: "object"; data: ObjectData }
| { kind: "document"; data: FileData; title: string }
| { kind: "file"; data: FileData; filename: string }
| { kind: "directory"; node: TreeNode };
// --- Helpers ---
/** Find a node in the tree by path. */
function findNode(
tree: TreeNode[],
path: string,
): TreeNode | null {
for (const node of tree) {
if (node.path === path) {return node;}
if (node.children) {
const found = findNode(node.children, path);
if (found) {return found;}
}
}
return null;
}
/** Extract the object name from a tree path (last segment). */
function objectNameFromPath(path: string): string {
const segments = path.split("/");
return segments[segments.length - 1];
}
// --- Main Page ---
export default function WorkspacePage() {
const searchParams = useSearchParams();
const initialPathHandled = useRef(false);
const [tree, setTree] = useState<TreeNode[]>([]);
const [context, setContext] = useState<WorkspaceContext | null>(null);
const [activePath, setActivePath] = useState<string | null>(null);
const [content, setContent] = useState<ContentState>({ kind: "none" });
const [treeLoading, setTreeLoading] = useState(true);
const [workspaceExists, setWorkspaceExists] = useState(true);
// Fetch tree and context on mount
useEffect(() => {
let cancelled = false;
async function load() {
setTreeLoading(true);
try {
const [treeRes, ctxRes] = await Promise.all([
fetch("/api/workspace/tree"),
fetch("/api/workspace/context"),
]);
const treeData = await treeRes.json();
const ctxData = await ctxRes.json();
if (cancelled) {return;}
setTree(treeData.tree ?? []);
setWorkspaceExists(treeData.exists ?? false);
setContext(ctxData);
} catch {
if (!cancelled) {
setTree([]);
setWorkspaceExists(false);
}
} finally {
if (!cancelled) {setTreeLoading(false);}
}
}
load();
return () => {
cancelled = true;
};
}, []);
// Load content when path changes
const loadContent = useCallback(
async (node: TreeNode) => {
setActivePath(node.path);
setContent({ kind: "loading" });
try {
if (node.type === "object") {
const name = objectNameFromPath(node.path);
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
if (!res.ok) {
setContent({ kind: "none" });
return;
}
const data: ObjectData = await res.json();
setContent({ kind: "object", data });
} else if (node.type === "document") {
const res = await fetch(
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
);
if (!res.ok) {
setContent({ kind: "none" });
return;
}
const data: FileData = await res.json();
setContent({
kind: "document",
data,
title: node.name.replace(/\.md$/, ""),
});
} else if (node.type === "file") {
const res = await fetch(
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
);
if (!res.ok) {
setContent({ kind: "none" });
return;
}
const data: FileData = await res.json();
setContent({ kind: "file", data, filename: node.name });
} else if (node.type === "folder") {
setContent({ kind: "directory", node });
}
} catch {
setContent({ kind: "none" });
}
},
[],
);
const handleNodeSelect = useCallback(
(node: TreeNode) => {
loadContent(node);
},
[loadContent],
);
// Auto-navigate to path from URL query param after tree loads
useEffect(() => {
if (initialPathHandled.current || treeLoading || tree.length === 0) {return;}
const pathParam = searchParams.get("path");
if (pathParam) {
const node = findNode(tree, pathParam);
if (node) {
initialPathHandled.current = true;
loadContent(node);
}
}
}, [tree, treeLoading, searchParams, loadContent]);
const handleBreadcrumbNavigate = useCallback(
(path: string) => {
if (!path) {
setActivePath(null);
setContent({ kind: "none" });
return;
}
const node = findNode(tree, path);
if (node) {
loadContent(node);
}
},
[tree, loadContent],
);
return (
<div className="flex h-screen" style={{ background: "var(--color-bg)" }}>
{/* Sidebar */}
<WorkspaceSidebar
tree={tree}
activePath={activePath}
onSelect={handleNodeSelect}
orgName={context?.organization?.name}
loading={treeLoading}
/>
{/* Main content */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Top bar with breadcrumbs */}
{activePath && (
<div
className="px-6 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<Breadcrumbs
path={activePath}
onNavigate={handleBreadcrumbNavigate}
/>
</div>
)}
{/* Content area */}
<div className="flex-1 overflow-y-auto">
<ContentRenderer
content={content}
workspaceExists={workspaceExists}
tree={tree}
members={context?.members}
onNodeSelect={handleNodeSelect}
/>
</div>
</main>
</div>
);
}
// --- Content Renderer ---
function ContentRenderer({
content,
workspaceExists,
tree,
members,
onNodeSelect,
}: {
content: ContentState;
workspaceExists: boolean;
tree: TreeNode[];
members?: Array<{ id: string; name: string; email: string; role: string }>;
onNodeSelect: (node: TreeNode) => void;
}) {
switch (content.kind) {
case "loading":
return (
<div className="flex items-center justify-center h-full">
<div
className="w-6 h-6 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
);
case "object":
return (
<div className="p-6">
{/* Object header */}
<div className="mb-6">
<h1
className="text-2xl font-bold capitalize"
style={{ color: "var(--color-text)" }}
>
{content.data.object.name}
</h1>
{content.data.object.description && (
<p
className="text-sm mt-1"
style={{ color: "var(--color-text-muted)" }}
>
{content.data.object.description}
</p>
)}
<div className="flex items-center gap-3 mt-3">
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{content.data.entries.length} entries
</span>
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{content.data.fields.length} fields
</span>
</div>
</div>
{/* Table or Kanban */}
{content.data.object.default_view === "kanban" ? (
<ObjectKanban
objectName={content.data.object.name}
fields={content.data.fields}
entries={content.data.entries}
statuses={content.data.statuses}
members={members}
/>
) : (
<ObjectTable
objectName={content.data.object.name}
fields={content.data.fields}
entries={content.data.entries}
members={members}
/>
)}
</div>
);
case "document":
return (
<DocumentView
content={content.data.content}
title={content.title}
/>
);
case "file":
return (
<FileViewer
content={content.data.content}
filename={content.filename}
type={content.data.type === "yaml" ? "yaml" : "text"}
/>
);
case "directory":
return (
<DirectoryListing
node={content.node}
onNodeSelect={onNodeSelect}
/>
);
case "none":
default:
if (tree.length === 0) {
return <EmptyState workspaceExists={workspaceExists} />;
}
return <WelcomeView tree={tree} onNodeSelect={onNodeSelect} />;
}
}
// --- Directory Listing ---
function DirectoryListing({
node,
onNodeSelect,
}: {
node: TreeNode;
onNodeSelect: (node: TreeNode) => void;
}) {
const children = node.children ?? [];
return (
<div className="p-6 max-w-4xl mx-auto">
<h1
className="text-2xl font-bold mb-1 capitalize"
style={{ color: "var(--color-text)" }}
>
{node.name}
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
{children.length} items
</p>
{children.length === 0 ? (
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
This folder is empty.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{children.map((child) => (
<button
type="button"
key={child.path}
onClick={() => onNodeSelect(child)}
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-text-muted)";
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-border)";
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
}}
>
<span
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background:
child.type === "object"
? "rgba(232, 93, 58, 0.1)"
: child.type === "document"
? "rgba(96, 165, 250, 0.1)"
: "var(--color-surface-hover)",
color:
child.type === "object"
? "var(--color-accent)"
: child.type === "document"
? "#60a5fa"
: "var(--color-text-muted)",
}}
>
<NodeTypeIcon type={child.type} />
</span>
<div className="min-w-0 flex-1">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{child.name.replace(/\.md$/, "")}
</div>
<div className="text-xs capitalize" style={{ color: "var(--color-text-muted)" }}>
{child.type}
{child.children ? ` (${child.children.length})` : ""}
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
// --- Welcome View (no selection) ---
function WelcomeView({
tree,
onNodeSelect,
}: {
tree: TreeNode[];
onNodeSelect: (node: TreeNode) => void;
}) {
// Collect all objects and documents for quick access
const objects: TreeNode[] = [];
const documents: TreeNode[] = [];
function collect(nodes: TreeNode[]) {
for (const n of nodes) {
if (n.type === "object") {objects.push(n);}
else if (n.type === "document") {documents.push(n);}
if (n.children) {collect(n.children);}
}
}
collect(tree);
return (
<div className="p-8 max-w-4xl mx-auto">
<h1
className="text-2xl font-bold mb-2"
style={{ color: "var(--color-text)" }}
>
Workspace
</h1>
<p className="text-sm mb-8" style={{ color: "var(--color-text-muted)" }}>
Select an item from the sidebar, or browse the sections below.
</p>
{/* Objects section */}
{objects.length > 0 && (
<div className="mb-8">
<h2
className="text-sm font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--color-text-muted)" }}
>
Objects
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{objects.map((obj) => (
<button
type="button"
key={obj.path}
onClick={() => onNodeSelect(obj)}
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-accent)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-border)";
}}
>
<span
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "rgba(232, 93, 58, 0.1)",
color: "var(--color-accent)",
}}
>
<NodeTypeIcon type="object" />
</span>
<div className="min-w-0">
<div
className="text-sm font-medium capitalize truncate"
style={{ color: "var(--color-text)" }}
>
{obj.name}
</div>
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{obj.defaultView === "kanban" ? "Kanban board" : "Table view"}
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* Documents section */}
{documents.length > 0 && (
<div>
<h2
className="text-sm font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--color-text-muted)" }}
>
Documents
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{documents.map((doc) => (
<button
type="button"
key={doc.path}
onClick={() => onNodeSelect(doc)}
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "#60a5fa";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-border)";
}}
>
<span
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "rgba(96, 165, 250, 0.1)",
color: "#60a5fa",
}}
>
<NodeTypeIcon type="document" />
</span>
<div className="min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{doc.name.replace(/\.md$/, "")}
</div>
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Document
</div>
</div>
</button>
))}
</div>
</div>
)}
</div>
);
}
// --- Shared icon for node types ---
function NodeTypeIcon({ type }: { type: string }) {
switch (type) {
case "object":
return (
<svg width="20" height="20" 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>
);
case "document":
return (
<svg width="20" height="20" 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>
);
case "folder":
return (
<svg width="20" height="20" 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>
);
default:
return (
<svg width="20" height="20" 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>
);
}
}

188
apps/web/lib/workspace.ts Normal file
View File

@ -0,0 +1,188 @@
import { existsSync, readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve, normalize } from "node:path";
import { homedir } from "node:os";
/**
* Resolve the dench workspace directory, checking in order:
* 1. DENCH_WORKSPACE env var
* 2. ~/.openclaw/workspace/dench/
* 3. ./dench/ (relative to process cwd)
*/
export function resolveDenchRoot(): string | null {
const candidates = [
process.env.DENCH_WORKSPACE,
join(homedir(), ".openclaw", "workspace", "dench"),
join(process.cwd(), "dench"),
].filter(Boolean) as string[];
for (const dir of candidates) {
if (existsSync(dir)) {return dir;}
}
return null;
}
/** Path to the DuckDB database file, or null if workspace doesn't exist. */
export function duckdbPath(): string | null {
const root = resolveDenchRoot();
if (!root) {return null;}
const dbPath = join(root, "workspace.duckdb");
return existsSync(dbPath) ? dbPath : null;
}
/**
* Resolve the duckdb CLI binary path.
* Checks common locations since the Next.js server may have a minimal PATH.
*/
function resolveDuckdbBin(): string | null {
const home = homedir();
const candidates = [
// User-local installs
join(home, ".duckdb", "cli", "latest", "duckdb"),
join(home, ".local", "bin", "duckdb"),
// Homebrew
"/opt/homebrew/bin/duckdb",
"/usr/local/bin/duckdb",
// System
"/usr/bin/duckdb",
];
for (const bin of candidates) {
if (existsSync(bin)) {return bin;}
}
// Fallback: try bare `duckdb` and hope it's in PATH
try {
execSync("which duckdb", { encoding: "utf-8", timeout: 2000 });
return "duckdb";
} catch {
return null;
}
}
/**
* Execute a DuckDB query and return parsed JSON rows.
* Uses the duckdb CLI with -json output format.
*/
export function duckdbQuery<T = Record<string, unknown>>(
sql: string,
): T[] {
const db = duckdbPath();
if (!db) {return [];}
const bin = resolveDuckdbBin();
if (!bin) {return [];}
try {
// Escape single quotes in SQL for shell safety
const escapedSql = sql.replace(/'/g, "'\\''");
const result = execSync(`'${bin}' -json '${db}' '${escapedSql}'`, {
encoding: "utf-8",
timeout: 10_000,
maxBuffer: 10 * 1024 * 1024, // 10 MB
shell: "/bin/sh",
});
const trimmed = result.trim();
if (!trimmed || trimmed === "[]") {return [];}
return JSON.parse(trimmed) as T[];
} catch {
return [];
}
}
/**
* Validate and resolve a path within the dench workspace.
* Prevents path traversal by ensuring the resolved path stays within root.
* Returns the absolute path or null if invalid/nonexistent.
*/
export function safeResolvePath(
relativePath: string,
): string | null {
const root = resolveDenchRoot();
if (!root) {return null;}
// Reject obvious traversal attempts
const normalized = normalize(relativePath);
if (normalized.startsWith("..") || normalized.includes("/../")) {return null;}
const absolute = resolve(root, normalized);
// Ensure the resolved path is still within the workspace root
if (!absolute.startsWith(resolve(root))) {return null;}
if (!existsSync(absolute)) {return null;}
return absolute;
}
/**
* Lightweight YAML frontmatter / simple-value parser.
* Handles flat key: value pairs and simple nested structures.
* Good enough for .object.yaml and workspace_context.yaml top-level fields.
*/
export function parseSimpleYaml(
content: string,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const lines = content.split("\n");
for (const line of lines) {
// Skip comments and empty lines
if (line.trim().startsWith("#") || !line.trim()) {continue;}
// Match top-level key: value
const match = line.match(/^(\w[\w_-]*)\s*:\s*(.+)/);
if (match) {
const key = match[1];
let value: unknown = match[2].trim();
// Strip quotes
if (
typeof value === "string" &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = (value as string).slice(1, -1);
}
// Parse booleans and numbers
if (value === "true") {value = true;}
else if (value === "false") {value = false;}
else if (value === "null") {value = null;}
else if (
typeof value === "string" &&
/^-?\d+(\.\d+)?$/.test(value)
) {
value = Number(value);
}
result[key] = value;
}
}
return result;
}
/**
* Read a file from the workspace safely.
* Returns content and detected type, or null if not found.
*/
export function readWorkspaceFile(
relativePath: string,
): { content: string; type: "markdown" | "yaml" | "text" } | null {
const absolute = safeResolvePath(relativePath);
if (!absolute) {return null;}
try {
const content = readFileSync(absolute, "utf-8");
const ext = relativePath.split(".").pop()?.toLowerCase();
let type: "markdown" | "yaml" | "text" = "text";
if (ext === "md" || ext === "mdx") {type = "markdown";}
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
return { content, type };
} catch {
return null;
}
}

View File

@ -4,6 +4,9 @@ const nextConfig: NextConfig = {
// Allow long-running API routes for agent streaming
serverExternalPackages: [],
// Transpile ESM-only packages so webpack can bundle them
transpilePackages: ["react-markdown", "remark-gfm"],
// Ensure Node.js built-ins work correctly
webpack: (config, { isServer }) => {
if (isServer) {

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,9 @@
"ai": "^6.0.73",
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",

File diff suppressed because one or more lines are too long