diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 365261076..e046b5600 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,6 +129,10 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; +import { + latestWorkspaceMutationId, + useWorkspaceMutationRefresh, +} from "../hooks/useWorkspaceMutationRefresh"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -2447,6 +2451,13 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; + const workspaceMutationId = useMemo(() => { + const activityId = latestWorkspaceMutationId(threadActivities); + return activityId === null && latestCheckpointCompletedAt === null + ? null + : JSON.stringify([activityId, latestCheckpointCompletedAt]); + }, [latestCheckpointCompletedAt, threadActivities]); const activeContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(threadActivities), [threadActivities], @@ -3076,6 +3087,12 @@ function ChatViewContent(props: ChatViewProps) { input: { cwd: gitStatusCwd }, }), ); + useWorkspaceMutationRefresh({ + enabled: gitStatusCwd !== null, + mutationId: workspaceMutationId, + refresh: gitStatusQuery.refresh, + resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`, + }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. @@ -8002,6 +8019,7 @@ function ChatViewContent(props: ChatViewProps) { mode="embedded" composerDraftTarget={composerDraftTarget} initialGitScope={initialDiffPanelGitScope} + workspaceMutationId={workspaceMutationId} /> ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( @@ -8085,6 +8103,10 @@ function ChatViewContent(props: ChatViewProps) { revealRequestId={activeFileSurface?.revealRequestId ?? 0} onOpenFile={openFileSurface} onPendingChange={handleFilePendingChange} + selectedFilePending={ + activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id) + } + workspaceMutationId={workspaceMutationId} /> ) : null diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6c4b720e9..66886c3d4 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -39,6 +39,7 @@ import { } from "../lib/diffRendering"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; @@ -90,6 +91,7 @@ interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; initialGitScope: "branch" | "unstaged"; + workspaceMutationId: string | null; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; @@ -98,6 +100,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget, initialGitScope: initialGitScopeProp, + workspaceMutationId, }: DiffPanelProps) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); @@ -113,10 +116,6 @@ export default function DiffPanel({ })); const [codeViewRevision, setCodeViewRevision] = useState(0); const codeViewRef = useRef(null); - const lastCompletedTurnRefreshRef = useRef<{ - readonly threadKey: string | null; - readonly turnId: TurnId | null; - } | null>(null); const routeThreadRef = useParams({ strict: false, @@ -290,23 +289,12 @@ export default function DiffPanel({ return () => window.removeEventListener("focus", refreshOnFocus); }, [canRefreshGitDiff, refreshBranchDiffPreview]); - useEffect(() => { - const current = { - threadKey: activeThreadRefreshKey, - turnId: latestTurn?.turnId ?? null, - }; - const previous = lastCompletedTurnRefreshRef.current; - if (!canRefreshGitDiff) { - return; - } - if (previous === null || previous.threadKey !== current.threadKey) { - lastCompletedTurnRefreshRef.current = current; - return; - } - if (previous.turnId === current.turnId) return; - refreshBranchDiffPreview(); - lastCompletedTurnRefreshRef.current = current; - }, [activeThreadRefreshKey, canRefreshGitDiff, latestTurn?.turnId, refreshBranchDiffPreview]); + useWorkspaceMutationRefresh({ + enabled: canRefreshGitDiff, + mutationId: workspaceMutationId, + refresh: refreshBranchDiffPreview, + resourceKey: `diff:${activeThreadRefreshKey ?? ""}`, + }); const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index cbe20f4d3..f62155844 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -260,7 +260,6 @@ export default function FileBrowserPanel({ entriesQuery.refresh(); onRefreshSelectedFile?.(); }; - useEffect(() => { if (previousTreePathsRef.current === treePaths) return; entryKindsRef.current = entryKinds; diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index bc2ff98f0..7d1864e9c 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -23,6 +23,7 @@ import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -78,6 +79,8 @@ interface FilePreviewPanelProps { revealRequestId: number; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; + selectedFilePending: boolean; + workspaceMutationId: string | null; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -140,8 +143,9 @@ function WorkspaceImagePreview(props: { path: props.absolutePath, }); const [failedUrl, setFailedUrl] = useState(null); + const imageUrl = assetUrl._tag === "Success" ? assetUrl.url : null; - if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return (
Unable to load workspace image. @@ -149,13 +153,13 @@ function WorkspaceImagePreview(props: { ); } - return assetUrl._tag === "Success" ? ( + return assetUrl._tag === "Success" && imageUrl !== null ? (
{props.alt} setFailedUrl(assetUrl.url)} + onError={() => setFailedUrl(imageUrl)} />
) : ( @@ -768,6 +772,8 @@ export default function FilePreviewPanel({ revealRequestId, onOpenFile, onPendingChange, + selectedFilePending, + workspaceMutationId, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); @@ -812,6 +818,12 @@ export default function FilePreviewPanel({ [projectName, relativePath], ); const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); + useWorkspaceMutationRefresh({ + enabled: relativePath !== null && !isImage && !selectedFilePending, + mutationId: workspaceMutationId, + refresh: file.refresh, + resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, + }); useEffect(() => { const currentCrumb = breadcrumbRef.current?.querySelector( diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx new file mode 100644 index 000000000..ef3a2dc5d --- /dev/null +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -0,0 +1,209 @@ +import { EnvironmentId, type ProjectReadFileResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const projectMocks = vi.hoisted(() => ({ + listEntries: vi.fn(), + optimisticFile: vi.fn(), + readFile: vi.fn(), +})); + +const atomHooks = vi.hoisted(() => ({ + registry: null as { + get(atom: object): unknown; + refresh(atom: object): void; + } | null, +})); + +const reactHooks = vi.hoisted(() => { + let cursor = 0; + let refs: Array<{ current: unknown }> = []; + let cleanups: Array<(() => void) | undefined> = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + refs = []; + cleanups = []; + }, + useCallback(callback: A): A { + nextIndex(); + return callback; + }, + // Runs the previous cleanup before re-running, as React does: an effect + // that schedules a timer relies on it to coalesce successive renders. + useEffect(effect: () => void | (() => void)): void { + const index = nextIndex(); + cleanups[index]?.(); + cleanups[index] = effect() ?? undefined; + }, + useRef(initialValue: A): { current: A } { + const index = nextIndex(); + refs[index] ??= { current: initialValue }; + return refs[index] as { current: A }; + }, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomRefresh: (atom: object) => () => { + atomHooks.registry?.refresh(atom); + }, + useAtomValue: (atom: object) => atomHooks.registry?.get(atom), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCallback: reactHooks.useCallback, + useEffect: reactHooks.useEffect, + useRef: reactHooks.useRef, + }; +}); + +vi.mock("~/state/projects", () => ({ + projectEnvironment: projectMocks, +})); + +vi.mock("~/state/queries", () => ({ + useProjectPathSearch: vi.fn(), +})); + +import { + WORKSPACE_MUTATION_REFRESH_COALESCE_MS, + useWorkspaceMutationRefresh, +} from "~/hooks/useWorkspaceMutationRefresh"; +import { useProjectFileQuery } from "./projectFilesQueryState"; + +const environmentId = EnvironmentId.make("environment-1"); + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function file(contents: string): ProjectReadFileResult { + return { + relativePath: "src/preview.ts", + contents, + byteLength: contents.length, + truncated: false, + }; +} + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("project file query refresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + projectMocks.listEntries.mockReset(); + projectMocks.optimisticFile.mockReset(); + projectMocks.readFile.mockReset(); + reactHooks.reset(); + }); + + it("coalesces a burst of mutations into one refresh of the initial read", async () => { + const requests: Array>> = []; + const readAtom = Atom.make( + Effect.promise(() => { + const request = deferred(); + requests.push(request); + return request.promise; + }), + ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true })); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + let renderedContents: string | null = null; + + const render = (mutationId: string | null) => { + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + renderedContents = query.data?.contents ?? null; + useWorkspaceMutationRefresh({ + mutationId, + refresh: query.refresh, + resourceKey: "file:environment-1:/repo:src/preview.ts", + }); + }; + + try { + render(null); + await flushEffects(); + expect(requests).toHaveLength(1); + + // A burst of mutations lands one trailing refresh, not one per mutation. + render("mutation-1"); + await flushEffects(); + render("mutation-2"); + await flushEffects(); + expect(requests).toHaveLength(1); + + vi.advanceTimersByTime(WORKSPACE_MUTATION_REFRESH_COALESCE_MS); + await flushEffects(); + expect(requests).toHaveLength(2); + + requests[1]!.resolve(file("fresh")); + await flushEffects(); + render("mutation-2"); + expect(renderedContents).toBe("fresh"); + + requests[0]!.resolve(file("stale")); + await flushEffects(); + render("mutation-2"); + expect(renderedContents).toBe("fresh"); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("does not issue a file read for a disabled image preview", async () => { + const requests: Array>> = []; + const readAtom = Atom.make( + Effect.promise(() => { + const request = deferred(); + requests.push(request); + return request.promise; + }), + ); + const registry = AtomRegistry.make(); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", "preview.png", false); + useWorkspaceMutationRefresh({ + enabled: false, + mutationId: "mutation-1", + refresh: query.refresh, + resourceKey: "file:environment-1:/repo:preview.png", + }); + await flushEffects(); + + expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(requests).toHaveLength(0); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }); +}); diff --git a/apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts b/apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts new file mode 100644 index 000000000..28cc0d0e4 --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts @@ -0,0 +1,62 @@ +import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + latestWorkspaceMutationId, + workspaceMutationRefreshToken, +} from "./useWorkspaceMutationRefresh"; + +function activity( + id: string, + kind: string, + itemType: string, + status?: string, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + kind, + tone: "tool", + summary: "Tool activity", + payload: { itemType, ...(status ? { status } : {}) }, + turnId: null, + createdAt: "2026-08-30T00:00:00.000Z", + }; +} + +describe("workspace mutation refresh", () => { + it("tracks the latest completed file change or command", () => { + expect( + latestWorkspaceMutationId([ + activity("file-started", "tool.started", "file_change"), + activity("search-completed", "tool.completed", "web_search"), + activity("file-completed", "tool.completed", "file_change"), + activity("command-completed", "tool.completed", "command_execution"), + ]), + ).toBe("command-completed"); + }); + + it("ignores read-only and in-progress tools", () => { + expect( + latestWorkspaceMutationId([ + activity("command-updated", "tool.updated", "command_execution", "inProgress"), + activity("legacy-command-updated", "tool.updated", "command_execution", "in_progress"), + activity("image-completed", "tool.completed", "image_view"), + ]), + ).toBeNull(); + }); + + it("accepts providers that report terminal state on an update", () => { + expect( + latestWorkspaceMutationId([ + activity("file-updated", "tool.updated", "file_change", "completed"), + ]), + ).toBe("file-updated"); + }); + + it("scopes the same mutation to each preview resource", () => { + expect(workspaceMutationRefreshToken("file:/repo/README.md", "event-1")).not.toBe( + workspaceMutationRefreshToken("diff:/repo", "event-1"), + ); + expect(workspaceMutationRefreshToken("file:/repo/README.md", null)).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useWorkspaceMutationRefresh.ts b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts new file mode 100644 index 000000000..c8beb5d29 --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts @@ -0,0 +1,91 @@ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +const WORKSPACE_MUTATION_ITEM_TYPES = new Set(["command_execution", "file_change"]); + +function activityPayload(activity: OrchestrationThreadActivity): Record | null { + return activity.payload !== null && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; +} + +/** + * The latest provider event after which files on disk may have changed. + * File tools are explicit; completed commands are included because a shell + * command can mutate the workspace without reporting the paths it touched. + */ +export function latestWorkspaceMutationId( + activities: ReadonlyArray, +): string | null { + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity) continue; + const payload = activityPayload(activity); + const terminalUpdate = + activity.kind === "tool.updated" && + typeof payload?.status === "string" && + payload.status !== "inProgress" && + payload.status !== "in_progress"; + if (activity.kind !== "tool.completed" && !terminalUpdate) continue; + const itemType = payload?.itemType; + if (typeof itemType === "string" && WORKSPACE_MUTATION_ITEM_TYPES.has(itemType)) { + return activity.id; + } + } + return null; +} + +export function workspaceMutationRefreshToken( + resourceKey: string, + mutationId: string | null, +): string | null { + return mutationId === null ? null : `${resourceKey}\u0000${mutationId}`; +} + +/** + * A busy turn lands a mutation every few hundred milliseconds, and each refresh + * costs a git subprocess or a full-tree payload. Coalesce a burst into one + * trailing refresh rather than issuing one per tool call. + */ +export const WORKSPACE_MUTATION_REFRESH_COALESCE_MS = 750; + +/** + * Refreshes once per settled burst of mutations, per resource. Disabled + * mutations stay pending, which lets an editable file catch up after its local + * save finishes. + * + * The first observed mutation is adopted without refreshing: on mount the atom + * has just issued its own read, and refreshing would cancel and re-issue it. + */ +export function useWorkspaceMutationRefresh(input: { + readonly enabled?: boolean; + readonly mutationId: string | null; + readonly refresh: () => void; + readonly resourceKey: string; +}): void { + const { enabled = true, mutationId, refresh, resourceKey } = input; + const handledTokenRef = useRef(null); + const seededResourceRef = useRef(null); + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + + useEffect(() => { + if (!enabled) return; + const token = workspaceMutationRefreshToken(resourceKey, mutationId); + if (seededResourceRef.current !== resourceKey) { + // Opening a panel mid-turn already has a mutation id in hand, and the atom + // has just issued its own read: adopt that state instead of cancelling it. + seededResourceRef.current = resourceKey; + handledTokenRef.current = token; + return; + } + if (token === null || token === handledTokenRef.current) return; + handledTokenRef.current = token; + const timer = setTimeout(() => { + refreshRef.current(); + }, WORKSPACE_MUTATION_REFRESH_COALESCE_MS); + return () => { + clearTimeout(timer); + }; + }, [enabled, mutationId, resourceKey]); +}