diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 066666b1ad6..ea465eab0a7 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -8,7 +8,7 @@ export const runtime = "nodejs"; export type TreeNode = { name: string; - path: string; // relative to dench/ (or ~skills/, ~memories/ for virtual nodes) + path: string; // relative to dench/ (or ~skills/, ~memories/, ~workspace/ for virtual nodes) type: "object" | "document" | "folder" | "file" | "database" | "report"; icon?: string; defaultView?: "table" | "kanban"; @@ -214,6 +214,47 @@ function buildSkillsVirtualFolder(): TreeNode | null { }; } +/** + * Build top-level workspace root file nodes (USER.md, SOUL.md, TOOLS.md, etc.). + * These live directly in ~/.openclaw/workspace/ but outside the dench/ subdirectory. + * They are virtual (not movable/renamable/deletable) but editable. + */ +function buildWorkspaceRootFiles(): TreeNode[] { + const workspaceDir = join(homedir(), ".openclaw", "workspace"); + if (!existsSync(workspaceDir)) {return [];} + + // Files already handled by the Memories virtual folder + const SKIP_FILES = new Set(["MEMORY.md", "memory.md"]); + + const nodes: TreeNode[] = []; + + try { + const entries = readdirSync(workspaceDir, { withFileTypes: true }); + for (const entry of entries) { + // Skip subdirectories (handled elsewhere) and hidden files + if (entry.isDirectory()) {continue;} + if (entry.name.startsWith(".")) {continue;} + if (SKIP_FILES.has(entry.name)) {continue;} + + const ext = entry.name.split(".").pop()?.toLowerCase(); + const isDocument = ext === "md" || ext === "mdx"; + + nodes.push({ + name: entry.name, + path: `~workspace/${entry.name}`, + type: isDocument ? "document" : "file", + virtual: true, + }); + } + } catch { + // dir unreadable + } + + // Sort alphabetically + nodes.sort((a, b) => a.name.localeCompare(b.name)); + return nodes; +} + /** Build a virtual "Memories" folder from ~/.openclaw/workspace/. */ function buildMemoriesVirtualFolder(): TreeNode | null { const workspaceDir = join(homedir(), ".openclaw", "workspace"); @@ -274,6 +315,7 @@ export async function GET() { if (!root) { // Even without a dench workspace, return virtual folders if they exist const tree: TreeNode[] = []; + tree.push(...buildWorkspaceRootFiles()); const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} const memoriesFolder = buildMemoriesVirtualFolder(); @@ -323,6 +365,10 @@ export async function GET() { // skip if root unreadable } + // Workspace root files (USER.md, SOUL.md, etc.) -- editable but reserved + const workspaceRootFiles = buildWorkspaceRootFiles(); + if (workspaceRootFiles.length > 0) {tree.push(...workspaceRootFiles);} + // Virtual folders go after all real files/folders const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} diff --git a/apps/web/app/api/workspace/virtual-file/route.ts b/apps/web/app/api/workspace/virtual-file/route.ts index 30d955ae99c..a5f840e9327 100644 --- a/apps/web/app/api/workspace/virtual-file/route.ts +++ b/apps/web/app/api/workspace/virtual-file/route.ts @@ -68,6 +68,15 @@ function resolveVirtualPath(virtualPath: string): string | null { return join(workspaceDir, "memory", rest); } + if (virtualPath.startsWith("~workspace/")) { + const rest = virtualPath.slice("~workspace/".length); + // Only allow direct filenames (no subdirectories, no traversal) + if (!rest || rest.includes("..") || rest.includes("/")) { + return null; + } + return join(home, ".openclaw", "workspace", rest); + } + return null; } diff --git a/apps/web/app/components/workspace/document-view.tsx b/apps/web/app/components/workspace/document-view.tsx index 905763a53e7..5f68142e07d 100644 --- a/apps/web/app/components/workspace/document-view.tsx +++ b/apps/web/app/components/workspace/document-view.tsx @@ -93,7 +93,6 @@ export function DocumentView({ tree={tree ?? []} onSave={onSave} onNavigate={onNavigate} - onSwitchToRead={() => setEditMode(false)} searchFn={searchFn} /> diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index 9b16e6c9439..feb6c1c3a6a 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -618,11 +618,14 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact const containerRef = useRef(null); - // Auto-expand first level on mount + // Auto-expand first level on mount. + // Keep ~skills and ~memories collapsed by default; always expand ~chats. + const collapsedByDefault = new Set(["~skills", "~memories"]); useEffect(() => { if (tree.length > 0 && expandedPaths.size === 0) { const initial = new Set(); for (const node of tree) { + if (collapsedByDefault.has(node.path)) {continue;} if (node.children && node.children.length > 0) { initial.add(node.path); } diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index eab8bf03f1e..a2802abcf2f 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -30,8 +30,6 @@ export type MarkdownEditorProps = { tree: TreeNode[]; onSave?: () => void; onNavigate?: (path: string) => void; - /** Switch to read-only mode (renders a "Read" button in the top bar). */ - onSwitchToRead?: () => void; /** Optional search function from useSearchIndex for fuzzy @ mention search. */ searchFn?: MentionSearchFn; }; @@ -51,7 +49,6 @@ export function MarkdownEditor({ tree, onSave, onNavigate, - onSwitchToRead, searchFn, }: MarkdownEditorProps) { const [saving, setSaving] = useState(false); @@ -347,7 +344,7 @@ export function MarkdownEditor({ return (
- {/* Sticky top bar: save status + save button + read toggle */} + {/* Sticky top bar: save status + save button */}
{isDirty && ( @@ -378,20 +375,6 @@ export function MarkdownEditor({ > {saving ? "Saving..." : "Save"} - {onSwitchToRead && ( - - )}