From 1b543c36f2160fabc7f6d53da0ae938536b96f66 Mon Sep 17 00:00:00 2001 From: Matthew Vaught <8752859+MTVaught@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:42:13 -0500 Subject: [PATCH] fix(web): keep a staged file hidden until a preview generated after the stage lands --- apps/server/src/git/GitWorkflowService.ts | 5 +- apps/server/src/vcs/GitVcsDriver.ts | 5 +- apps/server/src/vcs/GitVcsDriverCore.ts | 8 ++- apps/web/src/components/DiffPanel.tsx | 77 ++++++++++++++--------- packages/contracts/src/git.ts | 6 ++ packages/contracts/src/rpc.ts | 2 + 6 files changed, 71 insertions(+), 32 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 55c5aa9433c5..fe8dd827942c 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -8,6 +8,7 @@ import { type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsStagePathsInput, + type VcsStagePathsResult, type VcsCreateRefInput, type VcsCreateRefResult, type VcsCreateWorktreeInput, @@ -106,7 +107,9 @@ export class GitWorkflowService extends Context.Service< readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; - readonly stagePaths: (input: VcsStagePathsInput) => Effect.Effect; + readonly stagePaths: ( + input: VcsStagePathsInput, + ) => Effect.Effect; readonly renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index c6f1de64f160..babf61b1f249 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -14,6 +14,7 @@ import { VcsProcessExitError, type VcsSwitchRefInput, type VcsStagePathsInput, + type VcsStagePathsResult, type VcsSwitchRefResult, type VcsCreateRefInput, type VcsCreateRefResult, @@ -373,7 +374,9 @@ export class GitVcsDriver extends Context.Service< readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; - readonly stagePaths: (input: VcsStagePathsInput) => Effect.Effect; + readonly stagePaths: ( + input: VcsStagePathsInput, + ) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 7b03f0e5e01d..ae2290b4a7f3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2545,11 +2545,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { + // Stamped before any repository read, so a client can tell whether the preview saw an + // index change that completed at a known server time. + const generatedAt = yield* DateTime.now; const details = yield* statusDetailsLocal(input.cwd); if (!details.isRepo) { return { cwd: input.cwd, - generatedAt: yield* DateTime.now, + generatedAt, sources: [], }; } @@ -2656,7 +2659,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { cwd: input.cwd, - generatedAt: yield* DateTime.now, + generatedAt, sources, }; }); @@ -3556,6 +3559,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorDetail: input.staged ? "git add failed" : "git reset failed", }, ); + return { completedAt: yield* DateTime.now }; }, ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e1df54e6c482..6b82dd13f96c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -241,13 +241,14 @@ export default function DiffPanel({ ); const stagePaths = useAtomCommand(vcsEnvironment.stagePaths, { reportFailure: false }); const [pendingStagePaths, setPendingStagePaths] = useState>(EMPTY_PATH_SET); - // Files that a filtered view drops as soon as they are staged or unstaged, so the reader is - // not left waiting on the server to regenerate the preview. Bound to the preview they came - // from; the next refresh carries the real answer. - const [optimisticallyHiddenFiles, setOptimisticallyHiddenFiles] = useState<{ - readonly previewKey: string | null; - readonly paths: ReadonlySet; - }>({ previewKey: null, paths: EMPTY_PATH_SET }); + // Files a filtered view drops as soon as they are staged or unstaged, so the reader is not + // left waiting on the server to regenerate the preview. Each entry holds the server time the + // index change completed (null while in flight); the file stays hidden until a preview + // generated after that time lands, so a refresh already in flight cannot bring it back. + const [hiddenStagedFiles, setHiddenStagedFiles] = useState<{ + readonly scopeKey: string | null; + readonly entries: ReadonlyMap; + }>({ scopeKey: null, entries: new Map() }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -500,13 +501,19 @@ export default function DiffPanel({ }), [resolvedTheme, selectedPatch, selectedTurnId], ); - const previewKey = branchDiffPreview.data - ? `${DateTime.toEpochMillis(branchDiffPreview.data.generatedAt)}:${selectedGitSource?.diffHash ?? ""}` + const previewGeneratedAt = branchDiffPreview.data + ? DateTime.toEpochMillis(branchDiffPreview.data.generatedAt) : null; - const hiddenFilePaths = - previewKey !== null && optimisticallyHiddenFiles.previewKey === previewKey - ? optimisticallyHiddenFiles.paths - : EMPTY_PATH_SET; + const hiddenFilePaths = useMemo(() => { + if (hiddenStagedFiles.scopeKey !== fileSelectionScopeKey) return EMPTY_PATH_SET; + const paths = new Set(); + for (const [path, completedAt] of hiddenStagedFiles.entries) { + if (completedAt === null || previewGeneratedAt === null || previewGeneratedAt < completedAt) { + paths.add(path); + } + } + return paths; + }, [fileSelectionScopeKey, hiddenStagedFiles, previewGeneratedAt]); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { return []; @@ -729,14 +736,17 @@ export default function DiffPanel({ setPendingStagePaths((current) => new Set([...current, ...paths])); const leavesFilteredView = (workingTreeFilter === "unstaged" && staged) || (workingTreeFilter === "staged" && !staged); + const scopeKey = fileSelectionScopeKey; + const updateHiddenFiles = (update: (entries: Map) => void) => + setHiddenStagedFiles((current) => { + const entries = new Map(current.scopeKey === scopeKey ? current.entries : []); + update(entries); + return { scopeKey, entries }; + }); if (leavesFilteredView) { - setOptimisticallyHiddenFiles((current) => ({ - previewKey, - paths: new Set([ - ...(current.previewKey === previewKey ? current.paths : EMPTY_PATH_SET), - ...paths, - ]), - })); + updateHiddenFiles((entries) => { + for (const path of paths) entries.set(path, null); + }); } void (async () => { const result = await stagePaths({ @@ -748,14 +758,18 @@ export default function DiffPanel({ for (const path of paths) next.delete(path); return next; }); + if (leavesFilteredView) { + updateHiddenFiles((entries) => { + for (const path of paths) { + if (result._tag === "Success") { + entries.set(path, DateTime.toEpochMillis(result.value.completedAt)); + } else { + entries.delete(path); + } + } + }); + } if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - if (leavesFilteredView) { - setOptimisticallyHiddenFiles((current) => { - const next = new Set(current.paths); - for (const path of paths) next.delete(path); - return { previewKey: current.previewKey, paths: next }; - }); - } const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ @@ -768,7 +782,14 @@ export default function DiffPanel({ refreshBranchDiffPreview(); })(); }, - [activeThread, previewCwd, previewKey, refreshBranchDiffPreview, stagePaths, workingTreeFilter], + [ + activeThread, + fileSelectionScopeKey, + previewCwd, + refreshBranchDiffPreview, + stagePaths, + workingTreeFilter, + ], ); // The filtered views already say which way a file can go; the unfiltered view asks the index. const stagingActionFor = useCallback( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 6eebf548e37f..de47fcc413e5 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -193,6 +193,12 @@ export const VcsStagePathsInput = Schema.Struct({ }); export type VcsStagePathsInput = typeof VcsStagePathsInput.Type; +export const VcsStagePathsResult = Schema.Struct({ + /** Server clock when the index was updated; a preview generated later reflects it. */ + completedAt: Schema.DateTimeUtc, +}); +export type VcsStagePathsResult = typeof VcsStagePathsResult.Type; + export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, kind: Schema.optional(VcsDriverKind), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f28d2ad500f4..154cf46c6bf9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -57,6 +57,7 @@ import { GitActionProgressEvent, VcsSwitchRefInput, VcsStagePathsInput, + VcsStagePathsResult, VcsSwitchRefResult, GitCommandError, VcsCreateRefInput, @@ -1079,6 +1080,7 @@ const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { const WsVcsStagePathsRpc = Rpc.make(WS_METHODS.vcsStagePaths, { payload: VcsStagePathsInput, + success: VcsStagePathsResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), });