fix(studio): refresh the file tree on external change, and say when a composition is gone (#4370)

The fix the cutover-dashboard investigation this week was building toward.
Proven 2026-09-23: `refreshFileTree` (useFileTree) is only ever called from
Studio's OWN file operations (create/delete/rename/upload/block install) —
never on an external change. An agent removing or replacing a composition
updates the preview and the SDK session (useExternalFileChangeCoordinator
already reloads both) but never the tree, so the file stays listed and
clickable forever. Opening it opens a session that can never resolve
(`reason: "absent"`), every edit through it silently takes the (also
failing) server path, and nothing tells the user why.

Two refresh triggers, belt and braces:

- `useExternalFileChangeCoordinator` gains an optional `refreshFileTree`
  collaborator, called alongside `reloadPreview`/`reloadSdkSession` on
  every accepted external change — closes staleness at the source, for
  every consumer of the tree, not just the SDK read. Wired through
  `useStudioExternalFileChanges` from `fileManager.refreshFileTree`
  (already in scope there).

- `useSdkSession` gains `onAbsentRead`, called once per (projectId, path)
  when a read reports `reason: "absent"` — the fallback for a change the
  SSE never delivered (server restart, a missed watcher event). Guarded by
  a ref-tracked set so a genuinely-gone file can't loop, cleared on
  project change so a different project's identical path can still fire.

New: `sdkHandle.compositionMissing`, true for exactly that read result on
the currently active path, and `CompositionMissingBanner` — sibling of
`ProjectUnreachableBanner` (that one fires when the whole PROJECT is
unreachable; this one when the project is fine and this one file inside
it is not) — shown in App.tsx alongside it. The read failure alone is
silent; this is what tells the user their edits stopped landing.

Deliberately not touched: `activeCompPath` is never cleared when the path
leaves the tree — the graveyard page (`clearing-activecomppath-on-tree-
absence`) is binding, and its reasons still hold (`useAutoOpenRootComposition`
latches once; a null path never recovers).

Tests: coordinator refresh fires once per accepted change, not for a
self-write echo, and no-ops when the collaborator is absent (4 new).
useSdkSession: compositionMissing set/cleared, onAbsentRead once per path,
fires again for a different path, guard resets on project change, no-ops
without the collaborator (5 new). Banner render test (1 new). 25/25 in the
touched suites; 2830/2831 across studio hooks+components overall (the one
failure is a pre-existing happy-dom `window.confirm` gap in
ExternalFileConflictBanner.test.tsx, confirmed unrelated via git stash).
oxlint, oxfmt, tsc --noEmit clean.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-09-23 20:13:21 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent ff884845a8
commit c59326d72e
9 changed files with 333 additions and 10 deletions
+5
View File
@@ -8,6 +8,7 @@ import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
import { ProjectUnreachableBanner } from "./components/ProjectUnreachableBanner";
import { CompositionMissingBanner } from "./components/CompositionMissingBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -132,6 +133,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
masterCompPath,
fileManager.fileTree,
fileManager.fileTreeLoaded,
fileManager.refreshFileTree,
);
const activeCompPathRef = useRef(activeCompPath);
activeCompPathRef.current = activeCompPath;
@@ -481,6 +483,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
{sdkHandle.unreachableProject && (
<ProjectUnreachableBanner projectId={sdkHandle.unreachableProject} />
)}
{sdkHandle.compositionMissing && activeCompPath && (
<CompositionMissingBanner path={activeCompPath} />
)}
<EditorShell
readOnlyPreview={readOnlyPreview}
readOnlyPreviewReason={readOnlyPreviewReason}
@@ -0,0 +1,35 @@
// fallow-ignore-file code-duplication
// The mount/unmount harness matches ProjectUnreachableBanner.test.tsx's — the
// same small React-root scaffold every banner test in this directory uses.
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { CompositionMissingBanner } from "./CompositionMissingBanner";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.replaceChildren();
});
describe("CompositionMissingBanner", () => {
it("names the missing composition and does not claim the whole project is gone", async () => {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => {
root?.render(<CompositionMissingBanner path="scenes/intro.html" />);
});
expect(host.textContent).toContain("scenes/intro.html");
expect(host.textContent).toContain("no longer on disk");
// Distinct from ProjectUnreachableBanner's wording — this is one file
// inside a project that resolves fine, not the whole project.
expect(host.textContent).not.toContain("This Studio is serving");
});
});
@@ -0,0 +1,32 @@
interface CompositionMissingBannerProps {
/** The composition this tab has open, that the server can no longer find. */
path: string;
}
/**
* Shown when the currently open composition's file has vanished from disk
* mid-session — the `absent` read reason (#4325): the file tree listed it and
* the read cannot get it.
*
* Distinct from `ProjectUnreachableBanner`: there the whole PROJECT fails to
* resolve; here the project is fine and this one file inside it is not. Proven
* 2026-09-23 to be a stale tree, not a placeholder or a path-encoding gap:
* `refreshFileTree` only ran after Studio's own file operations, so an agent
* removing or replacing a composition updated the preview and the SDK session
* but never the listing — the file stayed clickable, and every edit through
* it failed silently, with nothing to tell the user why. The tree is now
* refreshed as soon as this fires (`useSdkSession`'s `onAbsentRead`); this
* banner is the only thing that explains the failure while that catches up.
*/
export function CompositionMissingBanner({ path }: CompositionMissingBannerProps) {
return (
<div
role="alert"
className="hf-backdrop-in absolute left-1/2 top-14 z-92 flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-amber-500/30 bg-amber-950/85 px-4 py-2 text-[12px] font-medium text-amber-100 shadow-lg shadow-black/30"
>
<span>
<strong>{path}</strong> is no longer on disk. Select another composition to keep editing.
</span>
</div>
);
}
@@ -74,9 +74,44 @@ describe("external file change coordinator", () => {
onAcceptedPersistedFileChange: () => order.push("thumbnail"),
reloadPreview: () => order.push("preview"),
reloadSdkSession: () => order.push("sdk"),
refreshFileTree: () => {
order.push("tree");
},
});
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(order).toEqual(["drain", "thumbnail", "preview", "sdk"]);
expect(order).toEqual(["drain", "thumbnail", "preview", "sdk", "tree"]);
expect(captured.handle?.blocked).toBeNull();
});
// The file tree (useFileTree) is only ever refreshed from Studio's own file
// operations (create/delete/rename/upload) — never on an external change.
// Without this call, an agent removing or replacing a composition updates
// the preview and the SDK session but leaves the listing stale forever.
it("refreshes the file tree on an accepted external change", async () => {
const refreshFileTree = vi.fn();
await mountCoordinator({ refreshFileTree });
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(refreshFileTree).toHaveBeenCalledOnce();
});
it("does not refresh the tree for a suppressed self-write echo", async () => {
const refreshFileTree = vi.fn();
await mountCoordinator({ refreshFileTree });
markStudioWriteToken("studio-write-1");
await act(async () =>
handler?.({
path: "index.html",
content: "studio",
version: "v2",
writeToken: "studio-write-1",
}),
);
expect(refreshFileTree).not.toHaveBeenCalled();
});
it("is optional — an accepted change with no refreshFileTree collaborator does not throw", async () => {
const { captured } = await mountCoordinator({ refreshFileTree: undefined });
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(captured.handle?.blocked).toBeNull();
});
@@ -64,6 +64,17 @@ interface ExternalFileChangeCoordinatorOptions {
path: string,
affectedCompositions: readonly string[] | null,
) => void;
/**
* Called alongside `reloadPreview`/`reloadSdkSession` on every accepted
* external change. The file tree (`useFileTree`) is only ever refreshed
* from Studio's OWN file operations (create/delete/rename/upload) — an
* external change (an agent writing outside Studio) reloads the preview
* and the SDK session but, without this, never the listing. A composition
* an agent removed or replaced then stays in the tree until the user does
* a Studio-side file op or reloads the tab; clicking it opens a session
* that can never resolve (`reason: "absent"`, proven stale-tree 2026-09-23).
*/
refreshFileTree?: () => void | Promise<void>;
}
export interface ExternalFileChangeCoordinatorHandle {
@@ -149,6 +160,7 @@ export function useExternalFileChangeCoordinator({
onUseExternalFile,
resetSaveQueues,
onAcceptedPersistedFileChange,
refreshFileTree,
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
const generationRef = useRef(0);
@@ -223,8 +235,14 @@ export function useExternalFileChangeCoordinator({
logReload("reload", { path, by: "external-change coordinator" });
reloadPreview();
reloadSdkSession(path);
// Fire-and-forget: a failed refresh leaves the tree as stale as it was,
// which is the status quo this exists to improve on, not a new failure
// mode to surface. The `absent`-triggered fallback in useSdkSession
// covers the case where this call is missed entirely (server restart,
// a watcher event the SSE never delivered).
void refreshFileTree?.();
},
[reloadPreview, reloadSdkSession],
[reloadPreview, reloadSdkSession, refreshFileTree],
);
const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
@@ -587,6 +587,152 @@ describe("useSdkSession unavailable telemetry", () => {
await act(async () => rootB.unmount());
});
// `compositionMissing` and the once-per-path refresh fallback: proven
// 2026-09-23 that `absent` means a stale tree (refreshFileTree only runs
// after Studio's own file ops, never on an external change), so an `absent`
// read is the one signal that should make the tree self-correct even when
// the SSE-driven refresh in useExternalFileChangeCoordinator is missed.
describe("compositionMissing and the absent-read refresh fallback", () => {
function HandleProbe({
projectId,
path,
onAbsentRead,
}: {
projectId: string;
path: string;
onAbsentRead?: (path: string) => void;
}) {
captured.handle = useSdkSession(projectId, path, [], false, onAbsentRead);
return null;
}
const captured: { handle: SdkSessionHandle | null } = { handle: null };
it("sets compositionMissing and calls onAbsentRead once for an absent read", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
),
);
const onAbsentRead = vi.fn();
const root = createRoot(document.createElement("div"));
await act(async () =>
root.render(
<HandleProbe projectId="project-a" path="index.html" onAbsentRead={onAbsentRead} />,
),
);
await flushAsyncEffects();
expect(captured.handle?.compositionMissing).toBe(true);
expect(onAbsentRead).toHaveBeenCalledOnce();
expect(onAbsentRead).toHaveBeenCalledWith("index.html");
// A second absent read for the SAME path must not refresh again — the
// refresh already ran and didn't fix it (the file really is gone).
await act(async () => {
captured.handle?.forceReload();
});
await flushAsyncEffects();
expect(onAbsentRead).toHaveBeenCalledOnce();
await act(async () => root.unmount());
});
it("calls onAbsentRead again for a different path", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
),
);
const onAbsentRead = vi.fn();
const root = createRoot(document.createElement("div"));
await act(async () =>
root.render(
<HandleProbe projectId="project-a" path="scenes/a.html" onAbsentRead={onAbsentRead} />,
),
);
await flushAsyncEffects();
await act(async () =>
root.render(
<HandleProbe projectId="project-a" path="scenes/b.html" onAbsentRead={onAbsentRead} />,
),
);
await flushAsyncEffects();
expect(onAbsentRead).toHaveBeenCalledTimes(2);
expect(onAbsentRead).toHaveBeenNthCalledWith(1, "scenes/a.html");
expect(onAbsentRead).toHaveBeenNthCalledWith(2, "scenes/b.html");
await act(async () => root.unmount());
});
it("resets the guard on project change, so the same path can refresh again", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
),
);
const onAbsentRead = vi.fn();
const root = createRoot(document.createElement("div"));
await act(async () =>
root.render(
<HandleProbe projectId="project-a" path="index.html" onAbsentRead={onAbsentRead} />,
),
);
await flushAsyncEffects();
await act(async () =>
root.render(
<HandleProbe projectId="project-b" path="index.html" onAbsentRead={onAbsentRead} />,
),
);
await flushAsyncEffects();
expect(onAbsentRead).toHaveBeenCalledTimes(2);
await act(async () => root.unmount());
});
it("clears compositionMissing once a later read succeeds", async () => {
const fetchMock = vi.fn(
async () => ({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
);
vi.stubGlobal("fetch", fetchMock);
openComposition.mockResolvedValue(fakeSession());
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<HandleProbe projectId="project-a" path="index.html" />));
await flushAsyncEffects();
expect(captured.handle?.compositionMissing).toBe(true);
fetchMock.mockImplementation(async () => response("PROJECT_A"));
await act(async () => {
captured.handle?.forceReload();
});
await flushAsyncEffects();
expect(captured.handle?.compositionMissing).toBe(false);
await act(async () => root.unmount());
});
it("does not throw when onAbsentRead is not supplied", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
),
);
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<HandleProbe projectId="project-a" path="index.html" />));
await flushAsyncEffects();
expect(captured.handle?.compositionMissing).toBe(true);
await act(async () => root.unmount());
});
});
it("reports a parse failure with its message", async () => {
vi.stubGlobal(
"fetch",
+56 -8
View File
@@ -273,6 +273,16 @@ export interface SdkSessionHandle {
* `missing_content` say nothing about the project at all.
*/
unreachableProject: string | null;
/**
* True when the most recent read of `activeCompPath` reported `reason:
* "absent"` — the file tree lists this path and the server cannot get it.
* Proven 2026-09-23 to mean a stale tree: `refreshFileTree` only runs after
* Studio's own file operations, so an external change (an agent removing
* or replacing the file) updates the preview and this session but never
* the listing, and the file stays clickable forever. Every edit then fails
* silently with nothing to tell the user why — this is that signal.
*/
compositionMissing: boolean;
}
interface SdkSessionOwner {
@@ -345,6 +355,12 @@ export function useSdkSession(
// reports `null`, same as before the tree loads.
fileTree: readonly string[] = [],
fileTreeLoaded = false,
// Fallback for the SSE-driven refresh in useExternalFileChangeCoordinator:
// called at most once per (projectId, path) so the tree self-corrects even
// when that delivery is missed (server restart, a watcher event the SSE
// never sent). Optional for the same reason fileTree is: a secondary
// session has no tree of its own to refresh.
onAbsentRead?: (path: string) => void,
): SdkSessionHandle {
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
@@ -358,6 +374,35 @@ export function useSdkSession(
const reloadTokenRef = useRef(reloadToken);
reloadTokenRef.current = reloadToken;
const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
const [compositionMissing, setCompositionMissing] = useState(false);
// Keyed `${projectId}:${path}` so a refresh that doesn't fix it (the file
// really is gone) can't loop, and so it fires again for a genuinely
// different path or project.
const refreshedAbsentPathsRef = useRef<Set<string>>(new Set());
useEffect(() => {
refreshedAbsentPathsRef.current.clear();
}, [projectId]);
/**
* Update `unreachableProject`/`compositionMissing` for one failed read, and
* fire the once-per-path tree-refresh fallback on `absent`. Pulled out of
* the open effect's `.then` — the branching there was pushing that
* callback over the complexity threshold on its own.
*/
function handleReadFailure(
read: ProjectFileReadFailure,
forProjectId: string,
forPath: string,
): void {
const pathInTree = fileTreeLoaded ? fileTree.includes(forPath) : null;
setUnreachableProject(reportReadFailure(read, forProjectId, pathInTree));
setCompositionMissing(read.reason === "absent");
if (read.reason !== "absent") return;
const key = `${forProjectId}:${forPath}`;
if (refreshedAbsentPathsRef.current.has(key)) return;
refreshedAbsentPathsRef.current.add(key);
onAbsentRead?.(forPath);
}
useEffect(
() =>
@@ -381,6 +426,7 @@ export function useSdkSession(
if (!projectId || !activeCompPath) {
setUnreachableProject(null);
setCompositionMissing(false);
return () => {
cancelled = true;
};
@@ -397,11 +443,11 @@ export function useSdkSession(
.then(async (read) => {
if (cancelled) return;
if (!read.ok) {
const pathInTree = fileTreeLoaded ? fileTree.includes(activeCompPath) : null;
setUnreachableProject(reportReadFailure(read, projectId, pathInTree));
handleReadFailure(read, projectId, activeCompPath);
return;
}
setUnreachableProject(null);
setCompositionMissing(false);
const content = read.content;
// No persist queue: Studio's writeProjectFile (via sdkCutover's
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
@@ -469,11 +515,13 @@ export function useSdkSession(
disposeSdkSession(owned.session);
}
};
// fileTree/fileTreeLoaded deliberately excluded: they only annotate a
// read-failure event fired from this same effect run (the diagnostic
// "was the path in the last-loaded tree" snapshot), and re-running the
// whole open/dispose cycle on every tree refresh would drop and reopen a
// perfectly good session far more often than the tree actually changes.
// fileTree/fileTreeLoaded/onAbsentRead deliberately excluded: they only
// annotate or react to a read-failure event fired from this same effect
// run (the diagnostic "was the path in the last-loaded tree" snapshot,
// and the tree-refresh fallback), and re-running the whole open/dispose
// cycle on every tree refresh or on every render (callers are not
// required to memoize onAbsentRead) would drop and reopen a perfectly
// good session far more often than the tree actually changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, activeCompPath, reloadToken]);
@@ -511,5 +559,5 @@ export function useSdkSession(
ownedSession.reloadToken === reloadToken
? ownedSession.session
: null;
return { session, publish, forceReload, unreachableProject };
return { session, publish, forceReload, unreachableProject, compositionMissing };
}
@@ -20,6 +20,7 @@ type FileManager = Pick<
| "overwriteExternalConflict"
| "readProjectFile"
| "updateEditingFileContent"
| "refreshFileTree"
>;
type PreviewPersistence = Pick<
@@ -85,5 +86,6 @@ export function useStudioExternalFileChanges({
onUseExternalFile: fileManager.updateEditingFileContent,
resetSaveQueues: resetDomEditSaveQueueBreaker,
onAcceptedPersistedFileChange,
refreshFileTree: fileManager.refreshFileTree,
});
}
@@ -20,6 +20,7 @@ export function useStudioSdkSessions(
masterCompPath: string | null,
fileTree: readonly string[] = [],
fileTreeLoaded = false,
refreshFileTree?: () => void | Promise<void>,
) {
// On the master view (no explicit comp) the schema panels target the project's
// resolved main composition — the first `.html` in the tree, not a hardcoded
@@ -30,6 +31,7 @@ export function useStudioSdkSessions(
activeCompPath ?? masterCompPath,
fileTree,
fileTreeLoaded,
refreshFileTree,
);
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
useEffect(() => {