authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-30 18:07:20-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 18:07:20-04:00
loge09b88b6a51bb7e4309498f01c2f36bf5ada43f4
tree2f1c898ca49d994ac8884b1357a1cae59dba8cfa
parent5885a68adb61805460047512040838bbb5e7ed65
signature Signed by PGP key B5690EEEBB952194

fix(web): keep right panel synced with agent edits (#8803)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

7 files changed, 382 insertions(+), 25 deletions(-)

apps/web/src/components/ChatView.tsx+22
...@@ -117,6 +117,10 @@ import {...@@ -117,6 +117,10 @@ import {
117 type PendingUserInputDraftAnswer,117 type PendingUserInputDraftAnswer,
118} from "../pendingUserInput";118} from "../pendingUserInput";
119import { useUiStateStore } from "../uiStateStore";119import { useUiStateStore } from "../uiStateStore";
120import {
121 latestWorkspaceMutationId,
122 useWorkspaceMutationRefresh,
123} from "../hooks/useWorkspaceMutationRefresh";
120import {124import {
121 buildPlanImplementationThreadTitle,125 buildPlanImplementationThreadTitle,
122 buildPlanImplementationPrompt,126 buildPlanImplementationPrompt,
...@@ -2343,6 +2347,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2343,6 +2347,13 @@ function ChatViewContent(props: ChatViewProps) {
2343 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;2347 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
2344 const phase = derivePhase(activeThread?.session ?? null);2348 const phase = derivePhase(activeThread?.session ?? null);
2345 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;2349 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2350 const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null;
2351 const workspaceMutationId = useMemo(() => {
2352 const activityId = latestWorkspaceMutationId(threadActivities);
2353 return activityId === null && latestCheckpointCompletedAt === null
2354 ? null
2355 : JSON.stringify([activityId, latestCheckpointCompletedAt]);
2356 }, [latestCheckpointCompletedAt, threadActivities]);
2346 const activeContextWindow = useMemo(2357 const activeContextWindow = useMemo(
2347 () => deriveLatestContextWindowSnapshot(threadActivities),2358 () => deriveLatestContextWindowSnapshot(threadActivities),
2348 [threadActivities],2359 [threadActivities],
...@@ -2896,6 +2907,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2896,6 +2907,12 @@ function ChatViewContent(props: ChatViewProps) {
2896 input: { cwd: gitStatusCwd },2907 input: { cwd: gitStatusCwd },
2897 }),2908 }),
2898 );2909 );
2910 useWorkspaceMutationRefresh({
2911 enabled: gitStatusCwd !== null,
2912 mutationId: workspaceMutationId,
2913 refresh: gitStatusQuery.refresh,
2914 resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,
2915 });
2899 const keybindings = useAtomValue(primaryServerKeybindingsAtom);2916 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2900 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);2917 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
2901 // Prefer an instance-id match so a custom Codex instance (e.g.2918 // Prefer an instance-id match so a custom Codex instance (e.g.
...@@ -7027,6 +7044,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7027,6 +7044,7 @@ function ChatViewContent(props: ChatViewProps) {
7027 mode="embedded"7044 mode="embedded"
7028 composerDraftTarget={composerDraftTarget}7045 composerDraftTarget={composerDraftTarget}
7029 initialGitScope={initialDiffPanelGitScope}7046 initialGitScope={initialDiffPanelGitScope}
7047 workspaceMutationId={workspaceMutationId}
7030 />7048 />
7031 </Suspense>7049 </Suspense>
7032 ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (7050 ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
...@@ -7095,6 +7113,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7095,6 +7113,10 @@ function ChatViewContent(props: ChatViewProps) {
7095 revealRequestId={activeFileSurface?.revealRequestId ?? 0}7113 revealRequestId={activeFileSurface?.revealRequestId ?? 0}
7096 onOpenFile={openFileSurface}7114 onOpenFile={openFileSurface}
7097 onPendingChange={handleFilePendingChange}7115 onPendingChange={handleFilePendingChange}
7116 selectedFilePending={
7117 activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id)
7118 }
7119 workspaceMutationId={workspaceMutationId}
7098 />7120 />
7099 </Suspense>7121 </Suspense>
7100 ) : null7122 ) : null
apps/web/src/components/DiffPanel.tsx+9-21
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39} from "../lib/diffRendering";39} from "../lib/diffRendering";
40import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";40import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
41import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";41import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
42import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
42import { useProject, useThread } from "../state/entities";43import { useProject, useThread } from "../state/entities";
43import { resolveThreadRouteRef } from "../threadRoutes";44import { resolveThreadRouteRef } from "../threadRoutes";
44import { useClientSettings } from "../hooks/useSettings";45import { useClientSettings } from "../hooks/useSettings";
...@@ -90,6 +91,7 @@ interface DiffPanelProps {...@@ -90,6 +91,7 @@ interface DiffPanelProps {
90 mode?: DiffPanelMode;91 mode?: DiffPanelMode;
91 composerDraftTarget: ScopedThreadRef | DraftId;92 composerDraftTarget: ScopedThreadRef | DraftId;
92 initialGitScope: "branch" | "unstaged";93 initialGitScope: "branch" | "unstaged";
94 workspaceMutationId: string | null;
93}95}
9496
95export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";97export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
...@@ -98,6 +100,7 @@ export default function DiffPanel({...@@ -98,6 +100,7 @@ export default function DiffPanel({
98 mode = "inline",100 mode = "inline",
99 composerDraftTarget,101 composerDraftTarget,
100 initialGitScope: initialGitScopeProp,102 initialGitScope: initialGitScopeProp,
103 workspaceMutationId,
101}: DiffPanelProps) {104}: DiffPanelProps) {
102 const { resolvedTheme } = useTheme();105 const { resolvedTheme } = useTheme();
103 const settings = useClientSettings();106 const settings = useClientSettings();
...@@ -113,10 +116,6 @@ export default function DiffPanel({...@@ -113,10 +116,6 @@ export default function DiffPanel({
113 }));116 }));
114 const [codeViewRevision, setCodeViewRevision] = useState(0);117 const [codeViewRevision, setCodeViewRevision] = useState(0);
115 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);118 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
116 const lastCompletedTurnRefreshRef = useRef<{
117 readonly threadKey: string | null;
118 readonly turnId: TurnId | null;
119 } | null>(null);
120119
121 const routeThreadRef = useParams({120 const routeThreadRef = useParams({
122 strict: false,121 strict: false,
...@@ -290,23 +289,12 @@ export default function DiffPanel({...@@ -290,23 +289,12 @@ export default function DiffPanel({
290 return () => window.removeEventListener("focus", refreshOnFocus);289 return () => window.removeEventListener("focus", refreshOnFocus);
291 }, [canRefreshGitDiff, refreshBranchDiffPreview]);290 }, [canRefreshGitDiff, refreshBranchDiffPreview]);
292291
293 useEffect(() => {292 useWorkspaceMutationRefresh({
294 const current = {293 enabled: canRefreshGitDiff,
295 threadKey: activeThreadRefreshKey,294 mutationId: workspaceMutationId,
296 turnId: latestTurn?.turnId ?? null,295 refresh: refreshBranchDiffPreview,
297 };296 resourceKey: `diff:${activeThreadRefreshKey ?? ""}`,
298 const previous = lastCompletedTurnRefreshRef.current;297 });
299 if (!canRefreshGitDiff) {
300 return;
301 }
302 if (previous === null || previous.threadKey !== current.threadKey) {
303 lastCompletedTurnRefreshRef.current = current;
304 return;
305 }
306 if (previous.turnId === current.turnId) return;
307 refreshBranchDiffPreview();
308 lastCompletedTurnRefreshRef.current = current;
309 }, [activeThreadRefreshKey, canRefreshGitDiff, latestTurn?.turnId, refreshBranchDiffPreview]);
310298
311 const selectedGitSource = branchDiffPreview.data?.sources.find(299 const selectedGitSource = branchDiffPreview.data?.sources.find(
312 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),300 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
apps/web/src/components/files/FileBrowserPanel.tsx+8
...@@ -15,6 +15,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";...@@ -15,6 +15,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
15import { useComposerHandleContext } from "~/composerHandleContext";15import { useComposerHandleContext } from "~/composerHandleContext";
16import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";16import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
17import { useTheme } from "~/hooks/useTheme";17import { useTheme } from "~/hooks/useTheme";
18import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
18import { cn } from "~/lib/utils";19import { cn } from "~/lib/utils";
19import { readLocalApi } from "~/localApi";20import { readLocalApi } from "~/localApi";
20import { T3_PIERRE_ICONS } from "~/pierre-icons";21import { T3_PIERRE_ICONS } from "~/pierre-icons";
...@@ -32,6 +33,7 @@ interface FileBrowserPanelProps {...@@ -32,6 +33,7 @@ interface FileBrowserPanelProps {
32 selectedPathRevealId: number;33 selectedPathRevealId: number;
33 onOpenFile: (relativePath: string) => void;34 onOpenFile: (relativePath: string) => void;
34 onRefreshSelectedFile?: () => void;35 onRefreshSelectedFile?: () => void;
36 workspaceMutationId: string | null;
35}37}
3638
37const TREE_UNSAFE_CSS = `39const TREE_UNSAFE_CSS = `
...@@ -107,6 +109,7 @@ export default function FileBrowserPanel({...@@ -107,6 +109,7 @@ export default function FileBrowserPanel({
107 selectedPathRevealId,109 selectedPathRevealId,
108 onOpenFile,110 onOpenFile,
109 onRefreshSelectedFile,111 onRefreshSelectedFile,
112 workspaceMutationId,
110}: FileBrowserPanelProps) {113}: FileBrowserPanelProps) {
111 const { resolvedTheme } = useTheme();114 const { resolvedTheme } = useTheme();
112 const composerRef = useComposerHandleContext();115 const composerRef = useComposerHandleContext();
...@@ -260,6 +263,11 @@ export default function FileBrowserPanel({...@@ -260,6 +263,11 @@ export default function FileBrowserPanel({
260 entriesQuery.refresh();263 entriesQuery.refresh();
261 onRefreshSelectedFile?.();264 onRefreshSelectedFile?.();
262 };265 };
266 useWorkspaceMutationRefresh({
267 mutationId: workspaceMutationId,
268 refresh: entriesQuery.refresh,
269 resourceKey: `files:${environmentId}:${cwd}`,
270 });
263271
264 useEffect(() => {272 useEffect(() => {
265 if (previousTreePathsRef.current === treePaths) return;273 if (previousTreePathsRef.current === treePaths) return;
apps/web/src/components/files/FilePreviewPanel.tsx+23-4
...@@ -23,6 +23,7 @@ import { useRemoteOpenState } from "~/remoteOpen";...@@ -23,6 +23,7 @@ import { useRemoteOpenState } from "~/remoteOpen";
23import { useClientSettings } from "~/hooks/useSettings";23import { useClientSettings } from "~/hooks/useSettings";
24import { useTheme } from "~/hooks/useTheme";24import { useTheme } from "~/hooks/useTheme";
25import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";25import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
26import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
26import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering";27import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering";
27import { cn } from "~/lib/utils";28import { cn } from "~/lib/utils";
28import { isPreviewSupportedInRuntime } from "~/previewStateStore";29import { isPreviewSupportedInRuntime } from "~/previewStateStore";
...@@ -78,6 +79,8 @@ interface FilePreviewPanelProps {...@@ -78,6 +79,8 @@ interface FilePreviewPanelProps {
78 revealRequestId: number;79 revealRequestId: number;
79 onOpenFile: (relativePath: string) => void;80 onOpenFile: (relativePath: string) => void;
80 onPendingChange: (relativePath: string, pending: boolean) => void;81 onPendingChange: (relativePath: string, pending: boolean) => void;
82 selectedFilePending: boolean;
83 workspaceMutationId: string | null;
81}84}
8285
83const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";86const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
...@@ -133,6 +136,7 @@ function WorkspaceImagePreview(props: {...@@ -133,6 +136,7 @@ function WorkspaceImagePreview(props: {
133 readonly threadRef: ScopedThreadRef;136 readonly threadRef: ScopedThreadRef;
134 readonly absolutePath: string;137 readonly absolutePath: string;
135 readonly alt: string;138 readonly alt: string;
139 readonly workspaceMutationId: string | null;
136}) {140}) {
137 const assetUrl = useAssetUrlState(props.environmentId, {141 const assetUrl = useAssetUrlState(props.environmentId, {
138 _tag: "workspace-file",142 _tag: "workspace-file",
...@@ -140,8 +144,13 @@ function WorkspaceImagePreview(props: {...@@ -140,8 +144,13 @@ function WorkspaceImagePreview(props: {
140 path: props.absolutePath,144 path: props.absolutePath,
141 });145 });
142 const [failedUrl, setFailedUrl] = useState<string | null>(null);146 const [failedUrl, setFailedUrl] = useState<string | null>(null);
147 const revisionSuffix =
148 props.workspaceMutationId === null
149 ? ""
150 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
151 const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
143152
144 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {153 if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) {
145 return (154 return (
146 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">155 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
147 Unable to load workspace image.156 Unable to load workspace image.
...@@ -149,13 +158,13 @@ function WorkspaceImagePreview(props: {...@@ -149,13 +158,13 @@ function WorkspaceImagePreview(props: {
149 );158 );
150 }159 }
151160
152 return assetUrl._tag === "Success" ? (161 return assetUrl._tag === "Success" && imageUrl !== null ? (
153 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">162 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
154 <img163 <img
155 className="max-h-full max-w-full object-contain"164 className="max-h-full max-w-full object-contain"
156 src={assetUrl.url}165 src={imageUrl}
157 alt={props.alt}166 alt={props.alt}
158 onError={() => setFailedUrl(assetUrl.url)}167 onError={() => setFailedUrl(imageUrl)}
159 />168 />
160 </div>169 </div>
161 ) : (170 ) : (
...@@ -768,6 +777,8 @@ export default function FilePreviewPanel({...@@ -768,6 +777,8 @@ export default function FilePreviewPanel({
768 revealRequestId,777 revealRequestId,
769 onOpenFile,778 onOpenFile,
770 onPendingChange,779 onPendingChange,
780 selectedFilePending,
781 workspaceMutationId,
771}: FilePreviewPanelProps) {782}: FilePreviewPanelProps) {
772 const { resolvedTheme } = useTheme();783 const { resolvedTheme } = useTheme();
773 const wordWrap = useClientSettings((settings) => settings.wordWrap);784 const wordWrap = useClientSettings((settings) => settings.wordWrap);
...@@ -812,6 +823,12 @@ export default function FilePreviewPanel({...@@ -812,6 +823,12 @@ export default function FilePreviewPanel({
812 [projectName, relativePath],823 [projectName, relativePath],
813 );824 );
814 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);825 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
826 useWorkspaceMutationRefresh({
827 enabled: relativePath !== null && !isImage && !selectedFilePending,
828 mutationId: workspaceMutationId,
829 refresh: file.refresh,
830 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
831 });
815832
816 useEffect(() => {833 useEffect(() => {
817 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(834 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
...@@ -1001,6 +1018,7 @@ export default function FilePreviewPanel({...@@ -1001,6 +1018,7 @@ export default function FilePreviewPanel({
1001 threadRef={threadRef}1018 threadRef={threadRef}
1002 absolutePath={absolutePath}1019 absolutePath={absolutePath}
1003 alt={relativePath}1020 alt={relativePath}
1021 workspaceMutationId={workspaceMutationId}
1004 />1022 />
1005 ) : relativePath && file.error && file.data === null ? (1023 ) : relativePath && file.error && file.data === null ? (
1006 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">1024 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
...@@ -1080,6 +1098,7 @@ export default function FilePreviewPanel({...@@ -1080,6 +1098,7 @@ export default function FilePreviewPanel({
1080 selectedPath={relativePath}1098 selectedPath={relativePath}
1081 selectedPathRevealId={revealRequestId}1099 selectedPathRevealId={revealRequestId}
1082 onOpenFile={onOpenFile}1100 onOpenFile={onOpenFile}
1101 workspaceMutationId={workspaceMutationId}
1083 {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})}1102 {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})}
1084 />1103 />
1085 </aside>1104 </aside>
apps/web/src/components/files/projectFilesQueryState.test.tsx created+193
...@@ -0,0 +1,193 @@
1import { EnvironmentId, type ProjectReadFileResult } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { Atom, AtomRegistry } from "effect/unstable/reactivity";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const projectMocks = vi.hoisted(() => ({
7 listEntries: vi.fn(),
8 optimisticFile: vi.fn(),
9 readFile: vi.fn(),
10}));
11
12const atomHooks = vi.hoisted(() => ({
13 registry: null as {
14 get(atom: object): unknown;
15 refresh(atom: object): void;
16 } | null,
17}));
18
19const reactHooks = vi.hoisted(() => {
20 let cursor = 0;
21 let refs: Array<{ current: unknown }> = [];
22 const nextIndex = () => cursor++;
23
24 return {
25 beginRender() {
26 cursor = 0;
27 },
28 reset() {
29 cursor = 0;
30 refs = [];
31 },
32 useCallback<A>(callback: A): A {
33 nextIndex();
34 return callback;
35 },
36 useEffect(effect: () => void): void {
37 nextIndex();
38 effect();
39 },
40 useRef<A>(initialValue: A): { current: A } {
41 const index = nextIndex();
42 refs[index] ??= { current: initialValue };
43 return refs[index] as { current: A };
44 },
45 };
46});
47
48vi.mock("@effect/atom-react", () => ({
49 useAtomRefresh: (atom: object) => () => {
50 atomHooks.registry?.refresh(atom);
51 },
52 useAtomValue: (atom: object) => atomHooks.registry?.get(atom),
53}));
54
55vi.mock("react", async (importOriginal) => {
56 const actual = await importOriginal<typeof import("react")>();
57 return {
58 ...actual,
59 useCallback: reactHooks.useCallback,
60 useEffect: reactHooks.useEffect,
61 useRef: reactHooks.useRef,
62 };
63});
64
65vi.mock("~/state/projects", () => ({
66 projectEnvironment: projectMocks,
67}));
68
69vi.mock("~/state/queries", () => ({
70 useProjectPathSearch: vi.fn(),
71}));
72
73import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
74import { useProjectFileQuery } from "./projectFilesQueryState";
75
76const environmentId = EnvironmentId.make("environment-1");
77
78function deferred<A>() {
79 let resolve!: (value: A) => void;
80 const promise = new Promise<A>((complete) => {
81 resolve = complete;
82 });
83 return { promise, resolve };
84}
85
86function file(contents: string): ProjectReadFileResult {
87 return {
88 relativePath: "src/preview.ts",
89 contents,
90 byteLength: contents.length,
91 truncated: false,
92 };
93}
94
95async function flushEffects(): Promise<void> {
96 await Promise.resolve();
97 await Promise.resolve();
98 await Promise.resolve();
99}
100
101describe("project file query refresh", () => {
102 beforeEach(() => {
103 projectMocks.listEntries.mockReset();
104 projectMocks.optimisticFile.mockReset();
105 projectMocks.readFile.mockReset();
106 reactHooks.reset();
107 });
108
109 it("replaces an in-flight initial read when a workspace mutation arrives", async () => {
110 const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = [];
111 const readAtom = Atom.make(
112 Effect.promise(() => {
113 const request = deferred<ProjectReadFileResult>();
114 requests.push(request);
115 return request.promise;
116 }),
117 ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true }));
118 const registry = AtomRegistry.make();
119 const unmount = registry.mount(readAtom);
120 projectMocks.readFile.mockReturnValue(readAtom);
121 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
122 atomHooks.registry = registry;
123 let renderedContents: string | null = null;
124
125 const render = (mutationId: string | null) => {
126 reactHooks.beginRender();
127 const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts");
128 renderedContents = query.data?.contents ?? null;
129 useWorkspaceMutationRefresh({
130 mutationId,
131 refresh: query.refresh,
132 resourceKey: "file:environment-1:/repo:src/preview.ts",
133 });
134 };
135
136 try {
137 render(null);
138 await flushEffects();
139 expect(requests).toHaveLength(1);
140
141 render("mutation-1");
142 await flushEffects();
143 expect(requests).toHaveLength(2);
144
145 requests[1]!.resolve(file("fresh"));
146 await flushEffects();
147 render("mutation-1");
148 expect(renderedContents).toBe("fresh");
149
150 requests[0]!.resolve(file("stale"));
151 await flushEffects();
152 render("mutation-1");
153 expect(renderedContents).toBe("fresh");
154 } finally {
155 unmount();
156 registry.dispose();
157 atomHooks.registry = null;
158 }
159 });
160
161 it("does not issue a file read for a disabled image preview", async () => {
162 const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = [];
163 const readAtom = Atom.make(
164 Effect.promise(() => {
165 const request = deferred<ProjectReadFileResult>();
166 requests.push(request);
167 return request.promise;
168 }),
169 );
170 const registry = AtomRegistry.make();
171 projectMocks.readFile.mockReturnValue(readAtom);
172 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
173 atomHooks.registry = registry;
174
175 try {
176 reactHooks.beginRender();
177 const query = useProjectFileQuery(environmentId, "/repo", "preview.png", false);
178 useWorkspaceMutationRefresh({
179 enabled: false,
180 mutationId: "mutation-1",
181 refresh: query.refresh,
182 resourceKey: "file:environment-1:/repo:preview.png",
183 });
184 await flushEffects();
185
186 expect(projectMocks.readFile).not.toHaveBeenCalled();
187 expect(requests).toHaveLength(0);
188 } finally {
189 registry.dispose();
190 atomHooks.registry = null;
191 }
192 });
193});
apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts created+62
...@@ -0,0 +1,62 @@
1import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 latestWorkspaceMutationId,
6 workspaceMutationRefreshToken,
7} from "./useWorkspaceMutationRefresh";
8
9function activity(
10 id: string,
11 kind: string,
12 itemType: string,
13 status?: string,
14): OrchestrationThreadActivity {
15 return {
16 id: EventId.make(id),
17 kind,
18 tone: "tool",
19 summary: "Tool activity",
20 payload: { itemType, ...(status ? { status } : {}) },
21 turnId: null,
22 createdAt: "2026-08-30T00:00:00.000Z",
23 };
24}
25
26describe("workspace mutation refresh", () => {
27 it("tracks the latest completed file change or command", () => {
28 expect(
29 latestWorkspaceMutationId([
30 activity("file-started", "tool.started", "file_change"),
31 activity("search-completed", "tool.completed", "web_search"),
32 activity("file-completed", "tool.completed", "file_change"),
33 activity("command-completed", "tool.completed", "command_execution"),
34 ]),
35 ).toBe("command-completed");
36 });
37
38 it("ignores read-only and in-progress tools", () => {
39 expect(
40 latestWorkspaceMutationId([
41 activity("command-updated", "tool.updated", "command_execution", "inProgress"),
42 activity("legacy-command-updated", "tool.updated", "command_execution", "in_progress"),
43 activity("image-completed", "tool.completed", "image_view"),
44 ]),
45 ).toBeNull();
46 });
47
48 it("accepts providers that report terminal state on an update", () => {
49 expect(
50 latestWorkspaceMutationId([
51 activity("file-updated", "tool.updated", "file_change", "completed"),
52 ]),
53 ).toBe("file-updated");
54 });
55
56 it("scopes the same mutation to each preview resource", () => {
57 expect(workspaceMutationRefreshToken("file:/repo/README.md", "event-1")).not.toBe(
58 workspaceMutationRefreshToken("diff:/repo", "event-1"),
59 );
60 expect(workspaceMutationRefreshToken("file:/repo/README.md", null)).toBeNull();
61 });
62});
apps/web/src/hooks/useWorkspaceMutationRefresh.ts created+65
...@@ -0,0 +1,65 @@
1import type { OrchestrationThreadActivity } from "@t3tools/contracts";
2import { useEffect, useRef } from "react";
3
4const WORKSPACE_MUTATION_ITEM_TYPES = new Set(["command_execution", "file_change"]);
5
6function activityPayload(activity: OrchestrationThreadActivity): Record<string, unknown> | null {
7 return activity.payload !== null && typeof activity.payload === "object"
8 ? (activity.payload as Record<string, unknown>)
9 : null;
10}
11
12/**
13 * The latest provider event after which files on disk may have changed.
14 * File tools are explicit; completed commands are included because a shell
15 * command can mutate the workspace without reporting the paths it touched.
16 */
17export function latestWorkspaceMutationId(
18 activities: ReadonlyArray<OrchestrationThreadActivity>,
19): string | null {
20 for (let index = activities.length - 1; index >= 0; index -= 1) {
21 const activity = activities[index];
22 if (!activity) continue;
23 const payload = activityPayload(activity);
24 const terminalUpdate =
25 activity.kind === "tool.updated" &&
26 typeof payload?.status === "string" &&
27 payload.status !== "inProgress" &&
28 payload.status !== "in_progress";
29 if (activity.kind !== "tool.completed" && !terminalUpdate) continue;
30 const itemType = payload?.itemType;
31 if (typeof itemType === "string" && WORKSPACE_MUTATION_ITEM_TYPES.has(itemType)) {
32 return activity.id;
33 }
34 }
35 return null;
36}
37
38export function workspaceMutationRefreshToken(
39 resourceKey: string,
40 mutationId: string | null,
41): string | null {
42 return mutationId === null ? null : `${resourceKey}\u0000${mutationId}`;
43}
44
45/**
46 * Refreshes once per mutation and resource. Disabled mutations stay pending,
47 * which lets an editable file catch up after its local save finishes.
48 */
49export function useWorkspaceMutationRefresh(input: {
50 readonly enabled?: boolean;
51 readonly mutationId: string | null;
52 readonly refresh: () => void;
53 readonly resourceKey: string;
54}): void {
55 const { enabled = true, mutationId, refresh, resourceKey } = input;
56 const handledTokenRef = useRef<string | null>(null);
57
58 useEffect(() => {
59 if (!enabled) return;
60 const token = workspaceMutationRefreshToken(resourceKey, mutationId);
61 if (token === null || token === handledTokenRef.current) return;
62 handledTokenRef.current = token;
63 refresh();
64 }, [enabled, mutationId, refresh, resourceKey]);
65}