kumarabhirup fe15ab44dc
Dench workspace: virtual folders (Skills, Memories, Chats), landing page, and unified workspace layout
Add virtual folder system that surfaces Skills, Memories, and Chat sessions
in the workspace sidebar alongside real dench files. Rearchitect the home
page into a landing hub and move the ChatPanel into the workspace as the
default view.

New API route — virtual-file:
- apps/web/app/api/workspace/virtual-file/route.ts: new GET/POST API that
  resolves virtual paths (~skills/*, ~memories/*) to absolute filesystem
  paths in ~/.openclaw/skills/ d ~/.openclaw/workspace/. Includes path
  traversal protection and directory allowlisting.

Tree API — virtual folder builders:
- apps/web/app/api/workspace/tree/route.ts: add `virtual` field to TreeNode
  type. Add ildSkillsVirtualFolder() scanning ~/.openclaw/skills/ and
  ~/.openclaw/workspace/skills/ with SKILL.md frontmatter parsing (name +
  emoji). Add buildMemoriesVirtualFolder() scanning MEMORY.md and daily
  logs from ~/.openclaw/workspace/memory/. Virtual folders are appended
  after real workspace entries and are also returned when no dench root
  exists.

File manager tree — virtual node awareness:
- apps/web/app/components/workspace/file-manager-tree.tsx: add isVirtualNode()
  helper and RESERVED_FOLDER_NAMES set (Chats, Skills, Memories). Virtual
  nodes show a lock badge, disable drag-and-drop, block rename/delete, and
  reject reserved names during create/rename. Add ChatBubbleIcon for ~chats/
  paths.

Markdown editor — virtual path routing:
- apps/web/app/components/workspace/markdown-editor.tsx: save to
  /api/workspace/virtual-file for paths starting with ~ instead of the
  regular /api/workspace/file endpoint.

Home page redesign:
- apps/web/app/page.tsx: replace the chat-first layout (SidebarhatPanel)
  with a branded landing page showing the OpenClaw Dench heading, tagline,
  and a single "Open Workspace" CTA linking to /workspace.

Workspace page — unified layout with integrated chat:
- apps/web/app/workspace/page.tsx: ChatPanel is now the default main view
  when no file is selected. Add session fetching from /api/web-sessions and
  build an enhanced tree with a virtual "Chats" folder listing all sessions.
  Clicking ~chats/<id> loads the session; clicking ~chats starts a new one.
  Add isVirtualPath()/fileApiUrl() helpers for virtual file reads. Add a
  "Back to chat" button in the top bar alongside the chat sidebar toggle.

Sidebar + empty-state cosmetic updates:
- apps/web/app/components/workspace/workspace-sidebar.tsx: rename BackIcon
  to HomeIcon (house SVG), change label from "Back to Chat" to "Home".
- apps/web/app/components/workspace/empty-state.tsx: update link text from
  "Back to Chat" to "Back to Home".
2026-02-11 22:09:59 -08:00

334 lines
9.7 KiB
TypeScript

import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery, isDatabaseFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export type TreeNode = {
name: string;
path: string; // relative to dench/ (or ~skills/, ~memories/ for virtual nodes)
type: "object" | "document" | "folder" | "file" | "database" | "report";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
/** Virtual nodes live outside the dench workspace (e.g. Skills, Memories). */
virtual?: boolean;
};
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 isReport = entry.name.endsWith(".report.json");
const isDocument = ext === "md" || ext === "mdx";
const isDatabase = isDatabaseFile(entry.name);
nodes.push({
name: entry.name,
path: relPath,
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
});
}
}
return nodes;
}
/** Classify a top-level file's type. */
function classifyFileType(name: string): TreeNode["type"] {
if (name.endsWith(".report.json")) {return "report";}
if (isDatabaseFile(name)) {return "database";}
const ext = name.split(".").pop()?.toLowerCase();
if (ext === "md" || ext === "mdx") {return "document";}
return "file";
}
// --- Virtual folder builders ---
/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) {return {};}
const yaml = match[1];
const result: Record<string, string> = {};
for (const line of yaml.split("\n")) {
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
if (kv) {result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();}
}
return { name: result.name, emoji: result.emoji };
}
/** Build a virtual "Skills" folder from ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/. */
function buildSkillsVirtualFolder(): TreeNode | null {
const home = homedir();
const dirs = [
join(home, ".openclaw", "skills"),
join(home, ".openclaw", "workspace", "skills"),
];
const children: TreeNode[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
if (!existsSync(dir)) {continue;}
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || seen.has(entry.name)) {continue;}
const skillMdPath = join(dir, entry.name, "SKILL.md");
if (!existsSync(skillMdPath)) {continue;}
seen.add(entry.name);
let displayName = entry.name;
try {
const content = readFileSync(skillMdPath, "utf-8");
const meta = parseSkillFrontmatter(content);
if (meta.name) {displayName = meta.name;}
if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;}
} catch {
// skip
}
children.push({
name: displayName,
path: `~skills/${entry.name}/SKILL.md`,
type: "document",
virtual: true,
});
}
} catch {
// dir unreadable
}
}
if (children.length === 0) {return null;}
children.sort((a, b) => a.name.localeCompare(b.name));
return {
name: "Skills",
path: "~skills",
type: "folder",
virtual: true,
children,
};
}
/** Build a virtual "Memories" folder from ~/.openclaw/workspace/. */
function buildMemoriesVirtualFolder(): TreeNode | null {
const workspaceDir = join(homedir(), ".openclaw", "workspace");
const children: TreeNode[] = [];
// MEMORY.md
for (const filename of ["MEMORY.md", "memory.md"]) {
const memPath = join(workspaceDir, filename);
if (existsSync(memPath)) {
children.push({
name: "MEMORY.md",
path: `~memories/MEMORY.md`,
type: "document",
virtual: true,
});
break;
}
}
// Daily logs from memory/
const memoryDir = join(workspaceDir, "memory");
if (existsSync(memoryDir)) {
try {
const entries = readdirSync(memoryDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) {continue;}
children.push({
name: entry.name,
path: `~memories/${entry.name}`,
type: "document",
virtual: true,
});
}
} catch {
// dir unreadable
}
}
if (children.length === 0) {return null;}
// Sort: MEMORY.md first, then reverse chronological for daily logs
children.sort((a, b) => {
if (a.name === "MEMORY.md") {return -1;}
if (b.name === "MEMORY.md") {return 1;}
return b.name.localeCompare(a.name);
});
return {
name: "Memories",
path: "~memories",
type: "folder",
virtual: true,
children,
};
}
export async function GET() {
const root = resolveDenchRoot();
if (!root) {
// Even without a dench workspace, return virtual folders if they exist
const tree: TreeNode[] = [];
const skillsFolder = buildSkillsVirtualFolder();
if (skillsFolder) {tree.push(skillsFolder);}
const memoriesFolder = buildMemoriesVirtualFolder();
if (memoriesFolder) {tree.push(memoriesFolder);}
return Response.json({ tree, exists: false });
}
// Load objects from DuckDB for smart directory detection
const dbObjects = loadDbObjects();
const knowledgeDir = join(root, "knowledge");
const reportsDir = join(root, "reports");
const tree: TreeNode[] = [];
// Build knowledge tree (real files first)
if (existsSync(knowledgeDir)) {
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
}
// Build reports tree
if (existsSync(reportsDir)) {
const reportNodes = buildTree(reportsDir, "reports", dbObjects);
if (reportNodes.length > 0) {
tree.push({
name: "reports",
path: "reports",
type: "folder",
children: reportNodes,
});
}
}
// Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.)
try {
const topLevel = readdirSync(root, { withFileTypes: true });
for (const entry of topLevel) {
if (!entry.isFile()) {continue;}
if (entry.name.startsWith(".")) {continue;}
tree.push({
name: entry.name,
path: entry.name,
type: classifyFileType(entry.name),
});
}
} catch {
// skip if root unreadable
}
// Virtual folders go after all real files/folders
const skillsFolder = buildSkillsVirtualFolder();
if (skillsFolder) {tree.push(skillsFolder);}
const memoriesFolder = buildMemoriesVirtualFolder();
if (memoriesFolder) {tree.push(memoriesFolder);}
return Response.json({ tree, exists: true });
}