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".
This commit is contained in:
kumarabhirup 2026-02-11 22:09:59 -08:00
parent f4a882f3e3
commit fe15ab44dc
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
8 changed files with 614 additions and 120 deletions

View File

@ -1,5 +1,6 @@
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";
@ -7,11 +8,13 @@ export const runtime = "nodejs";
export type TreeNode = {
name: string;
path: string; // relative to dench/
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 = {
@ -141,10 +144,141 @@ function classifyFileType(name: string): TreeNode["type"] {
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) {
return Response.json({ tree: [], exists: false });
// 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
@ -154,7 +288,7 @@ export async function GET() {
const reportsDir = join(root, "reports");
const tree: TreeNode[] = [];
// Build knowledge tree
// Build knowledge tree (real files first)
if (existsSync(knowledgeDir)) {
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
}
@ -189,5 +323,11 @@ export async function GET() {
// 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 });
}

View File

@ -0,0 +1,151 @@
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 },
);
}
}

View File

@ -112,7 +112,7 @@ export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) {
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Back to Chat
Back to Home
</a>
</div>
);

View File

@ -26,8 +26,18 @@ export type TreeNode = {
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
/** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */
virtual?: boolean;
};
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
const RESERVED_FOLDER_NAMES = new Set(["Chats", "Skills", "Memories"]);
/** Check if a node (or any of its ancestors) is virtual. Paths starting with ~ are always virtual. */
function isVirtualNode(node: TreeNode): boolean {
return !!node.virtual || node.path.startsWith("~");
}
type FileManagerTreeProps = {
tree: TreeNode[];
activePath: string | null;
@ -113,6 +123,14 @@ function ReportIcon() {
);
}
function ChatBubbleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
function LockBadge() {
return (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
@ -131,6 +149,10 @@ function ChevronIcon({ open }: { open: boolean }) {
}
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
// Chat items use the chat bubble icon
if (node.path.startsWith("~chats/") || node.path === "~chats") {
return <ChatBubbleIcon />;
}
switch (node.type) {
case "object":
return node.defaultView === "kanban" ? <KanbanIcon /> : <TableIcon />;
@ -361,18 +383,20 @@ function DraggableNode({
const isSelected = selectedPath === node.path;
const isRenaming = renamingPath === node.path;
const isSysFile = isSystemFile(node.path);
const isVirtual = isVirtualNode(node);
const isProtected = isSysFile || isVirtual;
const isDragOver = dragOverPath === node.path && isExpandable;
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
id: `drag-${node.path}`,
data: { node },
disabled: isSysFile,
disabled: isProtected,
});
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `drop-${node.path}`,
data: { node },
disabled: !isExpandable,
disabled: !isExpandable || isVirtual,
});
const handleClick = useCallback(() => {
@ -384,10 +408,10 @@ function DraggableNode({
}, [node, isExpandable, onSelect, onNodeSelect, onToggleExpand]);
const handleDoubleClick = useCallback(() => {
if (!isSysFile) {
if (!isProtected) {
onStartRename(node.path);
}
}, [node.path, isSysFile, onStartRename]);
}, [node.path, isProtected, onStartRename]);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
@ -468,8 +492,8 @@ function DraggableNode({
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
)}
{/* Lock badge for system files */}
{isSysFile && !compact && (
{/* Lock badge for system/virtual files */}
{isProtected && !compact && (
<span className="flex-shrink-0 ml-1">
<LockBadge />
</span>
@ -684,7 +708,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
// Context menu handlers
const handleContextMenu = useCallback((e: React.MouseEvent, node: TreeNode) => {
const isSys = isSystemFile(node.path);
const isSys = isSystemFile(node.path) || isVirtualNode(node);
const isFolder = node.type === "folder" || node.type === "object";
setCtxMenu({
x: e.clientX,
@ -771,6 +795,12 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
const handleCommitRename = useCallback(
async (newName: string) => {
if (!renamingPath) {return;}
// Block reserved folder names
if (RESERVED_FOLDER_NAMES.has(newName)) {
alert(`"${newName}" is a reserved name and cannot be used.`);
setRenamingPath(null);
return;
}
const result = await apiRename(renamingPath, newName);
setRenamingPath(null);
if (result.ok) {onRefresh();}
@ -794,6 +824,13 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
const handleNewItemSubmit = useCallback(
async (name: string) => {
if (!newItemPrompt || !name) {return;}
// Block reserved folder names
if (RESERVED_FOLDER_NAMES.has(name)) {
alert(`"${name}" is a reserved name and cannot be used.`);
return;
}
const fullPath = newItemPrompt.parentPath ? `${newItemPrompt.parentPath}/${name}` : name;
if (newItemPrompt.kind === "folder") {
@ -859,7 +896,8 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
case "Enter": {
e.preventDefault();
if (curNode) {
if (e.shiftKey || isSystemFile(curNode.path)) {
const curProtected = isSystemFile(curNode.path) || isVirtualNode(curNode);
if (e.shiftKey || curProtected) {
onSelect(curNode);
} else {
setRenamingPath(curNode.path);
@ -869,14 +907,14 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
}
case "F2": {
e.preventDefault();
if (curNode && !isSystemFile(curNode.path)) {
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
setRenamingPath(curNode.path);
}
break;
}
case "Backspace":
case "Delete": {
if (curNode && !isSystemFile(curNode.path)) {
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
e.preventDefault();
setConfirmDelete(curNode.path);
}

View File

@ -279,7 +279,11 @@ export function MarkdownEditor({
// Prepend preserved frontmatter so it isn't lost on save
const finalContent = frontmatterRef.current + bodyContent;
const res = await fetch("/api/workspace/file", {
// Virtual paths (~skills/*, ~memories/*) use the virtual-file API
const saveEndpoint = filePath.startsWith("~")
? "/api/workspace/virtual-file"
: "/api/workspace/file";
const res = await fetch(saveEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: finalContent }),

View File

@ -22,10 +22,11 @@ function WorkspaceLogo() {
);
}
function BackIcon() {
function HomeIcon() {
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" />
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
@ -73,7 +74,7 @@ export function WorkspaceSidebar({
Knowledge
</div>
{/* Tree */}
{/* Tree (includes real files + virtual Skills, Memories, Chats folders) */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
@ -112,8 +113,8 @@ export function WorkspaceSidebar({
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<BackIcon />
Back to Chat
<HomeIcon />
Home
</a>
</div>
</aside>

View File

@ -1,46 +1,92 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { ChatPanel, type ChatPanelHandle } from "./components/chat-panel";
import { Sidebar } from "./components/sidebar";
import Link from "next/link";
export default function Home() {
const chatRef = useRef<ChatPanelHandle>(null);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
const handleSessionSelect = useCallback(
(sessionId: string) => {
chatRef.current?.loadSession(sessionId);
},
[],
);
const handleNewSession = useCallback(() => {
chatRef.current?.newSession();
}, []);
const refreshSidebar = useCallback(() => {
setSidebarRefreshKey((k) => k + 1);
}, []);
return (
<div className="flex h-screen">
<Sidebar
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
activeSessionId={activeSessionId ?? undefined}
refreshKey={sidebarRefreshKey}
/>
<div
className="flex flex-col items-center justify-center min-h-screen px-6"
style={{ background: "var(--color-bg)" }}
>
{/* Logo / brand mark */}
<div
className="mb-6 w-16 h-16 rounded-2xl flex items-center justify-center"
style={{ background: "rgba(232, 93, 58, 0.12)" }}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-accent)" }}
>
<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>
{/* Main chat area */}
<main className="flex-1 flex flex-col min-w-0">
<ChatPanel
ref={chatRef}
onActiveSessionChange={setActiveSessionId}
onSessionsChange={refreshSidebar}
/>
</main>
{/* Heading */}
<h1
className="text-4xl font-bold tracking-tight mb-3 text-center"
style={{ color: "var(--color-text)" }}
>
OpenClaw Dench
</h1>
{/* Tagline */}
<p
className="text-lg mb-8 text-center max-w-md"
style={{ color: "var(--color-text-muted)" }}
>
Your AI workspace &mdash; chat, knowledge, skills, and memory in one place.
</p>
{/* CTA */}
<Link
href="/workspace"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-medium transition-colors"
style={{
background: "var(--color-accent)",
color: "#fff",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-accent-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-accent)";
}}
>
Open Workspace
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
{/* Subtle footer link */}
<p
className="mt-12 text-xs"
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
>
Powered by OpenClaw
</p>
</div>
);
}

View File

@ -13,7 +13,7 @@ import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { EmptyState } from "../components/workspace/empty-state";
import { ReportViewer } from "../components/charts/report-viewer";
import { ChatPanel } from "../components/chat-panel";
import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
import { useSearchIndex } from "@/lib/search-index";
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
@ -82,8 +82,28 @@ type ContentState =
| { kind: "report"; reportPath: string; filename: string }
| { kind: "directory"; node: TreeNode };
type WebSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
};
// --- Helpers ---
/** Detect virtual paths (skills, memories) that live outside the dench workspace. */
function isVirtualPath(path: string): boolean {
return path.startsWith("~");
}
/** Pick the right file API endpoint based on virtual vs real paths. */
function fileApiUrl(path: string): string {
return isVirtualPath(path)
? `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`
: `/api/workspace/file?path=${encodeURIComponent(path)}`;
}
/** Find a node in the tree by exact path. */
function findNode(
tree: TreeNode[],
@ -155,6 +175,9 @@ export default function WorkspacePage() {
const router = useRouter();
const initialPathHandled = useRef(false);
// Chat panel ref for session management
const chatRef = useRef<ChatPanelHandle>(null);
// Live-reactive tree via SSE watcher
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
@ -166,6 +189,11 @@ export default function WorkspacePage() {
const [content, setContent] = useState<ContentState>({ kind: "none" });
const [showChatSidebar, setShowChatSidebar] = useState(true);
// Chat session state
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [sessions, setSessions] = useState<WebSession[]>([]);
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
// Entry detail modal state
const [entryModal, setEntryModal] = useState<{
objectName: string;
@ -208,6 +236,25 @@ export default function WorkspacePage() {
return () => { cancelled = true; };
}, []);
// Fetch chat sessions
const fetchSessions = useCallback(async () => {
try {
const res = await fetch("/api/web-sessions");
const data = await res.json();
setSessions(data.sessions ?? []);
} catch {
// ignore
}
}, []);
useEffect(() => {
fetchSessions();
}, [fetchSessions, sidebarRefreshKey]);
const refreshSessions = useCallback(() => {
setSidebarRefreshKey((k) => k + 1);
}, []);
// Load content when path changes
const loadContent = useCallback(
async (node: TreeNode) => {
@ -225,9 +272,8 @@ export default function WorkspacePage() {
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)}`,
);
// Use virtual-file API for ~skills/ and ~memories/ paths
const res = await fetch(fileApiUrl(node.path));
if (!res.ok) {
setContent({ kind: "none" });
return;
@ -243,9 +289,7 @@ export default function WorkspacePage() {
} else if (node.type === "report") {
setContent({ kind: "report", reportPath: node.path, filename: node.name });
} else if (node.type === "file") {
const res = await fetch(
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
);
const res = await fetch(fileApiUrl(node.path));
if (!res.ok) {
setContent({ kind: "none" });
return;
@ -264,11 +308,49 @@ export default function WorkspacePage() {
const handleNodeSelect = useCallback(
(node: TreeNode) => {
// Intercept chat folder item clicks
if (node.path.startsWith("~chats/")) {
const sessionId = node.path.slice("~chats/".length);
setActivePath(null);
setContent({ kind: "none" });
setActiveSessionId(sessionId);
chatRef.current?.loadSession(sessionId);
router.replace("/workspace", { scroll: false });
return;
}
// Clicking the Chats folder itself opens a new chat
if (node.path === "~chats") {
setActivePath(null);
setContent({ kind: "none" });
chatRef.current?.newSession();
router.replace("/workspace", { scroll: false });
return;
}
loadContent(node);
},
[loadContent],
[loadContent, router],
);
// Build the enhanced tree: real tree + Chats virtual folder at the bottom
const enhancedTree = useMemo(() => {
const chatChildren: TreeNode[] = sessions.map((s) => ({
name: s.title || "Untitled chat",
path: `~chats/${s.id}`,
type: "file" as const,
virtual: true,
}));
const chatsFolder: TreeNode = {
name: "Chats",
path: "~chats",
type: "folder",
virtual: true,
children: chatChildren.length > 0 ? chatChildren : undefined,
};
return [...tree, chatsFolder];
}, [tree, sessions]);
// Sync URL bar when activePath changes
useEffect(() => {
const currentPath = searchParams.get("path");
@ -362,10 +444,6 @@ export default function WorkspacePage() {
[tree, loadContent],
);
/**
* Unified navigate handler for editor links.
* Handles both file/object paths and @entry/ links.
*/
/**
* Unified navigate handler for links in the editor and read mode.
* Handles /workspace?entry=..., /workspace?path=..., and legacy relative paths.
@ -429,12 +507,15 @@ export default function WorkspacePage() {
[handleEditorNavigate],
);
// Whether to show the main ChatPanel (no file/content selected)
const showMainChat = !activePath || content.kind === "none";
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="flex h-screen" style={{ background: "var(--color-bg)" }} onClick={handleContainerClick}>
{/* Sidebar */}
<WorkspaceSidebar
tree={tree}
tree={enhancedTree}
activePath={activePath}
onSelect={handleNodeSelect}
onRefresh={refreshTree}
@ -444,8 +525,8 @@ export default function WorkspacePage() {
{/* Main content */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Top bar with breadcrumbs */}
{activePath && (
{/* When a file is selected: show top bar with breadcrumbs */}
{activePath && content.kind !== "none" && (
<div
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
style={{ borderColor: "var(--color-border)" }}
@ -454,60 +535,93 @@ export default function WorkspacePage() {
path={activePath}
onNavigate={handleBreadcrumbNavigate}
/>
{/* Chat sidebar toggle */}
<button
type="button"
onClick={() => setShowChatSidebar((v) => !v)}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
style={{
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
}}
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
<div className="flex items-center gap-1">
{/* Back to chat button */}
<button
type="button"
onClick={() => {
setActivePath(null);
setContent({ kind: "none" });
router.replace("/workspace", { scroll: false });
}}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Back to chat"
>
<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>
</button>
{/* Chat sidebar toggle */}
<button
type="button"
onClick={() => setShowChatSidebar((v) => !v)}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
style={{
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
}}
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
</div>
</div>
)}
{/* Content + Chat sidebar row */}
{/* Content area */}
<div className="flex-1 flex min-h-0">
{/* Content area */}
<div className="flex-1 overflow-y-auto">
<ContentRenderer
content={content}
workspaceExists={workspaceExists}
tree={tree}
activePath={activePath}
members={context?.members}
onNodeSelect={handleNodeSelect}
onNavigateToObject={handleNavigateToObject}
onRefreshObject={refreshCurrentObject}
onRefreshTree={refreshTree}
onNavigate={handleEditorNavigate}
onOpenEntry={handleOpenEntry}
searchFn={searchIndex}
/>
</div>
{/* Chat sidebar (file-scoped) */}
{fileContext && showChatSidebar && (
<aside
className="flex-shrink-0 border-l"
style={{
width: 380,
borderColor: "var(--color-border)",
background: "var(--color-bg)",
}}
>
{showMainChat ? (
/* Main chat view (default when no file is selected) */
<div className="flex-1 flex flex-col min-w-0">
<ChatPanel
compact
fileContext={fileContext}
onFileChanged={handleFileChanged}
ref={chatRef}
onActiveSessionChange={(id) => {
setActiveSessionId(id);
}}
onSessionsChange={refreshSessions}
/>
</aside>
</div>
) : (
<>
{/* File content area */}
<div className="flex-1 overflow-y-auto">
<ContentRenderer
content={content}
workspaceExists={workspaceExists}
tree={tree}
activePath={activePath}
members={context?.members}
onNodeSelect={handleNodeSelect}
onNavigateToObject={handleNavigateToObject}
onRefreshObject={refreshCurrentObject}
onRefreshTree={refreshTree}
onNavigate={handleEditorNavigate}
onOpenEntry={handleOpenEntry}
searchFn={searchIndex}
/>
</div>
{/* Chat sidebar (file-scoped) */}
{fileContext && showChatSidebar && (
<aside
className="flex-shrink-0 border-l"
style={{
width: 380,
borderColor: "var(--color-border)",
background: "var(--color-bg)",
}}
>
<ChatPanel
compact
fileContext={fileContext}
onFileChanged={handleFileChanged}
/>
</aside>
)}
</>
)}
</div>
</main>