From 9cc347a25691b3c0faeac354056ea31672436ad2 Mon Sep 17 00:00:00 2001 From: archdex-art Date: Fri, 17 Jul 2026 14:29:05 +0530 Subject: [PATCH] fix: File Explorer not refreshing after the AI Assistant writes a file Bug report: "claude runs well but it claims that it made changes but i cant find any" -- screenshot showed a successful write_file tool call ("Created helloworld.py...") and a confirming list_directory call, but the file never appeared in the Explorer sidebar. ## Root cause (reproduced live, not assumed) Verified the entire backend chain first: the workspace write API and the file-listing API both resolve the identical on-disk workspace directory (a single DB column, `repos.workspace_dir`), and a direct write+list round trip through the real API confirmed the backend was correct. Reproduced the actual bug end-to-end in a real browser: stood up a fake OpenAI-compatible server standing in for the LLM (letting the exact real frontend code run against a deterministic "write a file" tool call, without spending real Anthropic credits), drove a real chat turn through CodeGraph's actual local-model path, and confirmed via the dev server's own request log that after the chat POST completed, the file explorer NEVER issued its usual follow-up `GET .../fs?op=list` request. A direct disk check confirmed the file genuinely existed -- the write succeeded, the assistant correctly reported success, but the Explorer silently never refreshed. Instrumented `AssistantPanel.tsx`'s `applyEvent` directly (writing to a `window` global read back via the browser tool, since `page.on('console')` listeners don't persist across separate browser-tool calls) and proved the exact mechanism: `applyEvent` computed `pathsToTouch`/`shouldMutate` by mutating `let` variables *inside* the `setTurns` state-updater callback, then read those same variables immediately after calling `setTurns()`. React does not guarantee a functional state updater has already run by the time `setState()` returns -- especially here, since every event arrives from an async SSE stream-reader loop, not a synchronous React event handler. The live trace showed exactly this: a `tool_result` event for a successful write fired with `shouldMutate` still `false`, because the updater responsible for setting it hadn't executed yet at the point it was read. `onMutated()` was silently never called. ## Fix Track the currently-running tool's input in a `useRef` (synchronous, no React-scheduling dependency) instead of deriving it from state-updater side effects. `pathsToTouch`/`shouldMutate` are now computed BEFORE `setTurns` is even called, from the ref, eliminating the timing hazard entirely. `setTurns`'s updater now only updates the rendered block list, with no side-channel data dependency. ## Verification - Reproduced the exact broken behavior live (pre-fix): a real write_file call succeeded on disk, but the Explorer's follow-up list request never fired. - Re-ran the identical live scenario post-fix: the newly-created file appeared in the Explorer immediately with zero manual reload. - New regression guard in assistant.test.ts (source-level, matching this file's existing convention for frontend logic with no React Testing Library/jsdom harness in this project): confirms the mutation-flag computation is ordered before the setTurns call and uses the new ref, not state-updater side effects. - Full suite 233/233, tsc --noEmit clean, lint baseline unchanged, next build clean. --- app/src/components/editor/AssistantPanel.tsx | 28 ++++++++++++++--- app/tests/assistant.test.ts | 32 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/src/components/editor/AssistantPanel.tsx b/app/src/components/editor/AssistantPanel.tsx index 53b6afd..5c19858 100644 --- a/app/src/components/editor/AssistantPanel.tsx +++ b/app/src/components/editor/AssistantPanel.tsx @@ -102,10 +102,34 @@ export function AssistantPanel({ useEffect(() => () => abortRef.current?.abort(), []); + // Tracks the input of whichever tool call is currently "running", keyed by + // tool name, so a matching `tool_result` can compute pathsToTouch/ + // shouldMutate synchronously -- a ref, not React state. The previous + // version derived these from *inside* the `setTurns` state-updater + // callback and read them immediately after calling `setTurns()`; React + // does not guarantee that updater has actually run by then (especially + // here, since every event arrives from an async stream-reader loop, not + // a synchronous React event handler), so `onMutated()` was silently + // skipped on every real write -- confirmed live: a real write_file call + // succeeded (file landed on disk, Claude correctly reported success) but + // the Explorer never refreshed, because `shouldMutate` was still read as + // `false` at the point `onMutated()` was gated on it. + const runningToolInputRef = useRef>>(new Map()); + const applyEvent = useCallback( (event: AssistantEvent) => { + if (event.kind === "tool_call") runningToolInputRef.current.set(event.tool, event.input); + let pathsToTouch: string[] = []; let shouldMutate = false; + if (event.kind === "tool_result" && event.ok) { + const input = runningToolInputRef.current.get(event.tool); + if (input) { + pathsToTouch = pathsTouchedBy(event.tool, input); + shouldMutate = true; + } + } + if (event.kind === "tool_result") runningToolInputRef.current.delete(event.tool); setTurns((prev) => { const next = [...prev]; @@ -129,10 +153,6 @@ export function AssistantPanel({ const b = blocks[i]; if (b.kind === "tool" && b.tool === event.tool && b.status === "running") { blocks[i] = { ...b, status: event.ok ? "ok" : "error", summary: event.summary }; - if (event.ok) { - pathsToTouch = pathsTouchedBy(b.tool, b.input); - shouldMutate = true; - } break; } } diff --git a/app/tests/assistant.test.ts b/app/tests/assistant.test.ts index 36befdd..4723342 100644 --- a/app/tests/assistant.test.ts +++ b/app/tests/assistant.test.ts @@ -184,3 +184,35 @@ describe("assistant.ts sendMessage — CLI auth-failure text detection", () => { expect(src).toContain("This server's Claude subscription login isn't available right now"); }); }); + +// Regression guard for a real, live-reproduced bug: AssistantPanel.tsx's +// `applyEvent` used to mutate `pathsToTouch`/`shouldMutate` *inside* the +// `setTurns` state-updater callback, then read those same variables +// immediately after calling `setTurns()`. React does not guarantee an +// updater function has actually run by the time `setState()` returns +// (especially here, since every event arrives from an async stream-reader +// loop, not a synchronous React event handler) — so `onMutated()` was +// silently skipped on every real successful write. Reproduced live end- +// to-end (real browser, real SSE stream, a fake OpenAI-compatible server +// standing in for the LLM): a `write_file` call genuinely succeeded (the +// file landed on disk, the assistant correctly reported success), but the +// file explorer never refreshed to show it. No React Testing Library/jsdom +// harness exists in this project to drive AssistantPanel directly (see the +// permission-mode and CLI-auth-detection guards above for the same +// convention), so this locks in the fix at the source level: the mutation +// flags must be computed from a synchronous ref BEFORE `setTurns` is +// called, never read from variables a state updater was responsible for +// setting. +describe("AssistantPanel.tsx applyEvent — no setState-timing hazard on file-mutation detection", () => { + it("computes pathsToTouch/shouldMutate before calling setTurns, via a ref, not by reading variables a state updater sets", () => { + const src = readFileSync(path.join(__dirname, "..", "src", "components", "editor", "AssistantPanel.tsx"), "utf8"); + const setTurnsIdx = src.indexOf("setTurns((prev) => {\n const next = [...prev];\n const last = next[next.length - 1];\n if (!last || last.role !== \"assistant\")"); + const pathsIdx = src.indexOf("let pathsToTouch"); + expect(setTurnsIdx).toBeGreaterThan(-1); + expect(pathsIdx).toBeGreaterThan(-1); + // The mutation-flag computation must appear BEFORE the setTurns call in + // source order -- i.e. it no longer depends on that updater having run. + expect(pathsIdx).toBeLessThan(setTurnsIdx); + expect(src).toContain("runningToolInputRef"); + }); +});