| author | |
| committer | |
| log | e09b88b6a51bb7e4309498f01c2f36bf5ada43f4 |
| tree | 2f1c898ca49d994ac8884b1357a1cae59dba8cfa |
| parent | 5885a68adb61805460047512040838bbb5e7ed65 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 117 | type PendingUserInputDraftAnswer, |
| 118 | 118 | } from "../pendingUserInput"; |
| 119 | 119 | import { useUiStateStore } from "../uiStateStore"; |
| 120 | import { | |
| 121 | latestWorkspaceMutationId, | |
| 122 | useWorkspaceMutationRefresh, | |
| 123 | } from "../hooks/useWorkspaceMutationRefresh"; | |
| 120 | 124 | import { |
| 121 | 125 | buildPlanImplementationThreadTitle, |
| 122 | 126 | buildPlanImplementationPrompt, |
| ... | ... | @@ -2343,6 +2347,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2343 | 2347 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 2344 | 2348 | const phase = derivePhase(activeThread?.session ?? null); |
| 2345 | 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 | 2357 | const activeContextWindow = useMemo( |
| 2347 | 2358 | () => deriveLatestContextWindowSnapshot(threadActivities), |
| 2348 | 2359 | [threadActivities], |
| ... | ... | @@ -2896,6 +2907,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 2896 | 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 | 2916 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2900 | 2917 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); |
| 2901 | 2918 | // Prefer an instance-id match so a custom Codex instance (e.g. |
| ... | ... | @@ -7027,6 +7044,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7027 | 7044 | mode="embedded" |
| 7028 | 7045 | composerDraftTarget={composerDraftTarget} |
| 7029 | 7046 | initialGitScope={initialDiffPanelGitScope} |
| 7047 | workspaceMutationId={workspaceMutationId} | |
| 7030 | 7048 | /> |
| 7031 | 7049 | </Suspense> |
| 7032 | 7050 | ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( |
| ... | ... | @@ -7095,6 +7113,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 7095 | 7113 | revealRequestId={activeFileSurface?.revealRequestId ?? 0} |
| 7096 | 7114 | onOpenFile={openFileSurface} |
| 7097 | 7115 | onPendingChange={handleFilePendingChange} |
| 7116 | selectedFilePending={ | |
| 7117 | activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id) | |
| 7118 | } | |
| 7119 | workspaceMutationId={workspaceMutationId} | |
| 7098 | 7120 | /> |
| 7099 | 7121 | </Suspense> |
| 7100 | 7122 | ) : null |
apps/web/src/components/DiffPanel.tsx+9-21| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | } from "../lib/diffRendering"; |
| 40 | 40 | import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; |
| 41 | 41 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 42 | import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; | |
| 42 | 43 | import { useProject, useThread } from "../state/entities"; |
| 43 | 44 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 44 | 45 | import { useClientSettings } from "../hooks/useSettings"; |
| ... | ... | @@ -90,6 +91,7 @@ interface DiffPanelProps { |
| 90 | 91 | mode?: DiffPanelMode; |
| 91 | 92 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 92 | 93 | initialGitScope: "branch" | "unstaged"; |
| 94 | workspaceMutationId: string | null; | |
| 93 | 95 | } |
| 94 | 96 | |
| 95 | 97 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| ... | ... | @@ -98,6 +100,7 @@ export default function DiffPanel({ |
| 98 | 100 | mode = "inline", |
| 99 | 101 | composerDraftTarget, |
| 100 | 102 | initialGitScope: initialGitScopeProp, |
| 103 | workspaceMutationId, | |
| 101 | 104 | }: DiffPanelProps) { |
| 102 | 105 | const { resolvedTheme } = useTheme(); |
| 103 | 106 | const settings = useClientSettings(); |
| ... | ... | @@ -113,10 +116,6 @@ export default function DiffPanel({ |
| 113 | 116 | })); |
| 114 | 117 | const [codeViewRevision, setCodeViewRevision] = useState(0); |
| 115 | 118 | const codeViewRef = useRef<AnnotatableCodeViewHandle>(null); |
| 116 | const lastCompletedTurnRefreshRef = useRef<{ | |
| 117 | readonly threadKey: string | null; | |
| 118 | readonly turnId: TurnId | null; | |
| 119 | } | null>(null); | |
| 120 | 119 | |
| 121 | 120 | const routeThreadRef = useParams({ |
| 122 | 121 | strict: false, |
| ... | ... | @@ -290,23 +289,12 @@ export default function DiffPanel({ |
| 290 | 289 | return () => window.removeEventListener("focus", refreshOnFocus); |
| 291 | 290 | }, [canRefreshGitDiff, refreshBranchDiffPreview]); |
| 292 | 291 | |
| 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 | }); | |
| 310 | 298 | |
| 311 | 299 | const selectedGitSource = branchDiffPreview.data?.sources.find( |
| 312 | 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 | 15 | import { useComposerHandleContext } from "~/composerHandleContext"; |
| 16 | 16 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 17 | 17 | import { useTheme } from "~/hooks/useTheme"; |
| 18 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | |
| 18 | 19 | import { cn } from "~/lib/utils"; |
| 19 | 20 | import { readLocalApi } from "~/localApi"; |
| 20 | 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| ... | ... | @@ -32,6 +33,7 @@ interface FileBrowserPanelProps { |
| 32 | 33 | selectedPathRevealId: number; |
| 33 | 34 | onOpenFile: (relativePath: string) => void; |
| 34 | 35 | onRefreshSelectedFile?: () => void; |
| 36 | workspaceMutationId: string | null; | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | const TREE_UNSAFE_CSS = ` |
| ... | ... | @@ -107,6 +109,7 @@ export default function FileBrowserPanel({ |
| 107 | 109 | selectedPathRevealId, |
| 108 | 110 | onOpenFile, |
| 109 | 111 | onRefreshSelectedFile, |
| 112 | workspaceMutationId, | |
| 110 | 113 | }: FileBrowserPanelProps) { |
| 111 | 114 | const { resolvedTheme } = useTheme(); |
| 112 | 115 | const composerRef = useComposerHandleContext(); |
| ... | ... | @@ -260,6 +263,11 @@ export default function FileBrowserPanel({ |
| 260 | 263 | entriesQuery.refresh(); |
| 261 | 264 | onRefreshSelectedFile?.(); |
| 262 | 265 | }; |
| 266 | useWorkspaceMutationRefresh({ | |
| 267 | mutationId: workspaceMutationId, | |
| 268 | refresh: entriesQuery.refresh, | |
| 269 | resourceKey: `files:${environmentId}:${cwd}`, | |
| 270 | }); | |
| 263 | 271 | |
| 264 | 272 | useEffect(() => { |
| 265 | 273 | if (previousTreePathsRef.current === treePaths) return; |
apps/web/src/components/files/FilePreviewPanel.tsx+23-4| ... | ... | @@ -23,6 +23,7 @@ import { useRemoteOpenState } from "~/remoteOpen"; |
| 23 | 23 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | 24 | import { useTheme } from "~/hooks/useTheme"; |
| 25 | 25 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| 26 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | |
| 26 | 27 | import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; |
| 27 | 28 | import { cn } from "~/lib/utils"; |
| 28 | 29 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| ... | ... | @@ -78,6 +79,8 @@ interface FilePreviewPanelProps { |
| 78 | 79 | revealRequestId: number; |
| 79 | 80 | onOpenFile: (relativePath: string) => void; |
| 80 | 81 | onPendingChange: (relativePath: string, pending: boolean) => void; |
| 82 | selectedFilePending: boolean; | |
| 83 | workspaceMutationId: string | null; | |
| 81 | 84 | } |
| 82 | 85 | |
| 83 | 86 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| ... | ... | @@ -133,6 +136,7 @@ function WorkspaceImagePreview(props: { |
| 133 | 136 | readonly threadRef: ScopedThreadRef; |
| 134 | 137 | readonly absolutePath: string; |
| 135 | 138 | readonly alt: string; |
| 139 | readonly workspaceMutationId: string | null; | |
| 136 | 140 | }) { |
| 137 | 141 | const assetUrl = useAssetUrlState(props.environmentId, { |
| 138 | 142 | _tag: "workspace-file", |
| ... | ... | @@ -140,8 +144,13 @@ function WorkspaceImagePreview(props: { |
| 140 | 144 | path: props.absolutePath, |
| 141 | 145 | }); |
| 142 | 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; | |
| 143 | 152 | |
| 144 | if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { | |
| 153 | if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { | |
| 145 | 154 | return ( |
| 146 | 155 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> |
| 147 | 156 | Unable to load workspace image. |
| ... | ... | @@ -149,13 +158,13 @@ function WorkspaceImagePreview(props: { |
| 149 | 158 | ); |
| 150 | 159 | } |
| 151 | 160 | |
| 152 | return assetUrl._tag === "Success" ? ( | |
| 161 | return assetUrl._tag === "Success" && imageUrl !== null ? ( | |
| 153 | 162 | <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4"> |
| 154 | 163 | <img |
| 155 | 164 | className="max-h-full max-w-full object-contain" |
| 156 | src={assetUrl.url} | |
| 165 | src={imageUrl} | |
| 157 | 166 | alt={props.alt} |
| 158 | onError={() => setFailedUrl(assetUrl.url)} | |
| 167 | onError={() => setFailedUrl(imageUrl)} | |
| 159 | 168 | /> |
| 160 | 169 | </div> |
| 161 | 170 | ) : ( |
| ... | ... | @@ -768,6 +777,8 @@ export default function FilePreviewPanel({ |
| 768 | 777 | revealRequestId, |
| 769 | 778 | onOpenFile, |
| 770 | 779 | onPendingChange, |
| 780 | selectedFilePending, | |
| 781 | workspaceMutationId, | |
| 771 | 782 | }: FilePreviewPanelProps) { |
| 772 | 783 | const { resolvedTheme } = useTheme(); |
| 773 | 784 | const wordWrap = useClientSettings((settings) => settings.wordWrap); |
| ... | ... | @@ -812,6 +823,12 @@ export default function FilePreviewPanel({ |
| 812 | 823 | [projectName, relativePath], |
| 813 | 824 | ); |
| 814 | 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 | }); | |
| 815 | 832 | |
| 816 | 833 | useEffect(() => { |
| 817 | 834 | const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>( |
| ... | ... | @@ -1001,6 +1018,7 @@ export default function FilePreviewPanel({ |
| 1001 | 1018 | threadRef={threadRef} |
| 1002 | 1019 | absolutePath={absolutePath} |
| 1003 | 1020 | alt={relativePath} |
| 1021 | workspaceMutationId={workspaceMutationId} | |
| 1004 | 1022 | /> |
| 1005 | 1023 | ) : relativePath && file.error && file.data === null ? ( |
| 1006 | 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 | 1098 | selectedPath={relativePath} |
| 1081 | 1099 | selectedPathRevealId={revealRequestId} |
| 1082 | 1100 | onOpenFile={onOpenFile} |
| 1101 | workspaceMutationId={workspaceMutationId} | |
| 1083 | 1102 | {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} |
| 1084 | 1103 | /> |
| 1085 | 1104 | </aside> |
apps/web/src/components/files/projectFilesQueryState.test.tsx created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | import { EnvironmentId, type ProjectReadFileResult } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | const projectMocks = vi.hoisted(() => ({ | |
| 7 | listEntries: vi.fn(), | |
| 8 | optimisticFile: vi.fn(), | |
| 9 | readFile: vi.fn(), | |
| 10 | })); | |
| 11 | ||
| 12 | const atomHooks = vi.hoisted(() => ({ | |
| 13 | registry: null as { | |
| 14 | get(atom: object): unknown; | |
| 15 | refresh(atom: object): void; | |
| 16 | } | null, | |
| 17 | })); | |
| 18 | ||
| 19 | const 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 | ||
| 48 | vi.mock("@effect/atom-react", () => ({ | |
| 49 | useAtomRefresh: (atom: object) => () => { | |
| 50 | atomHooks.registry?.refresh(atom); | |
| 51 | }, | |
| 52 | useAtomValue: (atom: object) => atomHooks.registry?.get(atom), | |
| 53 | })); | |
| 54 | ||
| 55 | vi.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 | ||
| 65 | vi.mock("~/state/projects", () => ({ | |
| 66 | projectEnvironment: projectMocks, | |
| 67 | })); | |
| 68 | ||
| 69 | vi.mock("~/state/queries", () => ({ | |
| 70 | useProjectPathSearch: vi.fn(), | |
| 71 | })); | |
| 72 | ||
| 73 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | |
| 74 | import { useProjectFileQuery } from "./projectFilesQueryState"; | |
| 75 | ||
| 76 | const environmentId = EnvironmentId.make("environment-1"); | |
| 77 | ||
| 78 | function 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 | ||
| 86 | function file(contents: string): ProjectReadFileResult { | |
| 87 | return { | |
| 88 | relativePath: "src/preview.ts", | |
| 89 | contents, | |
| 90 | byteLength: contents.length, | |
| 91 | truncated: false, | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | async function flushEffects(): Promise<void> { | |
| 96 | await Promise.resolve(); | |
| 97 | await Promise.resolve(); | |
| 98 | await Promise.resolve(); | |
| 99 | } | |
| 100 | ||
| 101 | describe("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 @@ |
| 1 | import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | latestWorkspaceMutationId, | |
| 6 | workspaceMutationRefreshToken, | |
| 7 | } from "./useWorkspaceMutationRefresh"; | |
| 8 | ||
| 9 | function 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 | ||
| 26 | describe("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 @@ |
| 1 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 2 | import { useEffect, useRef } from "react"; | |
| 3 | ||
| 4 | const WORKSPACE_MUTATION_ITEM_TYPES = new Set(["command_execution", "file_change"]); | |
| 5 | ||
| 6 | function 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 | */ | |
| 17 | export 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 | ||
| 38 | export 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 | */ | |
| 49 | export 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 | } |