diff --git a/apps/web/app/components/workspace/code-editor.test.tsx b/apps/web/app/components/workspace/code-editor.test.tsx
index 8885fadeaae..bee8a33e39b 100644
--- a/apps/web/app/components/workspace/code-editor.test.tsx
+++ b/apps/web/app/components/workspace/code-editor.test.tsx
@@ -490,6 +490,25 @@ describe("MonacoCodeEditor save flow", () => {
});
});
+ it("preserves absolute browse-mode paths when saving code files", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));
+ global.fetch = fetchMock;
+
+ render();
+ simulateContentChange("browse mode content");
+
+ const btn = screen.getByRole("button", { name: /save/i });
+ await act(async () => {
+ btn.click();
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "/tmp/app.ts", content: "browse mode content" }),
+ });
+ });
+
it("shows 'Saved' indicator after successful save", async () => {
global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));
diff --git a/apps/web/app/components/workspace/code-editor.tsx b/apps/web/app/components/workspace/code-editor.tsx
index b2f93df4714..ba7b3bb1331 100644
--- a/apps/web/app/components/workspace/code-editor.tsx
+++ b/apps/web/app/components/workspace/code-editor.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Editor, { type OnMount } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { DiffCard } from "../diff-viewer";
+import { fileWriteUrl } from "@/lib/workspace-paths";
const EXT_TO_MONACO_LANG: Record = {
ts: "typescript",
@@ -232,7 +233,7 @@ function EditorInner({ content, filename, filePath, className }: CodeEditorProps
const value = editorRef.current.getValue();
setSaveState("saving");
try {
- const res = await fetch("/api/workspace/file", {
+ const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: value }),
diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx
index f7f71c8442a..ba4a7a75bfc 100644
--- a/apps/web/app/components/workspace/file-manager-tree.tsx
+++ b/apps/web/app/components/workspace/file-manager-tree.tsx
@@ -16,6 +16,7 @@ import {
} from "@dnd-kit/core";
import { ContextMenu, type ContextMenuAction, type ContextMenuTarget } from "./context-menu";
import { InlineRename, RENAME_SHAKE_STYLE } from "./inline-rename";
+import { classifyWorkspacePath, fileWriteUrl, isVirtualPath } from "@/lib/workspace-paths";
// --- Types ---
@@ -44,9 +45,9 @@ export type TreeNode = {
/** 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. */
+/** Check if a node (or any of its ancestors) is virtual. */
function isVirtualNode(node: TreeNode): boolean {
- return !!node.virtual || node.path.startsWith("~");
+ return !!node.virtual || isVirtualPath(node.path);
}
type FileManagerTreeProps = {
@@ -82,10 +83,23 @@ const ROOT_ONLY_SYSTEM_PATTERNS = [
/^workspace_context\.yaml$/,
];
-function isSystemFile(path: string): boolean {
- const base = path.split("/").pop() ?? "";
+function toWorkspaceRelativePath(path: string, workspaceRoot?: string | null): string | null {
+ const kind = classifyWorkspacePath(path);
+ if (kind === "virtual" || kind === "homeRelative") {return null;}
+ if (kind === "workspaceRelative") {return path;}
+ if (!workspaceRoot) {return null;}
+ if (path !== workspaceRoot && !path.startsWith(`${workspaceRoot}/`)) {
+ return null;
+ }
+ return path === workspaceRoot ? "" : path.slice(workspaceRoot.length + 1);
+}
+
+function isSystemFile(path: string, workspaceRoot?: string | null): boolean {
+ const relativePath = toWorkspaceRelativePath(path, workspaceRoot);
+ if (relativePath == null) {return false;}
+ const base = relativePath.split("/").pop() ?? "";
if (ALWAYS_SYSTEM_PATTERNS.some((p) => p.test(base))) {return true;}
- const isRoot = !path.includes("/");
+ const isRoot = relativePath !== "" && !relativePath.includes("/");
return isRoot && ROOT_ONLY_SYSTEM_PATTERNS.some((p) => p.test(base));
}
@@ -302,7 +316,7 @@ async function apiMkdir(path: string) {
}
async function apiCreateFile(path: string, content: string = "") {
- const res = await fetch("/api/workspace/file", {
+ const res = await fetch(fileWriteUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
@@ -461,7 +475,7 @@ function DraggableNode({
const isActive = activePath === node.path;
const isSelected = selectedPath === node.path;
const isRenaming = renamingPath === node.path;
- const isSysFile = isSystemFile(node.path);
+ const isSysFile = isSystemFile(node.path, workspaceRoot);
const isVirtual = isVirtualNode(node);
const isProtected = isSysFile || isVirtual || isWorkspaceRoot;
const isDragOver = dragOverPath === node.path && isExpandable;
@@ -963,7 +977,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) || isVirtualNode(node);
+ const isSys = isSystemFile(node.path, workspaceRoot) || isVirtualNode(node);
const isFolder = node.type === "folder" || node.type === "object";
setCtxMenu({
x: e.clientX,
@@ -975,7 +989,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
isSystem: isSys,
},
});
- }, []);
+ }, [workspaceRoot]);
const handleEmptyContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -1151,7 +1165,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
case "Enter": {
e.preventDefault();
if (curNode) {
- const curProtected = isSystemFile(curNode.path) || isVirtualNode(curNode);
+ const curProtected = isSystemFile(curNode.path, workspaceRoot) || isVirtualNode(curNode);
if (e.shiftKey || curProtected) {
onSelect(curNode);
} else {
@@ -1162,14 +1176,14 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
}
case "F2": {
e.preventDefault();
- if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
+ if (curNode && !isSystemFile(curNode.path, workspaceRoot) && !isVirtualNode(curNode)) {
setRenamingPath(curNode.path);
}
break;
}
case "Backspace":
case "Delete": {
- if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
+ if (curNode && !isSystemFile(curNode.path, workspaceRoot) && !isVirtualNode(curNode)) {
e.preventDefault();
setConfirmDelete(curNode.path);
}
@@ -1181,7 +1195,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
if (e.key === "c" && curNode) {
e.preventDefault();
void navigator.clipboard.writeText(curNode.path);
- } else if (e.key === "d" && curNode && !isSystemFile(curNode.path)) {
+ } else if (e.key === "d" && curNode && !isSystemFile(curNode.path, workspaceRoot)) {
e.preventDefault();
void apiDuplicate(curNode.path).then(() => onRefresh());
} else if (e.key === "n") {
@@ -1202,7 +1216,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
}
}
},
- [tree, expandedPaths, selectedPath, renamingPath, onSelect, onRefresh],
+ [tree, expandedPaths, selectedPath, renamingPath, onSelect, onRefresh, workspaceRoot],
);
if (tree.length === 0) {
diff --git a/apps/web/app/components/workspace/markdown-editor.test.tsx b/apps/web/app/components/workspace/markdown-editor.test.tsx
new file mode 100644
index 00000000000..a9a96177b9c
--- /dev/null
+++ b/apps/web/app/components/workspace/markdown-editor.test.tsx
@@ -0,0 +1,194 @@
+// @vitest-environment jsdom
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { MarkdownEditor } from "./markdown-editor";
+
+type EditorOptions = {
+ onUpdate?: () => void;
+};
+
+let lastEditorOptions: EditorOptions | null = null;
+let currentEditor: {
+ view: { dom: HTMLDivElement };
+ commands: { setContent: ReturnType };
+ isActive: ReturnType;
+ getHTML: ReturnType;
+ getMarkdown: ReturnType;
+ chain: ReturnType;
+};
+
+function createMockChain() {
+ const chain = {
+ focus: vi.fn(() => chain),
+ toggleBold: vi.fn(() => chain),
+ toggleItalic: vi.fn(() => chain),
+ toggleStrike: vi.fn(() => chain),
+ toggleCode: vi.fn(() => chain),
+ unsetLink: vi.fn(() => chain),
+ setLink: vi.fn(() => chain),
+ toggleHeading: vi.fn(() => chain),
+ toggleBulletList: vi.fn(() => chain),
+ toggleOrderedList: vi.fn(() => chain),
+ toggleTaskList: vi.fn(() => chain),
+ toggleBlockquote: vi.fn(() => chain),
+ toggleCodeBlock: vi.fn(() => chain),
+ setImage: vi.fn(() => chain),
+ insertTable: vi.fn(() => chain),
+ setHorizontalRule: vi.fn(() => chain),
+ run: vi.fn(() => true),
+ };
+ return chain;
+}
+
+vi.mock("@tiptap/react", () => ({
+ useEditor: (options: EditorOptions) => {
+ lastEditorOptions = options;
+ return currentEditor;
+ },
+ EditorContent: () => ,
+}));
+
+vi.mock("@tiptap/react/menus", () => ({
+ BubbleMenu: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@tiptap/starter-kit", () => ({ default: { configure: () => ({}) } }));
+vi.mock("@tiptap/markdown", () => ({ Markdown: { configure: () => ({}) } }));
+vi.mock("@tiptap/extension-image", () => ({ default: { configure: () => ({}) } }));
+vi.mock("@tiptap/extension-link", () => ({ default: { configure: () => ({}) } }));
+vi.mock("@tiptap/extension-table", () => ({ Table: { configure: () => ({}) } }));
+vi.mock("@tiptap/extension-table-row", () => ({ default: {} }));
+vi.mock("@tiptap/extension-table-cell", () => ({ default: {} }));
+vi.mock("@tiptap/extension-table-header", () => ({ default: {} }));
+vi.mock("@tiptap/extension-task-list", () => ({ default: {} }));
+vi.mock("@tiptap/extension-task-item", () => ({ default: { configure: () => ({}) } }));
+vi.mock("@tiptap/extension-placeholder", () => ({ default: { configure: () => ({}) } }));
+
+vi.mock("./report-block-node", () => ({
+ ReportBlockNode: {},
+ preprocessReportBlocks: (value: string) => value,
+ postprocessReportBlocks: (value: string) => value,
+}));
+
+vi.mock("./slash-command", () => ({
+ createSlashCommand: () => ({}),
+ createWorkspaceMention: () => ({}),
+ createFileMention: () => ({}),
+}));
+
+vi.mock("./editor-toolbar-primitives", () => ({
+ ToolbarGroup: ({ children }: { children: React.ReactNode }) => {children}
,
+ ToolbarDivider: () => ,
+ ToolbarButton: ({
+ children,
+ onClick,
+ title,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ title?: string;
+ }) => (
+
+ ),
+ BubbleButton: ({
+ children,
+ onClick,
+ title,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ title?: string;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/lib/workspace-links", () => ({
+ isWorkspaceLink: () => false,
+}));
+
+describe("MarkdownEditor", () => {
+ beforeEach(() => {
+ const chain = createMockChain();
+ currentEditor = {
+ view: { dom: document.createElement("div") },
+ commands: { setContent: vi.fn() },
+ isActive: vi.fn(() => false),
+ getHTML: vi.fn(() => "fallback
"),
+ getMarkdown: vi.fn(() => "# updated markdown"),
+ chain: vi.fn(() => chain),
+ };
+ lastEditorOptions = null;
+ });
+
+ function markDirty() {
+ act(() => {
+ lastEditorOptions?.onUpdate?.();
+ });
+ }
+
+ it("saves home-relative markdown files through the real file API", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock;
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ markDirty();
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "~/notes/daily.md", content: "# updated markdown" }),
+ });
+ });
+
+ it("keeps virtual markdown paths on the virtual-file API", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock;
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ markDirty();
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/workspace/virtual-file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "~skills/demo/SKILL.md", content: "# updated markdown" }),
+ });
+ });
+});
diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx
index 2798888cf2d..2332b48bc7b 100644
--- a/apps/web/app/components/workspace/markdown-editor.tsx
+++ b/apps/web/app/components/workspace/markdown-editor.tsx
@@ -18,6 +18,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node";
import { createSlashCommand, createWorkspaceMention, createFileMention, type TreeNode, type MentionSearchFn } from "./slash-command";
import { ToolbarGroup, ToolbarDivider, ToolbarButton, BubbleButton } from "./editor-toolbar-primitives";
+import { fileWriteUrl } from "@/lib/workspace-paths";
import { isWorkspaceLink } from "@/lib/workspace-links";
// --- Types ---
@@ -311,11 +312,7 @@ export function MarkdownEditor({
// Prepend preserved frontmatter so it isn't lost on save
const finalContent = frontmatterRef.current + bodyContent;
- // Virtual paths (~skills/*) use the virtual-file API
- const saveEndpoint = filePath.startsWith("~")
- ? "/api/workspace/virtual-file"
- : "/api/workspace/file";
- const res = await fetch(saveEndpoint, {
+ const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: finalContent }),
diff --git a/apps/web/app/components/workspace/rich-document-editor.test.tsx b/apps/web/app/components/workspace/rich-document-editor.test.tsx
index 5b193a27b21..739e60dd740 100644
--- a/apps/web/app/components/workspace/rich-document-editor.test.tsx
+++ b/apps/web/app/components/workspace/rich-document-editor.test.tsx
@@ -295,6 +295,30 @@ describe("RichDocumentEditor save flows", () => {
expect(screen.getByText("Saved")).toBeInTheDocument();
});
+ it("preserves absolute browse-mode paths when saving TXT files", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }),
+ );
+ global.fetch = fetchMock;
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+ markDirty();
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: "/tmp/today.txt", content: "Updated plain text" }),
+ });
+ });
+
it("saves DOCX via html-to-docx and /api/workspace/write-binary", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }),
diff --git a/apps/web/app/components/workspace/rich-document-editor.tsx b/apps/web/app/components/workspace/rich-document-editor.tsx
index 33fe1632d05..971bbc64dda 100644
--- a/apps/web/app/components/workspace/rich-document-editor.tsx
+++ b/apps/web/app/components/workspace/rich-document-editor.tsx
@@ -21,6 +21,7 @@ import Superscript from "@tiptap/extension-superscript";
import Subscript from "@tiptap/extension-subscript";
import CharacterCount from "@tiptap/extension-character-count";
import { useState, useCallback, useEffect, useRef } from "react";
+import { fileWriteUrl } from "@/lib/workspace-paths";
import {
ToolbarGroup,
@@ -195,7 +196,7 @@ export function RichDocumentEditor({
try {
if (isTxt) {
const text = editor.getText();
- const res = await fetch("/api/workspace/file", {
+ const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: text }),
diff --git a/apps/web/app/components/workspace/spreadsheet-editor.tsx b/apps/web/app/components/workspace/spreadsheet-editor.tsx
index b04b7afee24..12eea62fada 100644
--- a/apps/web/app/components/workspace/spreadsheet-editor.tsx
+++ b/apps/web/app/components/workspace/spreadsheet-editor.tsx
@@ -28,6 +28,7 @@ import {
matrixToCsv,
selectionStats,
} from "./spreadsheet-utils";
+import { fileWriteUrl } from "@/lib/workspace-paths";
// ---------------------------------------------------------------------------
// Types
@@ -622,7 +623,7 @@ export function SpreadsheetEditor({
if (isTextSpreadsheet(filename)) {
const sep = fileExt(filename) === "tsv" ? "\t" : ",";
const text = matrixToCsv(sheets[activeSheet].data, sep);
- const res = await fetch("/api/workspace/file", {
+ const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: text }),
diff --git a/apps/web/app/workspace/workspace-content.tsx b/apps/web/app/workspace/workspace-content.tsx
index 9eef0a1860e..91831f19635 100644
--- a/apps/web/app/workspace/workspace-content.tsx
+++ b/apps/web/app/workspace/workspace-content.tsx
@@ -81,6 +81,13 @@ import {
type ChatRunsSnapshot,
type ChatPanelRuntimeState,
} from "@/lib/chat-session-registry";
+import {
+ fileReadUrl as fileApiUrl,
+ rawFileReadUrl as rawFileUrl,
+ isAbsolutePath,
+ isHomeRelativePath,
+ isVirtualPath,
+} from "@/lib/workspace-paths";
import dynamic from "next/dynamic";
const TerminalDrawer = dynamic(
@@ -202,42 +209,6 @@ type WebSession = {
messageCount: number;
};
-// --- Helpers ---
-
-/** Detect virtual paths (skills, memories) that live outside the main workspace. */
-function isVirtualPath(path: string): boolean {
- return path.startsWith("~") && !path.startsWith("~/");
-}
-
-/** Detect absolute filesystem paths (browse mode). */
-function isAbsolutePath(path: string): boolean {
- return path.startsWith("/");
-}
-
-/** Detect home-relative filesystem paths (e.g. ~/Desktop/file.txt). */
-function isHomeRelativePath(path: string): boolean {
- return path.startsWith("~/");
-}
-
-/** Pick the right file API endpoint based on virtual vs real vs absolute paths. */
-function fileApiUrl(path: string): string {
- if (isVirtualPath(path)) {
- return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`;
- }
- if (isAbsolutePath(path) || isHomeRelativePath(path)) {
- return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`;
- }
- return `/api/workspace/file?path=${encodeURIComponent(path)}`;
-}
-
-/** Pick the right raw file URL for media preview. */
-function rawFileUrl(path: string): string {
- if (isAbsolutePath(path) || isHomeRelativePath(path)) {
- return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
- }
- return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
-}
-
const LEFT_SIDEBAR_MIN = 200;
const LEFT_SIDEBAR_MAX = 480;
const RIGHT_SIDEBAR_MIN = 260;
@@ -1177,6 +1148,14 @@ function WorkspacePageInner() {
const jobId = tab.path.slice("~cron/".length);
const job = cronJobs.find((j) => j.id === jobId);
if (job) setContent({ kind: "cron-job", jobId, job });
+ } else {
+ const fileName = tab.title || tab.path.split("/").pop() || tab.path;
+ const syntheticNode: TreeNode = {
+ name: fileName,
+ path: tab.path,
+ type: tab.type === "object" ? "object" : inferNodeTypeFromFileName(fileName),
+ };
+ void loadContent(syntheticNode);
}
}
}, [tree, loadContent, cronJobs]);