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 {
117117 type PendingUserInputDraftAnswer,
118118} from "../pendingUserInput";
119119import { useUiStateStore } from "../uiStateStore";
120import {
121 latestWorkspaceMutationId,
122 useWorkspaceMutationRefresh,
123} from "../hooks/useWorkspaceMutationRefresh";
120124import {
121125 buildPlanImplementationThreadTitle,
122126 buildPlanImplementationPrompt,
......@@ -2343,6 +2347,13 @@ function ChatViewContent(props: ChatViewProps) {
23432347 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
23442348 const phase = derivePhase(activeThread?.session ?? null);
23452349 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]);
23462357 const activeContextWindow = useMemo(
23472358 () => deriveLatestContextWindowSnapshot(threadActivities),
23482359 [threadActivities],
......@@ -2896,6 +2907,12 @@ function ChatViewContent(props: ChatViewProps) {
28962907 input: { cwd: gitStatusCwd },
28972908 }),
28982909 );
2910 useWorkspaceMutationRefresh({
2911 enabled: gitStatusCwd !== null,
2912 mutationId: workspaceMutationId,
2913 refresh: gitStatusQuery.refresh,
2914 resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,
2915 });
28992916 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
29002917 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
29012918 // Prefer an instance-id match so a custom Codex instance (e.g.
......@@ -7027,6 +7044,7 @@ function ChatViewContent(props: ChatViewProps) {
70277044 mode="embedded"
70287045 composerDraftTarget={composerDraftTarget}
70297046 initialGitScope={initialDiffPanelGitScope}
7047 workspaceMutationId={workspaceMutationId}
70307048 />
70317049 </Suspense>
70327050 ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
......@@ -7095,6 +7113,10 @@ function ChatViewContent(props: ChatViewProps) {
70957113 revealRequestId={activeFileSurface?.revealRequestId ?? 0}
70967114 onOpenFile={openFileSurface}
70977115 onPendingChange={handleFilePendingChange}
7116 selectedFilePending={
7117 activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id)
7118 }
7119 workspaceMutationId={workspaceMutationId}
70987120 />
70997121 </Suspense>
71007122 ) : null
apps/web/src/components/DiffPanel.tsx+9-21
......@@ -39,6 +39,7 @@ import {
3939} from "../lib/diffRendering";
4040import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
4141import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
42import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
4243import { useProject, useThread } from "../state/entities";
4344import { resolveThreadRouteRef } from "../threadRoutes";
4445import { useClientSettings } from "../hooks/useSettings";
......@@ -90,6 +91,7 @@ interface DiffPanelProps {
9091 mode?: DiffPanelMode;
9192 composerDraftTarget: ScopedThreadRef | DraftId;
9293 initialGitScope: "branch" | "unstaged";
94 workspaceMutationId: string | null;
9395}
9496
9597export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
......@@ -98,6 +100,7 @@ export default function DiffPanel({
98100 mode = "inline",
99101 composerDraftTarget,
100102 initialGitScope: initialGitScopeProp,
103 workspaceMutationId,
101104}: DiffPanelProps) {
102105 const { resolvedTheme } = useTheme();
103106 const settings = useClientSettings();
......@@ -113,10 +116,6 @@ export default function DiffPanel({
113116 }));
114117 const [codeViewRevision, setCodeViewRevision] = useState(0);
115118 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
116 const lastCompletedTurnRefreshRef = useRef<{
117 readonly threadKey: string | null;
118 readonly turnId: TurnId | null;
119 } | null>(null);
120119
121120 const routeThreadRef = useParams({
122121 strict: false,
......@@ -290,23 +289,12 @@ export default function DiffPanel({
290289 return () => window.removeEventListener("focus", refreshOnFocus);
291290 }, [canRefreshGitDiff, refreshBranchDiffPreview]);
292291
293 useEffect(() => {
294 const current = {
295 threadKey: activeThreadRefreshKey,
296 turnId: latestTurn?.turnId ?? null,
297 };
298 const previous = lastCompletedTurnRefreshRef.current;
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]);
292 useWorkspaceMutationRefresh({
293 enabled: canRefreshGitDiff,
294 mutationId: workspaceMutationId,
295 refresh: refreshBranchDiffPreview,
296 resourceKey: `diff:${activeThreadRefreshKey ?? ""}`,
297 });
310298
311299 const selectedGitSource = branchDiffPreview.data?.sources.find(
312300 (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";
1515import { useComposerHandleContext } from "~/composerHandleContext";
1616import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
1717import { useTheme } from "~/hooks/useTheme";
18import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
1819import { cn } from "~/lib/utils";
1920import { readLocalApi } from "~/localApi";
2021import { T3_PIERRE_ICONS } from "~/pierre-icons";
......@@ -32,6 +33,7 @@ interface FileBrowserPanelProps {
3233 selectedPathRevealId: number;
3334 onOpenFile: (relativePath: string) => void;
3435 onRefreshSelectedFile?: () => void;
36 workspaceMutationId: string | null;
3537}
3638
3739const TREE_UNSAFE_CSS = `
......@@ -107,6 +109,7 @@ export default function FileBrowserPanel({
107109 selectedPathRevealId,
108110 onOpenFile,
109111 onRefreshSelectedFile,
112 workspaceMutationId,
110113}: FileBrowserPanelProps) {
111114 const { resolvedTheme } = useTheme();
112115 const composerRef = useComposerHandleContext();
......@@ -260,6 +263,11 @@ export default function FileBrowserPanel({
260263 entriesQuery.refresh();
261264 onRefreshSelectedFile?.();
262265 };
266 useWorkspaceMutationRefresh({
267 mutationId: workspaceMutationId,
268 refresh: entriesQuery.refresh,
269 resourceKey: `files:${environmentId}:${cwd}`,
270 });
263271
264272 useEffect(() => {
265273 if (previousTreePathsRef.current === treePaths) return;
apps/web/src/components/files/FilePreviewPanel.tsx+23-4
......@@ -23,6 +23,7 @@ import { useRemoteOpenState } from "~/remoteOpen";
2323import { useClientSettings } from "~/hooks/useSettings";
2424import { useTheme } from "~/hooks/useTheme";
2525import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
26import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
2627import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering";
2728import { cn } from "~/lib/utils";
2829import { isPreviewSupportedInRuntime } from "~/previewStateStore";
......@@ -78,6 +79,8 @@ interface FilePreviewPanelProps {
7879 revealRequestId: number;
7980 onOpenFile: (relativePath: string) => void;
8081 onPendingChange: (relativePath: string, pending: boolean) => void;
82 selectedFilePending: boolean;
83 workspaceMutationId: string | null;
8184}
8285
8386const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
......@@ -133,6 +136,7 @@ function WorkspaceImagePreview(props: {
133136 readonly threadRef: ScopedThreadRef;
134137 readonly absolutePath: string;
135138 readonly alt: string;
139 readonly workspaceMutationId: string | null;
136140}) {
137141 const assetUrl = useAssetUrlState(props.environmentId, {
138142 _tag: "workspace-file",
......@@ -140,8 +144,13 @@ function WorkspaceImagePreview(props: {
140144 path: props.absolutePath,
141145 });
142146 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)) {
145154 return (
146155 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
147156 Unable to load workspace image.
......@@ -149,13 +158,13 @@ function WorkspaceImagePreview(props: {
149158 );
150159 }
151160
152 return assetUrl._tag === "Success" ? (
161 return assetUrl._tag === "Success" && imageUrl !== null ? (
153162 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
154163 <img
155164 className="max-h-full max-w-full object-contain"
156 src={assetUrl.url}
165 src={imageUrl}
157166 alt={props.alt}
158 onError={() => setFailedUrl(assetUrl.url)}
167 onError={() => setFailedUrl(imageUrl)}
159168 />
160169 </div>
161170 ) : (
......@@ -768,6 +777,8 @@ export default function FilePreviewPanel({
768777 revealRequestId,
769778 onOpenFile,
770779 onPendingChange,
780 selectedFilePending,
781 workspaceMutationId,
771782}: FilePreviewPanelProps) {
772783 const { resolvedTheme } = useTheme();
773784 const wordWrap = useClientSettings((settings) => settings.wordWrap);
......@@ -812,6 +823,12 @@ export default function FilePreviewPanel({
812823 [projectName, relativePath],
813824 );
814825 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
816833 useEffect(() => {
817834 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
......@@ -1001,6 +1018,7 @@ export default function FilePreviewPanel({
10011018 threadRef={threadRef}
10021019 absolutePath={absolutePath}
10031020 alt={relativePath}
1021 workspaceMutationId={workspaceMutationId}
10041022 />
10051023 ) : relativePath && file.error && file.data === null ? (
10061024 <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({
10801098 selectedPath={relativePath}
10811099 selectedPathRevealId={revealRequestId}
10821100 onOpenFile={onOpenFile}
1101 workspaceMutationId={workspaceMutationId}
10831102 {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})}
10841103 />
10851104 </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}