diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 9ed7263c6c9..fcf29d59e9a 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -426,6 +426,8 @@ export type ChatPanelHandle = { export type FileContext = { path: string; filename: string; + /** When true the path refers to a directory rather than a file. */ + isDirectory?: boolean; }; type FileScopedSession = { @@ -897,7 +899,8 @@ export const ChatPanel = forwardRef( } if (fileContext && isFirstFileMessageRef.current) { - messageText = `[Context: workspace file '${fileContext.path}']\n\n${messageText}`; + const label = fileContext.isDirectory ? "directory" : "file"; + messageText = `[Context: workspace ${label} '${fileContext.path}']\n\n${messageText}`; isFirstFileMessageRef.current = false; } @@ -1387,10 +1390,10 @@ export const ChatPanel = forwardRef( onChange={(isEmpty) => setEditorEmpty(isEmpty) } - placeholder={ - compact && fileContext - ? `Ask about ${fileContext.filename}...` - : attachedFiles.length > + placeholder={ + compact && fileContext + ? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...` + : attachedFiles.length > 0 ? "Add a message or send files..." : "Type @ to mention files..." diff --git a/apps/web/app/hooks/use-workspace-watcher.ts b/apps/web/app/hooks/use-workspace-watcher.ts index 66c0c979b81..12351db3264 100644 --- a/apps/web/app/hooks/use-workspace-watcher.ts +++ b/apps/web/app/hooks/use-workspace-watcher.ts @@ -31,13 +31,17 @@ export function useWorkspaceWatcher() { const mountedRef = useRef(true); const retryDelayRef = useRef(1000); + // Version counter: prevents stale fetch responses from overwriting newer data. + // Each fetch increments the counter; only the latest version's response is applied. + const fetchVersionRef = useRef(0); // Fetch the workspace tree from the tree API const fetchWorkspaceTree = useCallback(async () => { + const version = ++fetchVersionRef.current; try { const res = await fetch("/api/workspace/tree"); const data = await res.json(); - if (mountedRef.current) { + if (mountedRef.current && fetchVersionRef.current === version) { setTree(data.tree ?? []); setExists(data.exists ?? false); setWorkspaceRoot(data.workspaceRoot ?? null); @@ -45,36 +49,45 @@ export function useWorkspaceWatcher() { setLoading(false); } } catch { - if (mountedRef.current) {setLoading(false);} + if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);} } }, []); // Fetch a directory listing from the browse API const fetchBrowseTree = useCallback(async (dir: string) => { + const version = ++fetchVersionRef.current; try { setLoading(true); const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`); const data = await res.json(); - if (mountedRef.current) { + if (mountedRef.current && fetchVersionRef.current === version) { setTree(data.entries ?? []); setParentDir(data.parentDir ?? null); setExists(true); setLoading(false); } } catch { - if (mountedRef.current) {setLoading(false);} + if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);} } }, []); // Smart setBrowseDir: auto-return to workspace mode when navigating to the // workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB // object detection are restored. + const browseDirRef = useRef(null); const setBrowseDir = useCallback((dir: string | null) => { + let nextDir = dir; if (dir != null && workspaceRoot && dir === workspaceRoot) { - setBrowseDirRaw(null); - } else { - setBrowseDirRaw(dir); + nextDir = null; } + // Mark loading synchronously when entering a new browse directory so the + // very first render after navigation already shows the loading state + // (prevents a flash of stale tree data). + if (nextDir != null && nextDir !== browseDirRef.current) { + setLoading(true); + } + browseDirRef.current = nextDir; + setBrowseDirRaw(nextDir); }, [workspaceRoot]); // Expose the raw value for reads diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 0f348534692..35bb02b3be7 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -251,8 +251,8 @@ function WorkspacePageInner() { if (!activePath) {return undefined;} if (isVirtualPath(activePath)) {return undefined;} const filename = activePath.split("/").pop() || activePath; - return { path: activePath, filename }; - }, [activePath]); + return { path: activePath, filename, isDirectory: content.kind === "directory" }; + }, [activePath, content.kind]); // Update content state when the agent edits the file (live reload) const handleFileChanged = useCallback((newContent: string) => { @@ -413,9 +413,13 @@ function WorkspacePageInner() { } } // Clicking a folder in browse mode → navigate into it so the tree - // is fetched fresh (avoids stale/empty children from depth limits). + // is fetched fresh, AND show it in the main panel with the chat sidebar. + // Children come from the live tree (same data source as the sidebar), + // not from the stale node snapshot. if (node.type === "folder") { setBrowseDir(node.path); + setActivePath(node.path); + setContent({ kind: "directory", node: { name: node.name, path: node.path, type: "folder" } }); return; } } @@ -541,8 +545,11 @@ function WorkspacePageInner() { const handleFileSearchSelect = useCallback( (item: { name: string; path: string; type: string }) => { if (item.type === "folder") { - // Navigate the sidebar into the folder + // Navigate the sidebar into the folder and show it in the main panel. + // Children come from the live tree (same data source as the sidebar). setBrowseDir(item.path); + setActivePath(item.path); + setContent({ kind: "directory", node: { name: item.name, path: item.path, type: "folder" } }); } else { // Navigate the sidebar to the parent directory of the file const parentOfFile = item.path.split("/").slice(0, -1).join("/") || "/"; @@ -801,8 +808,8 @@ function WorkspacePageInner() { - {/* Chat sidebar toggle (hidden for reserved/virtual paths and directories) */} - {fileContext && content.kind !== "directory" && ( + {/* Chat sidebar toggle (hidden for reserved/virtual paths) */} + {fileContext && (