| 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,6 +117,10 @@ import { |
| 117 | type PendingUserInputDraftAnswer, | 117 | type PendingUserInputDraftAnswer, |
| 118 | } from "../pendingUserInput"; | 118 | } from "../pendingUserInput"; |
| 119 | import { useUiStateStore } from "../uiStateStore"; | 119 | import { useUiStateStore } from "../uiStateStore"; |
| 120 | import { | ||
| 121 | latestWorkspaceMutationId, | ||
| 122 | useWorkspaceMutationRefresh, | ||
| 123 | } from "../hooks/useWorkspaceMutationRefresh"; | ||
| 120 | import { | 124 | import { |
| 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 | ) : null | 7122 | ) : 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"; |
| 40 | import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; | 40 | import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; |
| 41 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 41 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 42 | import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; | ||
| 42 | import { useProject, useThread } from "../state/entities"; | 43 | import { useProject, useThread } from "../state/entities"; |
| 43 | import { resolveThreadRouteRef } from "../threadRoutes"; | 44 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 44 | import { useClientSettings } from "../hooks/useSettings"; | 45 | import { 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 | } |
| 94 | 96 | ||
| 95 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | 97 | export { 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); | ||
| 120 | 119 | ||
| 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]); |
| 292 | 291 | ||
| 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]); | ||
| 310 | 298 | ||
| 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"; |
| 15 | import { useComposerHandleContext } from "~/composerHandleContext"; | 15 | import { useComposerHandleContext } from "~/composerHandleContext"; |
| 16 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 16 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 17 | import { useTheme } from "~/hooks/useTheme"; | 17 | import { useTheme } from "~/hooks/useTheme"; |
| 18 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | ||
| 18 | import { cn } from "~/lib/utils"; | 19 | import { cn } from "~/lib/utils"; |
| 19 | import { readLocalApi } from "~/localApi"; | 20 | import { readLocalApi } from "~/localApi"; |
| 20 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; | 21 | import { 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 | } |
| 36 | 38 | ||
| 37 | const TREE_UNSAFE_CSS = ` | 39 | const 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 | }); | ||
| 263 | 271 | ||
| 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"; |
| 23 | import { useClientSettings } from "~/hooks/useSettings"; | 23 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | import { useTheme } from "~/hooks/useTheme"; | 24 | import { useTheme } from "~/hooks/useTheme"; |
| 25 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; | 25 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| 26 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | ||
| 26 | import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; | 27 | import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; |
| 27 | import { cn } from "~/lib/utils"; | 28 | import { cn } from "~/lib/utils"; |
| 28 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | 29 | import { 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 | } |
| 82 | 85 | ||
| 83 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; | 86 | const 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; | ||
| 143 | 152 | ||
| 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 | } |
| 151 | 160 | ||
| 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 | <img | 163 | <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 | }); | ||
| 815 | 832 | ||
| 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 @@ | ||
| 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 | } | ||