diff --git a/apps/web/src/app/api/mcp/route.test.ts b/apps/web/src/app/api/mcp/route.test.ts index 68b8b206..ca515ff5 100644 --- a/apps/web/src/app/api/mcp/route.test.ts +++ b/apps/web/src/app/api/mcp/route.test.ts @@ -120,7 +120,7 @@ describe('POST /api/mcp', () => { }) it('rejects oversized requests before authentication', async () => { - const response = await POST(createRequest({ headers: { 'content-length': String(64 * 1024 + 1) } })) + const response = await POST(createRequest({ headers: { 'content-length': String(10 * 1024 * 1024 + 1) } })) expect(response.status).toBe(413) await expect(response.json()).resolves.toEqual({ error: 'payload_too_large' }) diff --git a/apps/web/src/app/api/mcp/route.ts b/apps/web/src/app/api/mcp/route.ts index 8e2da413..6a45feae 100644 --- a/apps/web/src/app/api/mcp/route.ts +++ b/apps/web/src/app/api/mcp/route.ts @@ -8,7 +8,7 @@ import { auditService, mcpSettingsService, rateLimitService } from '@/lib/servic export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -const MCP_MAX_BODY_BYTES = 64 * 1024 +const MCP_MAX_BODY_BYTES = 10 * 1024 * 1024 const MCP_PREAUTH_RATE_LIMIT_MAX = 300 const MCP_PREAUTH_RATE_LIMIT_WINDOW_MS = 60 * 1000 const MCP_TOKEN_RATE_LIMIT_MAX = 100 diff --git a/apps/web/src/components/workspace/__tests__/file-tree-panel.test.tsx b/apps/web/src/components/workspace/__tests__/file-tree-panel.test.tsx index 7d160b3c..fb473c14 100644 --- a/apps/web/src/components/workspace/__tests__/file-tree-panel.test.tsx +++ b/apps/web/src/components/workspace/__tests__/file-tree-panel.test.tsx @@ -53,6 +53,112 @@ describe("FileTreePanel", () => { expect(onDownloadFile).toHaveBeenCalledWith("alpha.md"); }); + it("auto-expands top-level directories when nodes arrive", async () => { + render( + {}} + /> + ); + + expect(await screen.findByRole("button", { name: /notes.md/i })).toBeTruthy(); + }); + + it("expands ancestor directories of the active file path", async () => { + const deepNodes: WorkspaceFileNode[] = [ + { + id: "docs", + name: "docs", + path: "docs", + type: "directory", + children: [ + { + id: "docs/research", + name: "research", + path: "docs/research", + type: "directory", + children: [ + { + id: "docs/research/analysis.md", + name: "analysis.md", + path: "docs/research/analysis.md", + type: "file", + }, + ], + }, + ], + }, + ]; + + render( + {}} + /> + ); + + expect(await screen.findByRole("button", { name: /analysis.md/i })).toBeTruthy(); + }); + + it("preserves user-collapsed directories on subsequent updates", async () => { + const onSelect = vi.fn(); + + const { rerender } = render( + + ); + + const docsButton = await screen.findByRole("button", { name: /^docs$/i }); + expect(screen.getByRole("button", { name: /notes.md/i })).toBeTruthy(); + + fireEvent.click(docsButton); + + expect(screen.queryByRole("button", { name: /notes.md/i })).toBeNull(); + + rerender( + + ); + + expect(screen.queryByRole("button", { name: /notes.md/i })).toBeNull(); + }); + + it("expands a collapsed dir when navigating to a file inside it", async () => { + const onSelect = vi.fn(); + + const { rerender } = render( + + ); + + const docsButton = await screen.findByRole("button", { name: /^docs$/i }); + expect(screen.getByRole("button", { name: /notes.md/i })).toBeTruthy(); + + fireEvent.click(docsButton); + expect(screen.queryByRole("button", { name: /notes.md/i })).toBeNull(); + + rerender( + + ); + + expect(await screen.findByRole("button", { name: /notes.md/i })).toBeTruthy(); + }); + it("supports downloading files from search results too", async () => { const onDownloadFile = vi.fn(); diff --git a/apps/web/src/components/workspace/__tests__/workspace-shell.test.tsx b/apps/web/src/components/workspace/__tests__/workspace-shell.test.tsx index f5cbf6bb..35a3b1c5 100644 --- a/apps/web/src/components/workspace/__tests__/workspace-shell.test.tsx +++ b/apps/web/src/components/workspace/__tests__/workspace-shell.test.tsx @@ -1088,7 +1088,7 @@ describe("WorkspaceShell", () => { const params = new URLSearchParams(window.location.search); expect(params.get("mode")).toBe("flows"); - expect(params.get("path")).toBe("docs/plan.md"); + expect(params.get("path")).toBeNull(); rerender(); rerender(); diff --git a/apps/web/src/components/workspace/file-tree.tsx b/apps/web/src/components/workspace/file-tree.tsx index 5a26c553..b76bf85a 100644 --- a/apps/web/src/components/workspace/file-tree.tsx +++ b/apps/web/src/components/workspace/file-tree.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, type MouseEvent } from "react"; +import { useEffect, useRef, useState, type MouseEvent } from "react"; import { CaretRight, File, Folder, FolderOpen } from "@phosphor-icons/react"; import { cn } from "@/lib/utils"; @@ -18,19 +18,58 @@ type FileTreeProps = { type TreeState = Record; +function getAncestorPaths(filePath: string): string[] { + const segments = filePath.split("/"); + const ancestors: string[] = []; + for (let i = 1; i < segments.length; i++) { + ancestors.push(segments.slice(0, i).join("/")); + } + return ancestors; +} + export function FileTree({ nodes, activePath, onSelect, onFileContextMenu }: FileTreeProps) { - const initialExpanded = useMemo(() => { - const state: TreeState = {}; - nodes.forEach((node) => { - if (node.type === "directory") state[node.path] = true; - }); - return state; - }, [nodes]); + const [expanded, setExpanded] = useState({}); + const userCollapsedRef = useRef(new Set()); + const prevActivePathRef = useRef(activePath); - const [expanded, setExpanded] = useState(initialExpanded); + useEffect(() => { + const activePathChanged = activePath !== prevActivePathRef.current; + prevActivePathRef.current = activePath; + + setExpanded((prev) => { + const next = { ...prev }; + let changed = false; + for (const node of nodes) { + if (node.type === "directory" && !(node.path in prev)) { + next[node.path] = true; + changed = true; + } + } + if (activePath) { + for (const ancestor of getAncestorPaths(activePath)) { + if (activePathChanged) { + userCollapsedRef.current.delete(ancestor); + } + if (!next[ancestor] && !userCollapsedRef.current.has(ancestor)) { + next[ancestor] = true; + changed = true; + } + } + } + return changed ? next : prev; + }); + }, [nodes, activePath]); const toggle = (path: string) => { - setExpanded((prev) => ({ ...prev, [path]: !prev[path] })); + setExpanded((prev) => { + const wasOpen = prev[path]; + if (wasOpen) { + userCollapsedRef.current.add(path); + } else { + userCollapsedRef.current.delete(path); + } + return { ...prev, [path]: !wasOpen }; + }); }; const renderNode = (node: WorkspaceFileNode, depth: number) => { diff --git a/apps/web/src/components/workspace/workspace-shell.tsx b/apps/web/src/components/workspace/workspace-shell.tsx index 81dfab5c..97b9a061 100644 --- a/apps/web/src/components/workspace/workspace-shell.tsx +++ b/apps/web/src/components/workspace/workspace-shell.tsx @@ -781,12 +781,36 @@ export function WorkspaceShell({ return normalized; }, [initialFilePath]); - const [openFilePaths, setOpenFilePaths] = useState( - safeInitialFilePath ? [safeInitialFilePath] : [] - ); - const [activeFilePath, setActiveFilePath] = useState( - safeInitialFilePath - ); + const openFilesStorageKey = `arche.workspace.${slug}.openFiles`; + + const [openFilePaths, setOpenFilePaths] = useState(() => { + if (typeof window === "undefined") return safeInitialFilePath ? [safeInitialFilePath] : []; + try { + const stored = window.localStorage.getItem(openFilesStorageKey); + if (!stored) return safeInitialFilePath ? [safeInitialFilePath] : []; + const parsed = JSON.parse(stored) as { paths?: string[]; active?: string | null }; + const paths = Array.isArray(parsed.paths) ? parsed.paths.filter((p): p is string => typeof p === "string") : []; + if (safeInitialFilePath && !paths.includes(safeInitialFilePath)) { + paths.push(safeInitialFilePath); + } + return paths.length > 0 ? paths : safeInitialFilePath ? [safeInitialFilePath] : []; + } catch { + return safeInitialFilePath ? [safeInitialFilePath] : []; + } + }); + + const [activeFilePath, setActiveFilePath] = useState(() => { + if (safeInitialFilePath) return safeInitialFilePath; + if (typeof window === "undefined") return null; + try { + const stored = window.localStorage.getItem(openFilesStorageKey); + if (!stored) return null; + const parsed = JSON.parse(stored) as { paths?: string[]; active?: string | null }; + return typeof parsed.active === "string" ? parsed.active : null; + } catch { + return null; + } + }); const [fileCache, setFileCache] = useState({}); const fileCacheRef = useRef(fileCache); @@ -794,6 +818,73 @@ export function WorkspaceShell({ fileCacheRef.current = fileCache; }, [fileCache]); + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + if (isKnowledgeMode && activeFilePath) { + params.set("path", activeFilePath); + } else { + params.delete("path"); + } + const query = params.toString(); + window.history.replaceState( + window.history.state, + "", + query ? `/w/${slug}?${query}` : `/w/${slug}` + ); + }, [activeFilePath, isKnowledgeMode, slug]); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + if (openFilePaths.length === 0) { + window.localStorage.removeItem(openFilesStorageKey); + } else { + window.localStorage.setItem( + openFilesStorageKey, + JSON.stringify({ paths: openFilePaths, active: activeFilePath }) + ); + } + } catch { + // ignore storage errors + } + }, [openFilePaths, activeFilePath, openFilesStorageKey]); + + const initialOpenFilePathsRef = useRef(openFilePaths); + useEffect(() => { + const pathsToLoad = initialOpenFilePathsRef.current.filter( + (p) => !fileCacheRef.current[p] + ); + if (pathsToLoad.length === 0) return; + void Promise.all( + pathsToLoad.map((filePath) => + readWorkspaceFile(filePath).then((result) => { + if (!result) return null; + return { filePath, result }; + }) + ) + ).then((results) => { + const loaded = results.filter( + (r): r is NonNullable => r !== null + ); + if (loaded.length === 0) return; + setFileCache((prev) => { + const next = { ...prev }; + for (const { filePath, result } of loaded) { + next[filePath] = { + content: result.content, + type: result.type, + title: filePath.split("/").pop() ?? filePath, + updatedAt: "Just now", + size: `${(result.content.length / 1024).toFixed(1)} KB`, + hash: result.hash, + }; + } + return next; + }); + }); + }, [readWorkspaceFile]); + const refreshOpenFilesCache = useCallback(async () => { if (openFilePaths.length === 0) return;