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

152 lines
4.5 KiB
TypeScript

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join, dirname, resolve, normalize } from "node:path";
import { homedir } from "node:os";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* Resolve a virtual path (~skills/... or ~memories/...) to an absolute filesystem path.
* Returns null if the path is invalid or tries to escape.
*/
function resolveVirtualPath(virtualPath: string): string | null {
const home = homedir();
if (virtualPath.startsWith("~skills/")) {
// ~skills/<skillName>/SKILL.md
const rest = virtualPath.slice("~skills/".length);
// Validate: must be <name>/SKILL.md
const parts = rest.split("/");
if (parts.length !== 2 || parts[1] !== "SKILL.md" || !parts[0]) {
return null;
}
const skillName = parts[0];
// Prevent path traversal
if (skillName.includes("..") || skillName.includes("/")) {
return null;
}
// Check workspace skills first, then managed skills
const candidates = [
join(home, ".openclaw", "workspace", "skills", skillName, "SKILL.md"),
join(home, ".openclaw", "skills", skillName, "SKILL.md"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
// Default to workspace skills dir for new files
return candidates[0];
}
if (virtualPath.startsWith("~memories/")) {
const rest = virtualPath.slice("~memories/".length);
// Prevent path traversal
if (rest.includes("..") || rest.includes("/")) {
return null;
}
const workspaceDir = join(home, ".openclaw", "workspace");
if (rest === "MEMORY.md") {
// Check both casing
for (const filename of ["MEMORY.md", "memory.md"]) {
const candidate = join(workspaceDir, filename);
if (existsSync(candidate)) {
return candidate;
}
}
// Default to MEMORY.md for new files
return join(workspaceDir, "MEMORY.md");
}
// Daily log: must be a .md file in the memory/ subdirectory
if (!rest.endsWith(".md")) {
return null;
}
return join(workspaceDir, "memory", rest);
}
return null;
}
/**
* Double-check that the resolved path stays within expected directories.
*/
function isSafePath(absPath: string): boolean {
const home = homedir();
const normalized = normalize(resolve(absPath));
const allowed = [
normalize(join(home, ".openclaw", "skills")),
normalize(join(home, ".openclaw", "workspace", "skills")),
normalize(join(home, ".openclaw", "workspace")),
];
return allowed.some((dir) => normalized.startsWith(dir));
}
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 absPath = resolveVirtualPath(path);
if (!absPath || !isSafePath(absPath)) {
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
}
if (!existsSync(absPath)) {
return Response.json({ error: "File not found" }, { status: 404 });
}
try {
const content = readFileSync(absPath, "utf-8");
const ext = absPath.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 Response.json({ content, type });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Read failed" },
{ status: 500 },
);
}
}
export async function POST(req: Request) {
let body: { path?: string; content?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { path: virtualPath, content } = body;
if (!virtualPath || typeof virtualPath !== "string" || typeof content !== "string") {
return Response.json(
{ error: "Missing 'path' and 'content' fields" },
{ status: 400 },
);
}
const absPath = resolveVirtualPath(virtualPath);
if (!absPath || !isSafePath(absPath)) {
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
}
try {
mkdirSync(dirname(absPath), { recursive: true });
writeFileSync(absPath, content, "utf-8");
return Response.json({ ok: true, path: virtualPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Write failed" },
{ status: 500 },
);
}
}