From b0ca08a452d6b5f6078fe24211dd7a86248db79c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 13 Jun 2026 00:55:18 +0800 Subject: [PATCH] Keep git review state fresh after reconnect (#1491) --- packages/app/src/contexts/session-context.tsx | 7 + .../app/src/git/checkout-status-cache.test.ts | 205 +++++++++++++----- packages/app/src/git/checkout-status-cache.ts | 74 +++++-- packages/app/src/git/diff-pane.tsx | 40 ++-- .../src/git/pull-request-panel/use-data.ts | 4 +- packages/app/src/git/query-keys.test.ts | 44 ++++ packages/app/src/git/query-keys.ts | 35 ++- packages/app/src/git/use-pr-status-query.ts | 41 +--- packages/app/src/git/use-status-query.ts | 37 +--- packages/app/src/review/index.ts | 6 +- packages/app/src/review/state.ts | 91 +++++--- packages/app/src/review/store.test.ts | 172 +++++++++++++-- packages/app/src/review/store.ts | 51 ++++- packages/app/src/review/surface.test.tsx | 2 +- packages/app/src/runtime/host-runtime.ts | 6 + .../screens/workspace/workspace-screen.tsx | 11 +- 16 files changed, 592 insertions(+), 234 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index ecb79892e..d7f408d89 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -69,6 +69,7 @@ import { import { isNative } from "@/constants/platform"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; +import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -1300,6 +1301,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload)); }); + const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => { + if (message.type !== "checkout_status_update") return; + applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); + }); + const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { if (message.type !== "workspace_setup_progress") return; applyWorkspaceSetupProgress(message.payload); @@ -1648,6 +1654,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentTimeline(); unsubWorkspaceUpdate(); unsubScriptStatusUpdate(); + unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); unsubWorkspaceSetupStatusResponse(); unsubStatus(); diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 334d67a4e..788c6eb1b 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -1,11 +1,17 @@ +// @vitest-environment jsdom +// The review draft store persists through AsyncStorage's web shim, which needs window. +import "@/test/window-local-storage"; import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys"; +import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys"; +import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { - applyCheckoutStatusUpdate, + applyCheckoutStatusUpdateFromEvent, + type CheckoutPrStatusPayload, type CheckoutStatusPayload, - peekOrFetchCheckoutStatus, + fetchCheckoutStatus, } from "./checkout-status-cache"; const serverId = "server-1"; @@ -31,94 +37,179 @@ function checkoutStatus(overrides: Partial = {}): Checkou } as CheckoutStatusPayload; } -function checkoutStatusUpdate(payload: CheckoutStatusPayload): CheckoutStatusUpdate { - return { type: "checkout_status_update", payload }; +function prStatus(overrides: Partial = {}): CheckoutPrStatusPayload { + return { + cwd, + status: { + url: "https://github.com/getpaseo/paseo/pull/42", + title: "My PR", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: false, + mergeable: "MERGEABLE", + checks: [], + checksStatus: "success", + reviewDecision: null, + }, + githubFeaturesEnabled: true, + error: null, + requestId: "pr-status-1", + ...overrides, + }; +} + +function checkoutStatusUpdate( + payload: CheckoutStatusPayload, + extraPrStatus?: CheckoutPrStatusPayload, +): CheckoutStatusUpdate { + return { + type: "checkout_status_update", + payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload, + }; +} + +function setDiffModeOverride(isDirtyAtSelection: boolean): void { + useReviewDraftStore.getState().setDiffModeOverride({ + scopeKey: "review:scope", + override: { serverId, cwd, mode: "base", isDirtyAtSelection }, + }); } function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); } -describe("peekOrFetchCheckoutStatus", () => { - it("fetches from the client and writes the result to the cache on a cold read", async () => { - const queryClient = createQueryClient(); - const fetched = checkoutStatus({ requestId: "cold-read" }); +beforeEach(() => { + resetReviewDraftStore(); +}); + +describe("fetchCheckoutStatus", () => { + it("fetches from the client and returns the payload", async () => { + const fetched = checkoutStatus({ requestId: "fetch-1" }); const client = { getCheckoutStatus: vi.fn(async () => fetched) }; - const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + const result = await fetchCheckoutStatus({ client, serverId, cwd }); expect(result).toEqual(fetched); expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(fetched); }); - it("returns the cached snapshot without calling the client when the cache already has data", async () => { - const queryClient = createQueryClient(); - const cached = checkoutStatus({ requestId: "cached" }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached); - const client = { - getCheckoutStatus: vi.fn(async () => checkoutStatus({ requestId: "uncached" })), - }; + it("expires a manual diff-mode override when the fetched dirty state flipped", async () => { + setDiffModeOverride(true); + const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) }; - const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + await fetchCheckoutStatus({ client, serverId, cwd }); - expect(result).toEqual(cached); - expect(client.getCheckoutStatus).not.toHaveBeenCalled(); + expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); }); }); -describe("applyCheckoutStatusUpdate", () => { - it("writes the pushed payload into the cache key for the matching cwd", () => { +describe("applyCheckoutStatusUpdateFromEvent", () => { + it("writes the checkout status to the cache using the cwd from the payload", () => { const queryClient = createQueryClient(); - const pushed = checkoutStatus({ - requestId: "server-push", - currentBranch: "pushed-branch", - isDirty: true, - aheadBehind: { ahead: 2, behind: 1 }, - aheadOfOrigin: 2, - behindOfOrigin: 1, - }); + const pushed = checkoutStatus({ requestId: "push-1", isDirty: true }); - applyCheckoutStatusUpdate({ + applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, - cwd, message: checkoutStatusUpdate(pushed), }); expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); }); - it("ignores updates whose payload cwd does not match the subscribed cwd", () => { + it("writes the PR status cache when prStatus is present, and skips it otherwise", () => { const queryClient = createQueryClient(); - const otherCwd = "/other-repo"; - const otherCached = checkoutStatus({ - cwd: otherCwd, - repoRoot: otherCwd, - requestId: "other-cached", - currentBranch: "other-main", - }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached); + const pushedPr = prStatus({ requestId: "pr-1" }); - applyCheckoutStatusUpdate({ + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus(), pushedPr), + }); + expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(pushedPr); + + const otherCwd = "/repo2"; + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ cwd: otherCwd, repoRoot: otherCwd })), + }); + expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined(); + }); + + it("expires a manual diff-mode override when the pushed dirty state flipped", () => { + const queryClient = createQueryClient(); + setDiffModeOverride(false); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), + }); + + expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); + }); + + it("keeps a manual diff-mode override while the pushed dirty state still matches", () => { + const queryClient = createQueryClient(); + setDiffModeOverride(true); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), + }); + + expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined(); + }); + + it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + checkoutPrStatusQueryKey(serverId, cwd), + prStatus({ requestId: "pr-v1" }), + ); + const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); + queryClient.setQueryData(timelineKey, { items: [] }); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })), + }); + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false); + + applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, - cwd, message: checkoutStatusUpdate( - checkoutStatus({ - cwd: otherCwd, - repoRoot: otherCwd, - requestId: "server-push", - currentBranch: "pushed-branch", + checkoutStatus(), + prStatus({ + requestId: "pr-v3", + status: { ...prStatus().status!, state: "closed" }, }), ), }); + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); + }); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toBeUndefined(); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual( - otherCached, - ); + it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => { + const queryClient = createQueryClient(); + const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); + const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 }); + queryClient.setQueryData(timelineKey, { items: [] }); + queryClient.setQueryData(otherTimelineKey, { items: [] }); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus(), prStatus()), + }); + + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false); }); }); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 471db1e4b..0efd2ccd9 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -1,48 +1,86 @@ import type { QueryClient } from "@tanstack/react-query"; import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; +import equal from "fast-deep-equal/es6"; +import { + checkoutPrStatusQueryKey, + checkoutStatusQueryKey, + invalidatePrPaneTimelineForCheckout, +} from "@/git/query-keys"; +import { expireStaleDiffModeOverrides } from "@/review/store"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; +export type CheckoutPrStatusPayload = NonNullable; export interface CheckoutStatusClient { getCheckoutStatus: (cwd: string) => Promise; } -export async function peekOrFetchCheckoutStatus({ - queryClient, +// Checkout status enters the app through exactly two doors: daemon pushes +// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run +// the dirty-state reactions, so they hold regardless of which screens are mounted. + +export async function fetchCheckoutStatus({ client, serverId, cwd, }: { - queryClient: QueryClient; client: CheckoutStatusClient; serverId: string; cwd: string; }): Promise { - const queryKey = checkoutStatusQueryKey(serverId, cwd); - const cached = queryClient.getQueryData(queryKey); - if (cached) { - return cached; - } - - const snapshot = await client.getCheckoutStatus(cwd); - queryClient.setQueryData(queryKey, snapshot); - return snapshot; + const payload = await client.getCheckoutStatus(cwd); + expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); + return payload; } -export function applyCheckoutStatusUpdate({ +export function applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, - cwd, message, }: { queryClient: QueryClient; serverId: string; - cwd: string; message: CheckoutStatusUpdate; }): void { - if (message.payload.cwd !== cwd) { + const { payload } = message; + queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload); + expireStaleDiffModeOverrides({ + serverId, + cwd: payload.cwd, + isDirty: payload.isGit && payload.isDirty, + }); + + const prStatus = payload.prStatus; + if (!prStatus) { return; } - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload); + + const previous = queryClient.getQueryData( + checkoutPrStatusQueryKey(serverId, prStatus.cwd), + ); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), prStatus); + + // The PR activity timeline has no push channel; mark it stale when the pushed PR status + // meaningfully changed. Active panes refetch immediately, evicted ones on next mount. + if (hasPrStatusChanged(previous, prStatus)) { + void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd }); + } +} + +// requestId changes on every emission and carries no PR state. +function prStatusWithoutVolatileFields( + prStatus: CheckoutPrStatusPayload, +): Omit { + const { requestId: _requestId, ...rest } = prStatus; + return rest; +} + +function hasPrStatusChanged( + previous: CheckoutPrStatusPayload | undefined, + next: CheckoutPrStatusPayload, +): boolean { + if (!previous) { + return true; + } + return !equal(prStatusWithoutVolatileFields(previous), prStatusWithoutVolatileFields(next)); } diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 6b694289e..17ff0cc5a 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -101,11 +101,10 @@ import { import { buildReviewDraftScopeKey, buildReviewDraftKey, - useActiveReviewDraftMode, useReviewAttachmentSnapshot, - useSetActiveReviewDraftMode, + useResolvedDiffMode, + useSetDiffModeOverride, type ReviewDraftComment, - type ReviewDraftMode, getInlineReviewThreadState, getSplitInlineReviewThreadState, InlineReviewGutterCell, @@ -1593,7 +1592,6 @@ export function GitDiffPane({ const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; - const [diffModeOverride, setDiffModeOverride] = useState(null); const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); const wrapLines = changesPreferences.wrapLines; @@ -1614,9 +1612,6 @@ export function GitDiffPane({ void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace }); }, [changesPreferences.hideWhitespace, updateChangesPreferences]); - // handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey - // and setActiveReviewMode are available, so they can record the active review mode. - const handleLayoutUnified = useCallback(() => { handleLayoutChange("unified"); }, [handleLayoutChange]); @@ -1691,8 +1686,6 @@ export function GitDiffPane({ const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState; - // Auto-select diff mode based on state: uncommitted when dirty, base when clean - const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base"; const reviewDraftScopeKey = useMemo( () => buildReviewDraftScopeKey({ @@ -1704,8 +1697,11 @@ export function GitDiffPane({ }), [baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId], ); - const activeReviewMode = useActiveReviewDraftMode({ scopeKey: reviewDraftScopeKey }); - const diffMode = diffModeOverride ?? activeReviewMode ?? autoDiffMode; + const diffMode = useResolvedDiffMode({ + scopeKey: reviewDraftScopeKey, + hasUncommittedChanges, + }); + const setDiffModeOverride = useSetDiffModeOverride(); const { files, @@ -1731,17 +1727,20 @@ export function GitDiffPane({ }), [baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId], ); - const setActiveReviewMode = useSetActiveReviewDraftMode(); const handleSelectUncommitted = useCallback(() => { - setDiffModeOverride("uncommitted"); - setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "uncommitted" }); - }, [reviewDraftScopeKey, setActiveReviewMode]); + setDiffModeOverride({ + scopeKey: reviewDraftScopeKey, + override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges }, + }); + }, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]); const handleSelectBase = useCallback(() => { - setDiffModeOverride("base"); - setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "base" }); - }, [reviewDraftScopeKey, setActiveReviewMode]); + setDiffModeOverride({ + scopeKey: reviewDraftScopeKey, + override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges }, + }); + }, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]); const reviewActions = useInlineReviewController({ reviewDraftKey, @@ -1994,11 +1993,6 @@ export function GitDiffPane({ } }, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); - // Clear diff mode override when auto mode changes (e.g., after commit) - useEffect(() => { - setDiffModeOverride(null); - }, [autoDiffMode]); - const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "header") { diff --git a/packages/app/src/git/pull-request-panel/use-data.ts b/packages/app/src/git/pull-request-panel/use-data.ts index 3770d0cc9..e3cd9557e 100644 --- a/packages/app/src/git/pull-request-panel/use-data.ts +++ b/packages/app/src/git/pull-request-panel/use-data.ts @@ -244,7 +244,9 @@ export function usePrPaneData({ }, enabled: shouldFetchTimeline, staleTime: Infinity, - refetchOnMount: false, + // Refetch on mount only after explicit invalidation (reconnect, or a pushed PR status + // change) — see useCheckoutStatusQuery for the rationale. + refetchOnMount: true, refetchOnReconnect: false, refetchOnWindowFocus: false, placeholderData: keepPreviousData, diff --git a/packages/app/src/git/query-keys.test.ts b/packages/app/src/git/query-keys.test.ts index 6ba9854bd..b47ed380a 100644 --- a/packages/app/src/git/query-keys.test.ts +++ b/packages/app/src/git/query-keys.test.ts @@ -5,6 +5,7 @@ import { checkoutPrStatusQueryKey, checkoutStatusQueryKey, invalidateCheckoutGitQueriesForClient, + invalidateCheckoutGitQueriesForServer, } from "@/git/query-keys"; import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys"; @@ -59,4 +60,47 @@ describe("checkout query keys", () => { queryClient.clear(); }); + + it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => { + const queryClient = new QueryClient(); + const otherServerId = "server-2"; + const otherCwd = "/tmp/repo-2"; + + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); + queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { + items: [], + }); + // Subscription-fed diff queries are deliberately not part of the server-wide sweep. + queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { + files: [], + }); + queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true }); + + await invalidateCheckoutGitQueriesForServer(queryClient, serverId); + + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) + ?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true)) + ?.isInvalidated, + ).toBe(false); + expect( + queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated, + ).toBe(false); + + queryClient.clear(); + }); }); diff --git a/packages/app/src/git/query-keys.ts b/packages/app/src/git/query-keys.ts index 5b48d09a6..e3441eed6 100644 --- a/packages/app/src/git/query-keys.ts +++ b/packages/app/src/git/query-keys.ts @@ -6,6 +6,11 @@ interface CheckoutQueryIdentity { cwd: string; } +interface CheckoutQueryScope { + serverId: string; + cwd?: string; +} + type CheckoutQueryKey = readonly unknown[]; export function checkoutStatusQueryKey(serverId: string, cwd: string) { @@ -46,17 +51,41 @@ export async function invalidateCheckoutGitQueriesForClient( ]); } +// checkoutDiff is excluded: diff queries are subscription-fed (queryFn: skipToken) and +// receive a fresh snapshot on every resubscribe, so invalidation cannot and need not +// refetch them. +export async function invalidateCheckoutGitQueriesForServer( + queryClient: QueryClient, + serverId: string, +) { + const kinds = ["checkoutStatus", "checkoutPrStatus", prPaneTimelineQueryKind]; + await Promise.all( + kinds.map((kind) => + queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(kind, { serverId }) }), + ), + ); +} + +export async function invalidatePrPaneTimelineForCheckout( + queryClient: QueryClient, + identity: CheckoutQueryIdentity, +) { + await queryClient.invalidateQueries({ + predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity), + }); +} + function checkoutQueryPredicate( queryKind: CheckoutQueryKey[0], - identity: CheckoutQueryIdentity, + scope: CheckoutQueryScope, ): (query: Query) => boolean { return (query) => { const key = query.queryKey; return ( isCheckoutQueryKey(key) && key[0] === queryKind && - key[1] === identity.serverId && - key[2] === identity.cwd + key[1] === scope.serverId && + (scope.cwd === undefined || key[2] === scope.cwd) ); }; } diff --git a/packages/app/src/git/use-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts index b9aa1a6a6..098a1deb5 100644 --- a/packages/app/src/git/use-pr-status-query.ts +++ b/packages/app/src/git/use-pr-status-query.ts @@ -1,5 +1,4 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; @@ -81,24 +80,9 @@ export function useCheckoutPrStatusQuery({ enabled = true, }: UseCheckoutPrStatusQueryOptions) { const { t } = useTranslation(); - const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - useEffect(() => { - if (!client || !isConnected || !cwd) { - return; - } - - return client.on("checkout_status_update", (message) => { - const prStatus = message.payload.prStatus; - if (!prStatus || prStatus.cwd !== cwd) { - return; - } - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus); - }); - }, [client, isConnected, cwd, queryClient, serverId]); - const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { @@ -109,7 +93,9 @@ export function useCheckoutPrStatusQuery({ }, enabled: !!client && isConnected && !!cwd && enabled, staleTime: Infinity, - refetchOnMount: false, + // Refetch on mount only after explicit invalidation (e.g. reconnect) — see + // useCheckoutStatusQuery for the rationale. + refetchOnMount: true, refetchOnReconnect: false, refetchOnWindowFocus: false, }); @@ -131,24 +117,9 @@ export function useWorkspacePrHint({ enabled = true, }: UseCheckoutPrStatusQueryOptions): PrHint | null { const { t } = useTranslation(); - const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - useEffect(() => { - if (!client || !isConnected || !cwd) { - return; - } - - return client.on("checkout_status_update", (message) => { - const prStatus = message.payload.prStatus; - if (!prStatus || prStatus.cwd !== cwd) { - return; - } - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus); - }); - }, [client, isConnected, cwd, queryClient, serverId]); - const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { @@ -159,7 +130,9 @@ export function useWorkspacePrHint({ }, enabled: !!client && isConnected && !!cwd && enabled, staleTime: Infinity, - refetchOnMount: false, + // Refetch on mount only after explicit invalidation (e.g. reconnect) — see + // useCheckoutStatusQuery for the rationale. + refetchOnMount: true, refetchOnReconnect: false, refetchOnWindowFocus: false, select: selectWorkspacePrHint, diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts index 00fde81ae..0cc0d7f93 100644 --- a/packages/app/src/git/use-status-query.ts +++ b/packages/app/src/git/use-status-query.ts @@ -1,14 +1,8 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { checkoutStatusQueryKey } from "@/git/query-keys"; -import { - applyCheckoutStatusUpdate, - type CheckoutStatusClient, - type CheckoutStatusPayload, - peekOrFetchCheckoutStatus, -} from "./checkout-status-cache"; +import { fetchCheckoutStatus } from "./checkout-status-cache"; export type { CheckoutStatusPayload } from "./checkout-status-cache"; @@ -19,40 +13,25 @@ interface UseCheckoutStatusQueryOptions { cwd: string; } -function fetchCheckoutStatus( - client: CheckoutStatusClient, - cwd: string, -): Promise { - return client.getCheckoutStatus(cwd); -} - export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const { t } = useTranslation(); - const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - useEffect(() => { - if (!client || !isConnected || !cwd) { - return; - } - - return client.on("checkout_status_update", (message) => { - applyCheckoutStatusUpdate({ queryClient, serverId, cwd, message }); - }); - }, [client, isConnected, cwd, queryClient, serverId]); - const query = useQuery({ queryKey: checkoutStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { throw new Error(t("common.errors.daemonClientUnavailable")); } - return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); + return await fetchCheckoutStatus({ client, serverId, cwd }); }, enabled: !!client && isConnected && !!cwd, staleTime: Infinity, - refetchOnMount: false, + // Freshness is push-driven (checkout_status_update applied globally); with + // staleTime: Infinity, refetchOnMount only fires after an explicit invalidation + // (e.g. reconnect), which is exactly when the push stream may have been missed. + refetchOnMount: true, refetchOnReconnect: false, refetchOnWindowFocus: false, }); @@ -81,7 +60,7 @@ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQ if (!client) { throw new Error(t("common.errors.daemonClientUnavailable")); } - return await fetchCheckoutStatus(client, cwd); + return await fetchCheckoutStatus({ client, serverId, cwd }); }, enabled: false, staleTime: CHECKOUT_STATUS_STALE_TIME, diff --git a/packages/app/src/review/index.ts b/packages/app/src/review/index.ts index 1504082f6..f15b0cbf5 100644 --- a/packages/app/src/review/index.ts +++ b/packages/app/src/review/index.ts @@ -2,15 +2,17 @@ export { buildReviewAttachmentSnapshot, buildReviewDraftKey, buildReviewDraftScopeKey, + expireStaleDiffModeOverrides, getReviewDraftComments, resetReviewDraftStore, - useActiveReviewDraftMode, useClearReviewDraft, useReviewAttachmentSnapshot, - useSetActiveReviewDraftMode, + useResolvedDiffMode, + useSetDiffModeOverride, addReviewDraftComment, type BuildReviewDraftKeyInput, type BuildReviewDraftScopeKeyInput, + type DiffModeOverride, type ReviewDraftCommentInput, type ReviewDraftComment, type ReviewDraftMode, diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts index 422b351b8..dff34ff03 100644 --- a/packages/app/src/review/state.ts +++ b/packages/app/src/review/state.ts @@ -11,32 +11,80 @@ export interface ReviewDraftComment { updatedAt: string; } +// A manual mode selection is valid only while the checkout's dirty state matches the +// value at the time of selection. serverId/cwd identify the checkout so the override can +// be expired when its dirty state changes (see expireStaleDiffModeOverridesInState). +export interface DiffModeOverride { + serverId: string; + cwd: string; + mode: ReviewDraftMode; + isDirtyAtSelection: boolean; +} + export interface ReviewDraftStoreState { drafts: Record; - activeModesByScope: Record; + // In-memory only — not persisted. Keyed by scope key. + diffModeOverrides: Record; } +// Only drafts are persisted; diffModeOverrides is intentionally excluded. export interface SerializedReviewDraftState { drafts: Record; - activeModesByScope: Record; } -export function setActiveModeInState( +export function setDiffModeOverrideInState( state: ReviewDraftStoreState, - input: { scopeKey: string; mode: ReviewDraftMode }, + input: { scopeKey: string; override: DiffModeOverride }, ): ReviewDraftStoreState { - if (state.activeModesByScope[input.scopeKey] === input.mode) { - return state; - } return { ...state, - activeModesByScope: { - ...state.activeModesByScope, - [input.scopeKey]: input.mode, + diffModeOverrides: { + ...state.diffModeOverrides, + [input.scopeKey]: input.override, }, }; } +// Drops every override for the checkout whose dirty state no longer matches the value it +// was selected under. Called whenever a checkout status enters the app (push or fetch), +// so expiry does not depend on any screen being mounted. +export function expireStaleDiffModeOverridesInState( + state: ReviewDraftStoreState, + input: { serverId: string; cwd: string; isDirty: boolean }, +): ReviewDraftStoreState { + const staleScopeKeys = Object.entries(state.diffModeOverrides) + .filter( + ([, override]) => + override.serverId === input.serverId && + override.cwd === input.cwd && + override.isDirtyAtSelection !== input.isDirty, + ) + .map(([scopeKey]) => scopeKey); + if (staleScopeKeys.length === 0) { + return state; + } + const next = { ...state.diffModeOverrides }; + for (const scopeKey of staleScopeKeys) { + delete next[scopeKey]; + } + return { ...state, diffModeOverrides: next }; +} + +// Pure read — returns the effective mode without mutating state. The staleness check is +// kept even though stale overrides are expired at the data boundary: a render can observe +// a fresh dirty state before the expiry lands, and resolution must be correct under any +// interleaving. +export function resolveDiffMode(input: { + override: DiffModeOverride | undefined; + hasUncommittedChanges: boolean; +}): ReviewDraftMode { + const { override, hasUncommittedChanges } = input; + if (override && override.isDirtyAtSelection === hasUncommittedChanges) { + return override.mode; + } + return hasUncommittedChanges ? "uncommitted" : "base"; +} + export function addCommentToState( state: ReviewDraftStoreState, input: { key: string; comment: ReviewDraftComment }, @@ -108,18 +156,18 @@ export function serializeReviewDraftState( ): SerializedReviewDraftState { return { drafts: state.drafts, - activeModesByScope: state.activeModesByScope, }; } export function normalizePersistedState(state: unknown): ReviewDraftStoreState { if (!state || typeof state !== "object") { - return { drafts: {}, activeModesByScope: {} }; + return { drafts: {}, diffModeOverrides: {} }; } - const persisted = state as { drafts?: unknown; activeModesByScope?: unknown }; + // activeModesByScope may be present in old persisted JSON — tolerate and ignore it. + const persisted = state as { drafts?: unknown }; const drafts = persisted.drafts; if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { - return { drafts: {}, activeModesByScope: {} }; + return { drafts: {}, diffModeOverrides: {} }; } const normalized: Record = {}; @@ -132,20 +180,7 @@ export function normalizePersistedState(state: unknown): ReviewDraftStoreState { ); } - const activeModesByScope: Record = {}; - const persistedActiveModes = persisted.activeModesByScope; - if ( - persistedActiveModes && - typeof persistedActiveModes === "object" && - !Array.isArray(persistedActiveModes) - ) { - for (const [key, mode] of Object.entries(persistedActiveModes)) { - if (mode === "base" || mode === "uncommitted") { - activeModesByScope[key] = mode; - } - } - } - return { drafts: normalized, activeModesByScope }; + return { drafts: normalized, diffModeOverrides: {} }; } export function isReviewDraftComment(value: unknown): value is ReviewDraftComment { diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 294c12d42..62e68f015 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -9,15 +9,25 @@ import { addCommentToState, clearReviewInState, deleteCommentFromState, + type DiffModeOverride, + expireStaleDiffModeOverridesInState, normalizePersistedState, + resolveDiffMode, type ReviewDraftComment, type ReviewDraftStoreState, - setActiveModeInState, + serializeReviewDraftState, + setDiffModeOverrideInState, updateCommentInState, } from "./state"; function emptyState(): ReviewDraftStoreState { - return { drafts: {}, activeModesByScope: {} }; + return { drafts: {}, diffModeOverrides: {} }; +} + +function makeOverride( + input: Pick, +): DiffModeOverride { + return { serverId: "server-1", cwd: "/repo", ...input }; } function makeComment(overrides: Partial = {}): ReviewDraftComment { @@ -95,7 +105,7 @@ describe("buildReviewDraftKey", () => { ).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false"); }); - it("builds a mode-free scope key for active review mode sharing", () => { + it("builds a mode-free scope key for diff mode override sharing", () => { const scope = buildReviewDraftScopeKey({ serverId: "local", workspaceId: "workspace-1", @@ -112,7 +122,7 @@ describe("buildReviewDraftKey", () => { }); describe("normalizePersistedState", () => { - it("filters invalid draft comments and drops unknown active modes", () => { + it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => { const normalized = normalizePersistedState({ drafts: { "review:key": [ @@ -128,17 +138,14 @@ describe("normalizePersistedState", () => { { id: "bad", filePath: "src/example.ts" }, ], }, + // Old persisted field — must be tolerated and ignored, not migrated. activeModesByScope: { "review:scope:base": "base", "review:scope:dirty": "uncommitted", - "review:scope:bad": "other", }, }); - expect(normalized.activeModesByScope).toEqual({ - "review:scope:base": "base", - "review:scope:dirty": "uncommitted", - }); + expect(normalized.diffModeOverrides).toEqual({}); expect(normalized.drafts["review:key"]).toEqual([ { id: "comment-1", @@ -153,15 +160,144 @@ describe("normalizePersistedState", () => { }); it("returns empty state for null, non-object, or malformed inputs", () => { - expect(normalizePersistedState(null)).toEqual({ drafts: {}, activeModesByScope: {} }); - expect(normalizePersistedState("nope")).toEqual({ drafts: {}, activeModesByScope: {} }); + expect(normalizePersistedState(null)).toEqual({ drafts: {}, diffModeOverrides: {} }); + expect(normalizePersistedState("nope")).toEqual({ drafts: {}, diffModeOverrides: {} }); expect(normalizePersistedState({ drafts: [] })).toEqual({ drafts: {}, - activeModesByScope: {}, + diffModeOverrides: {}, }); }); }); +describe("serializeReviewDraftState", () => { + it("serialized output does not contain activeModesByScope or diffModeOverrides", () => { + const state = setDiffModeOverrideInState( + addCommentToState(emptyState(), { key: "review:key", comment: makeComment() }), + { + scopeKey: "review:scope", + override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }), + }, + ); + + const serialized = serializeReviewDraftState(state); + + expect(Object.keys(serialized)).toEqual(["drafts"]); + expect("activeModesByScope" in serialized).toBe(false); + expect("diffModeOverrides" in serialized).toBe(false); + expect(serialized.drafts["review:key"]).toHaveLength(1); + }); +}); + +describe("diff mode override", () => { + it("resolves to auto mode from the dirty state when no override exists", () => { + expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: true })).toBe( + "uncommitted", + ); + expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: false })).toBe("base"); + }); + + it("honors the override while isDirty matches the value at selection, across remounts", () => { + const state = setDiffModeOverrideInState(emptyState(), { + scopeKey: "review:scope", + override: makeOverride({ mode: "base", isDirtyAtSelection: true }), + }); + + // Resolution is a plain store read, so it gives the same answer on every (re)mount. + const override = state.diffModeOverrides["review:scope"]; + expect(resolveDiffMode({ override, hasUncommittedChanges: true })).toBe("base"); + }); + + it("masks a stale override before its expiry lands", () => { + const state = setDiffModeOverrideInState(emptyState(), { + scopeKey: "review:scope", + override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }), + }); + + const override = state.diffModeOverrides["review:scope"]; + expect(resolveDiffMode({ override, hasUncommittedChanges: false })).toBe("base"); + }); + + it("expires overrides for the checkout whose dirty state flipped, keeping the rest", () => { + let state = setDiffModeOverrideInState(emptyState(), { + scopeKey: "review:scope-a", + override: makeOverride({ mode: "base", isDirtyAtSelection: true }), + }); + state = setDiffModeOverrideInState(state, { + scopeKey: "review:scope-b", + override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: false }), + }); + state = setDiffModeOverrideInState(state, { + scopeKey: "review:scope-other", + override: { + serverId: "server-2", + cwd: "/other", + mode: "base", + isDirtyAtSelection: true, + }, + }); + + const next = expireStaleDiffModeOverridesInState(state, { + serverId: "server-1", + cwd: "/repo", + isDirty: false, + }); + + expect(next.diffModeOverrides["review:scope-a"]).toBeUndefined(); + expect(next.diffModeOverrides["review:scope-b"]).toBeDefined(); + expect(next.diffModeOverrides["review:scope-other"]).toBeDefined(); + }); + + it("does not resurrect an expired override when the dirty state flips back", () => { + let state = setDiffModeOverrideInState(emptyState(), { + scopeKey: "review:scope", + override: makeOverride({ mode: "base", isDirtyAtSelection: true }), + }); + + // The checkout goes clean (e.g. agent commits): the override expires at the boundary. + state = expireStaleDiffModeOverridesInState(state, { + serverId: "server-1", + cwd: "/repo", + isDirty: false, + }); + // The checkout goes dirty again: nothing to expire, and nothing comes back. + state = expireStaleDiffModeOverridesInState(state, { + serverId: "server-1", + cwd: "/repo", + isDirty: true, + }); + + expect(state.diffModeOverrides["review:scope"]).toBeUndefined(); + expect( + resolveDiffMode({ + override: state.diffModeOverrides["review:scope"], + hasUncommittedChanges: true, + }), + ).toBe("uncommitted"); + }); + + it("keeps state identity when no override is stale", () => { + const state = setDiffModeOverrideInState(emptyState(), { + scopeKey: "review:scope", + override: makeOverride({ mode: "base", isDirtyAtSelection: true }), + }); + + expect( + expireStaleDiffModeOverridesInState(state, { + serverId: "server-1", + cwd: "/repo", + isDirty: true, + }), + ).toBe(state); + expect( + expireStaleDiffModeOverridesInState(emptyState(), { + serverId: "server-1", + cwd: "/repo", + isDirty: false, + }), + ).toEqual(emptyState()); + }); +}); + describe("review draft reducers", () => { it("adds, updates, and deletes draft comments by key", () => { let state = emptyState(); @@ -203,18 +339,6 @@ describe("review draft reducers", () => { expect(deleteCommentFromState(state, { key: "review:key", id: "missing" })).toBe(state); expect(clearReviewInState(state, { key: "other-key" })).toBe(state); }); - - it("keeps state identity when setActiveMode does not change the mode", () => { - const state = setActiveModeInState(emptyState(), { - scopeKey: "review:scope", - mode: "base", - }); - - expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "base" })).toBe(state); - expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "uncommitted" })).not.toBe( - state, - ); - }); }); describe("buildReviewAttachmentSnapshot", () => { diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index 168381a42..84c39a344 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -8,22 +8,31 @@ import { addCommentToState, clearReviewInState, deleteCommentFromState, + type DiffModeOverride, + expireStaleDiffModeOverridesInState, normalizePersistedState, + resolveDiffMode, type ReviewDraftComment, type ReviewDraftMode, type ReviewDraftSide, type ReviewDraftStoreState, serializeReviewDraftState, - setActiveModeInState, + setDiffModeOverrideInState, updateCommentInState, } from "@/review/state"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; -export type { ReviewDraftComment, ReviewDraftMode, ReviewDraftSide } from "@/review/state"; +export type { + DiffModeOverride, + ReviewDraftComment, + ReviewDraftMode, + ReviewDraftSide, +} from "@/review/state"; -const STORE_VERSION = 1; +// v2 dropped persisted activeModesByScope (diff mode overrides are in-memory only). +const STORE_VERSION = 2; const CONTEXT_RADIUS = 3; const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = []; @@ -55,7 +64,7 @@ export type ReviewDraftCommentInput = Omit>; interface ReviewDraftStoreActions { - setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void; + setDiffModeOverride: (input: { scopeKey: string; override: DiffModeOverride }) => void; addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment; updateComment: (input: { key: string; @@ -129,9 +138,9 @@ export const useReviewDraftStore = create()( persist( (set) => ({ drafts: {}, - activeModesByScope: {}, - setActiveMode: (input) => { - set((state) => setActiveModeInState(state, input)); + diffModeOverrides: {}, + setDiffModeOverride: (input) => { + set((state) => setDiffModeOverrideInState(state, input)); }, addComment: ({ key, comment }) => { const nextComment = createDraftComment(comment); @@ -271,8 +280,18 @@ export function useReviewDraftComments(key: string): ReviewDraftComment[] { return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS); } -export function useSetActiveReviewDraftMode(): ReviewDraftStoreActions["setActiveMode"] { - return useReviewDraftStore((state) => state.setActiveMode); +export function useSetDiffModeOverride(): ReviewDraftStoreActions["setDiffModeOverride"] { + return useReviewDraftStore((state) => state.setDiffModeOverride); +} + +// Non-React entry point: called from the checkout-status data boundary, where dirty-state +// changes enter the app regardless of which screens are mounted. +export function expireStaleDiffModeOverrides(input: { + serverId: string; + cwd: string; + isDirty: boolean; +}): void { + useReviewDraftStore.setState((state) => expireStaleDiffModeOverridesInState(state, input)); } export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] { @@ -291,7 +310,7 @@ export function getReviewDraftComments(key: string): ReviewDraftComment[] | unde } export function resetReviewDraftStore(): void { - useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} }); + useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} }); } export function useReviewDraftCommentsForAttachment(input: { @@ -309,8 +328,16 @@ export function useReviewCommentCount(key: string): number { return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0); } -export function useActiveReviewDraftMode(input: { scopeKey: string }): ReviewDraftMode | null { - return useReviewDraftStore((state) => state.activeModesByScope[input.scopeKey] ?? null); +export function useResolvedDiffMode(input: { + scopeKey: string; + hasUncommittedChanges: boolean; +}): ReviewDraftMode { + return useReviewDraftStore((state) => + resolveDiffMode({ + override: state.diffModeOverrides[input.scopeKey], + hasUncommittedChanges: input.hasUncommittedChanges, + }), + ); } export function useReviewAttachmentSnapshot(input: { diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index acb57e78e..bc1636804 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -168,7 +168,7 @@ function comment(overrides: Partial = {}): ReviewDraftCommen describe("useInlineReviewController", () => { beforeEach(() => { - useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} }); + useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} }); }); afterEach(() => { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 3bfc32980..c84bf13f2 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -38,6 +38,8 @@ import { } from "@/desktop/daemon/desktop-daemon-transport"; import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore } from "@/stores/session-store"; +import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; +import { queryClient } from "@/query/query-client"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRegistryStatus = "loading" | "ready"; @@ -1767,6 +1769,10 @@ export class HostRuntimeStore { snapshot.connectionStatus === "online" && previousStatus !== "online"; if (didTransitionOnline) { useSessionStore.getState().bumpHistorySyncGeneration(serverId); + // Checkout git data is push-driven; pushes emitted while disconnected are gone for + // good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active + // queries refetch now and evicted ones on their next mount. + void invalidateCheckoutGitQueriesForServer(queryClient, serverId); } // Runtime owns directory bootstrap policy, including reconnect and delayed diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 710afb38e..6b3a7b8f3 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -102,6 +102,7 @@ import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { fetchCheckoutStatus } from "@/git/checkout-status-cache"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -1715,10 +1716,16 @@ function useWorkspaceCheckoutStatus(input: { if (!input.client || !input.workspaceDirectory) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return await input.client.getCheckoutStatus(input.workspaceDirectory); + return await fetchCheckoutStatus({ + client: input.client, + serverId: input.normalizedServerId, + cwd: input.workspaceDirectory, + }); }, staleTime: Infinity, - refetchOnMount: false, + // Refetch on mount only after explicit invalidation (e.g. reconnect) — see + // useCheckoutStatusQuery for the rationale. + refetchOnMount: true, refetchOnReconnect: false, refetchOnWindowFocus: false, });