diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 1b25d7cb6ea..066666b1ad6 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -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 = {}; + 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(); + + 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 }); } diff --git a/apps/web/app/api/workspace/virtual-file/route.ts b/apps/web/app/api/workspace/virtual-file/route.ts new file mode 100644 index 00000000000..30d955ae99c --- /dev/null +++ b/apps/web/app/api/workspace/virtual-file/route.ts @@ -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//SKILL.md + const rest = virtualPath.slice("~skills/".length); + // Validate: must be /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 }, + ); + } +} diff --git a/apps/web/app/components/workspace/empty-state.tsx b/apps/web/app/components/workspace/empty-state.tsx index 39d17cde1c9..419866c2a94 100644 --- a/apps/web/app/components/workspace/empty-state.tsx +++ b/apps/web/app/components/workspace/empty-state.tsx @@ -112,7 +112,7 @@ export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) { - Back to Chat + Back to Home ); diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index 2f446c5a7f4..9b16e6c9439 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -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 ( + + + + ); +} + function LockBadge() { return ( @@ -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 ; + } switch (node.type) { case "object": return node.defaultView === "kanban" ? : ; @@ -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({ {node.name.replace(/\.md$/, "")} )} - {/* Lock badge for system files */} - {isSysFile && !compact && ( + {/* Lock badge for system/virtual files */} + {isProtected && !compact && ( @@ -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); } diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index 0d7a8e5024c..d234861b318 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -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 }), diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index 9a005e59e37..289b25ec887 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -22,10 +22,11 @@ function WorkspaceLogo() { ); } -function BackIcon() { +function HomeIcon() { return ( - + + ); } @@ -73,7 +74,7 @@ export function WorkspaceSidebar({ Knowledge - {/* Tree */} + {/* Tree (includes real files + virtual Skills, Memories, Chats folders) */}
{loading ? (
@@ -112,8 +113,8 @@ export function WorkspaceSidebar({ (e.currentTarget as HTMLElement).style.background = "transparent"; }} > - - Back to Chat + + Home
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 079b637241c..e585fcf5aa7 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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(null); - const [activeSessionId, setActiveSessionId] = useState(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 ( -
- +
+ {/* Logo / brand mark */} +
+ + + + + + +
- {/* Main chat area */} -
- -
+ {/* Heading */} +

+ OpenClaw Dench +

+ + {/* Tagline */} +

+ Your AI workspace — chat, knowledge, skills, and memory in one place. +

+ + {/* CTA */} + { + (e.currentTarget as HTMLElement).style.background = + "var(--color-accent-hover)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = + "var(--color-accent)"; + }} + > + Open Workspace + + + + + + + {/* Subtle footer link */} +

+ Powered by OpenClaw +

); } diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 2ae91bfbd1d..e699b582379 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -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(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({ kind: "none" }); const [showChatSidebar, setShowChatSidebar] = useState(true); + // Chat session state + const [activeSessionId, setActiveSessionId] = useState(null); + const [sessions, setSessions] = useState([]); + 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
{/* Sidebar */} - {/* Top bar with breadcrumbs */} - {activePath && ( + {/* When a file is selected: show top bar with breadcrumbs */} + {activePath && content.kind !== "none" && (
- {/* Chat sidebar toggle */} - +
+ {/* Back to chat button */} + + {/* Chat sidebar toggle */} + +
)} - {/* Content + Chat sidebar row */} + {/* Content area */}
- {/* Content area */} -
- -
- - {/* Chat sidebar (file-scoped) */} - {fileContext && showChatSidebar && ( - +
+ ) : ( + <> + {/* File content area */} +
+ +
+ + {/* Chat sidebar (file-scoped) */} + {fileContext && showChatSidebar && ( + + )} + )}