From 94c90a16b604c96f0305d08c67eccebc197cc19f Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 4 Sep 2026 16:41:39 +0300 Subject: [PATCH] fix(ui): reconcile git state after worktree changes --- .../chat/composer/state/useDraftTarget.ts | 13 +- .../chat/message/parts/ToolPart.tsx | 43 ----- .../chat/message/parts/toolDiffUtils.test.ts | 23 --- .../chat/message/parts/toolDiffUtils.ts | 23 --- .../work-status/WorkStatusPrimaryGroup.tsx | 11 +- packages/ui/src/components/views/GitView.tsx | 169 +++++++++++++----- packages/ui/src/lib/api/types.ts | 2 +- packages/ui/src/lib/gitApi.ts | 2 +- packages/ui/src/lib/gitApiHttp.test.ts | 75 ++++++++ packages/ui/src/lib/gitApiHttp.ts | 19 +- packages/ui/src/lib/gitStatusInvalidation.ts | 14 +- packages/ui/src/lib/sessionEvents.ts | 21 +++ .../src/lib/worktrees/worktreeManager.test.ts | 39 ++++ .../ui/src/lib/worktrees/worktreeManager.ts | 14 +- packages/ui/src/stores/DOCUMENTATION.md | 7 +- packages/ui/src/stores/useGitStore.test.ts | 57 +++++- packages/ui/src/stores/useGitStore.ts | 23 ++- .../__tests__/session-switch-resync.test.ts | 37 ++++ packages/ui/src/sync/sync-context.tsx | 9 + packages/vscode/webview/api/git.ts | 2 +- 20 files changed, 438 insertions(+), 165 deletions(-) diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index 82f31283..e4c2b916 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -198,6 +198,7 @@ export function useDraftTarget(enabled: boolean) { // about, such as the New Worktree dialog), so the probe never reads the // transient bootstrap files as the branch being dirty. const selectedDraftDirectoryBootstrapPending = useWorktreeBootstrapPending(selectedDraftDirectory); + const draftDirectoryNeedsFreshStatusRef = React.useRef(null); React.useEffect(() => { if ( @@ -208,14 +209,24 @@ export function useDraftTarget(enabled: boolean) { || newSessionDraft?.bootstrapPendingDirectory || selectedDraftDirectoryBootstrapPending ) { + if (selectedDraftDirectoryBootstrapPending && selectedDraftDirectory) { + draftDirectoryNeedsFreshStatusRef.current = selectedDraftDirectory; + } setDirtyDraftDirectory(null); return; } let cancelled = false; setDirtyDraftDirectory(null); - getGitStatus(selectedDraftDirectory, { mode: 'light' }) + const needsFreshStatus = draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory; + const statusRequest = needsFreshStatus + ? getGitStatus(selectedDraftDirectory, { mode: 'light', fresh: true }) + : getGitStatus(selectedDraftDirectory, { mode: 'light' }); + statusRequest .then((status) => { + if (!cancelled && needsFreshStatus && draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory) { + draftDirectoryNeedsFreshStatusRef.current = null; + } if (!cancelled && (status.files?.length ?? 0) > 0) { setDirtyDraftDirectory(selectedDraftDirectory); } diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index adf49da3..29425e2e 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -14,7 +14,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; import { useUIStore } from '@/stores/useUIStore'; -import { sessionEvents } from '@/lib/sessionEvents'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; @@ -57,7 +56,6 @@ import { extractFirstChangedLineFromDiff, getDiffPatchEntries, getFirstChangedLineFromMetadata, - getMutatedToolPaths, getPatchText, getPrimaryDiffFromMetadata, getPrimaryToolPath, @@ -108,14 +106,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => { return trimmed; }; -const GIT_REFRESH_MUTATING_TOOLS = new Set([ - 'bash', - 'edit', - 'write', - 'apply_patch', - 'patch', -]); - const formatDuration = (start: number, end?: number, now: number = Date.now()) => { const duration = Math.max(0, (end ?? now) - start); const seconds = duration / 1000; @@ -1699,19 +1689,16 @@ const ToolPartContent: React.FC = ({ const status = state?.status as string | undefined; const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; - const isSuccessfullyFinalized = status === 'completed'; const isError = status === 'error' || status === 'failed'; const [activeLatched, setActiveLatched] = React.useState(!isFinalized); const previousPartIdRef = React.useRef(part.id); - const observedActiveGitToolRef = React.useRef(!isFinalized); React.useEffect(() => { if (previousPartIdRef.current === part.id) { return; } previousPartIdRef.current = part.id; - observedActiveGitToolRef.current = !isFinalized; // Reset latch only when tool identity changes. setActiveLatched(!isFinalized); }, [isFinalized, part.id]); @@ -1722,36 +1709,6 @@ const ToolPartContent: React.FC = ({ } }, [isFinalized]); - React.useEffect(() => { - if (!isFinalized) { - observedActiveGitToolRef.current = true; - return; - } - - // Historical completed tools can remount when the timeline changes. - // Refresh only for a tool whose active state this instance observed. - const finalizedAfterObservedActive = observedActiveGitToolRef.current; - if (!finalizedAfterObservedActive) { - return; - } - - if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) { - observedActiveGitToolRef.current = false; - return; - } - if (!currentDirectory) { - return; - } - - observedActiveGitToolRef.current = false; - const paths = getMutatedToolPaths(normalizedPartTool, input, metadata) - .map((path) => getRelativePath(path, currentDirectory)); - sessionEvents.requestGitRefresh({ - directory: currentDirectory, - ...(paths.length > 0 ? { paths } : {}), - }); - }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); - const expandedContentRef = React.useRef(null); React.useLayoutEffect(() => { diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index b40456c0..e0352fed 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -5,7 +5,6 @@ import { getApplyPatchFilePath, getDiffPatchEntries, getFirstChangedLineFromMetadata, - getMutatedToolPaths, getPrimaryDiffFromMetadata, getPrimaryToolPath, getRenderablePatchInfo, @@ -57,28 +56,6 @@ describe('toolDiffUtils', () => { })).toBe('/workspace/project/src/second.ts'); }); - test('lists every apply_patch mutation path, including both sides of a move', () => { - expect(getMutatedToolPaths('apply_patch', undefined, { - files: [ - { filePath: '/workspace/project/src/deleted.ts', type: 'delete' }, - { - filePath: '/workspace/project/src/old.ts', - movePath: '/workspace/project/src/new.ts', - type: 'move', - }, - ], - })).toEqual([ - '/workspace/project/src/deleted.ts', - '/workspace/project/src/new.ts', - '/workspace/project/src/old.ts', - ]); - }); - - test('does not invent paths for bash or task tools', () => { - expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]); - expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]); - }); - test('selects the move patch and line from the same non-deleted file', () => { const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted'; const movedPatch = '@@ -42 +42 @@\n-before\n+after'; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 0818130e..cfe1796e 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -200,29 +200,6 @@ export const getPrimaryToolPath = ( return null; }; -export const getMutatedToolPaths = ( - toolName: string, - input: Record | undefined, - metadata: Record | undefined, -): string[] => { - if (toolName === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata.files : []; - const paths = new Set(); - for (const file of files) { - if (!isRecord(file)) continue; - const filePath = getApplyPatchFilePath(file); - if (filePath) paths.add(filePath); - if (file.type === 'move' && typeof file.filePath === 'string') { - paths.add(file.filePath); - } - } - return [...paths]; - } - - const primaryPath = getPrimaryToolPath(toolName, input, metadata); - return primaryPath ? [primaryPath] : []; -}; - const supportsDiffMetadata = (toolName: string): boolean => ( toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch' ); diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index 9289c5f4..412f1f3c 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -90,12 +90,17 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, } if (awaitingPostBootstrapStatus) { let cancelled = false; - void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, { silent: true })) - .finally(() => { + void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, { + force: true, + silent: true, + throwOnError: true, + })) + .then(() => { if (!cancelled) { setPostBootstrapRefreshDirectory((current) => (current === gitDirectory ? null : current)); } - }); + }) + .catch(() => undefined); return () => { cancelled = true; }; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 7ff07e05..8a79e210 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -20,6 +20,7 @@ import { useGitLoadingLog, } from '@/stores/useGitStore'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending'; import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; @@ -203,8 +204,14 @@ export const GitView: React.FC = ({ isActive }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); - const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null); - const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false); + const [worktreeBootstrapSnapshot, setWorktreeBootstrapSnapshot] = React.useState<{ + directory: string; + status: 'pending' | 'ready' | 'failed' | null; + } | null>(null); + const [postBootstrapRefresh, setPostBootstrapRefresh] = React.useState<{ + directory: string; + status: 'refreshing' | 'failed'; + } | null>(null); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); @@ -329,7 +336,6 @@ export const GitView: React.FC = ({ isActive }) => { }); const navigateToDiff = useUIStore((state) => state.navigateToDiff); - const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null); const gitReconcileTimeoutRef = React.useRef(null); const gitMutationFlushTimeoutRef = React.useRef(null); const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null); @@ -438,11 +444,13 @@ export const GitView: React.FC = ({ isActive }) => { React.useEffect(() => { if (!isActive) return; if (!currentDirectory) { - setWorktreeBootstrapStatus(null); - setIsWaitingForGitRefreshAfterBootstrap(false); + setWorktreeBootstrapSnapshot(null); return; } + const bootstrapDirectory = normalizePath(currentDirectory) ?? currentDirectory; + setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null }); + let cancelled = false; let timeoutId: number | null = null; @@ -452,7 +460,7 @@ export const GitView: React.FC = ({ isActive }) => { if (cancelled) { return; } - setWorktreeBootstrapStatus(next.status); + setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: next.status }); if (next.status === 'pending') { timeoutId = window.setTimeout(() => { void poll(); @@ -460,7 +468,7 @@ export const GitView: React.FC = ({ isActive }) => { } } catch { if (!cancelled) { - setWorktreeBootstrapStatus(null); + setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null }); } } }; @@ -475,37 +483,84 @@ export const GitView: React.FC = ({ isActive }) => { }; }, [isActive, currentDirectory]); - React.useEffect(() => { - const previous = previousBootstrapStatusRef.current; - previousBootstrapStatusRef.current = worktreeBootstrapStatus; - - if (!currentDirectory || !git) { - return; - } - - if (previous === 'pending' && worktreeBootstrapStatus === 'ready') { - setIsWaitingForGitRefreshAfterBootstrap(true); - void fetchAll(currentDirectory, git).finally(() => { - window.setTimeout(() => { - setIsWaitingForGitRefreshAfterBootstrap(false); - }, 1200); - }); - } - - if (worktreeBootstrapStatus === 'failed') { - setDraftBootstrapPendingDirectory(null); - setIsWaitingForGitRefreshAfterBootstrap(false); - } - }, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]); - const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); const isDraftBootstrapPendingForCurrentDirectory = Boolean( currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory) ); + const sharedWorktreeBootstrapPending = useWorktreeBootstrapPending(currentDirectory ?? null); + const normalizedCurrentBootstrapDirectory = normalizePath(currentDirectory); + const observedWorktreeBootstrapStatus = worktreeBootstrapSnapshot?.directory === normalizedCurrentBootstrapDirectory + ? worktreeBootstrapSnapshot.status + : null; const isPendingWorktreeSetup = Boolean( - currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory) + currentDirectory + && ( + sharedWorktreeBootstrapPending + || observedWorktreeBootstrapStatus === 'pending' + || (isDraftBootstrapPendingForCurrentDirectory && newSessionDraft?.pendingWorktreeRequestId) + ) ); - const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap; + const isPostBootstrapRefreshForCurrentDirectory = Boolean( + normalizedCurrentBootstrapDirectory + && postBootstrapRefresh?.directory === normalizedCurrentBootstrapDirectory + ); + + React.useEffect(() => { + if (!normalizedCurrentBootstrapDirectory) return; + + if (observedWorktreeBootstrapStatus === 'failed') { + setDraftBootstrapPendingDirectory(null); + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory ? null : current + )); + return; + } + + if (isPendingWorktreeSetup) { + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory && current.status === 'refreshing' + ? current + : { directory: normalizedCurrentBootstrapDirectory, status: 'refreshing' } + )); + return; + } + + if ( + postBootstrapRefresh?.directory !== normalizedCurrentBootstrapDirectory + || postBootstrapRefresh.status !== 'refreshing' + || !gitDirectory + || !git + ) { + return; + } + + let cancelled = false; + void fetchStatus(gitDirectory, git, { + force: true, + silent: true, + throwOnError: true, + }).then(() => { + if (cancelled) return; + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory ? null : current + )); + }).catch(() => { + if (cancelled) return; + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory + ? { ...current, status: 'failed' } + : current + )); + }); + + return () => { + cancelled = true; + }; + }, [fetchStatus, git, gitDirectory, isPendingWorktreeSetup, normalizedCurrentBootstrapDirectory, observedWorktreeBootstrapStatus, postBootstrapRefresh, setDraftBootstrapPendingDirectory]); + + const shouldHideGitState = isPendingWorktreeSetup || isPostBootstrapRefreshForCurrentDirectory; + const postBootstrapRefreshFailed = isPostBootstrapRefreshForCurrentDirectory + && postBootstrapRefresh?.status === 'failed'; const initialSnapshot = React.useMemo(() => { if (!gitDirectory) return null; @@ -2336,6 +2391,42 @@ export const GitView: React.FC = ({ isActive }) => { ); } + if (shouldHideGitState) { + return ( +
+ {!postBootstrapRefreshFailed ? ( + + ) : null} +

+ {postBootstrapRefreshFailed + ? t('gitView.toast.refreshRepositoryFailed') + : t('gitView.empty.worktreeSetupInProgress')} +

+ {!postBootstrapRefreshFailed ? ( +

+ {t('gitView.empty.worktreeSetupDescription')} +

+ ) : ( + + )} +
+ ); + } + if (isGitRepo === null || (isGitRepo === true && !status)) { return (
@@ -2348,20 +2439,6 @@ export const GitView: React.FC = ({ isActive }) => { } if (isGitRepo === false) { - if (shouldHideNotGitState) { - return ( -
- -

- {t('gitView.empty.worktreeSetupInProgress')} -

-

- {t('gitView.empty.worktreeSetupDescription')} -

-
- ); - } - // Nested repository discovery states (discovering, failed, unsupported, // none found, or settling on the auto-selected repository). return ( diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 910896cb..fe1bbdc2 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -494,7 +494,7 @@ interface GitWorktreeAPI { export interface GitAPI { checkIsGitRepository(directory: string): Promise; - getGitStatus(directory: string, options?: { mode?: 'light' }): Promise; + getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise; getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 1d0e325a..1522154f 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -84,7 +84,7 @@ export async function checkIsGitRepository(directory: string): Promise return gitHttp.checkIsGitRepository(directory); } -export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { +export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.getGitStatus(directory, options); return gitHttp.getGitStatus(directory, options); diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index bc2e2c49..1067481d 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -29,6 +29,7 @@ import { unstageGitFiles, } from './gitApiHttp'; import type { GitStatus } from './api/types'; +import { sessionEvents } from './sessionEvents'; type FetchCall = { input: RequestInfo | URL; @@ -141,6 +142,28 @@ describe('gitApiHttp index mutations', () => { }); describe('gitApiHttp status cache', () => { + test('a Git refresh hint invalidates the cached status before listeners fetch', async () => { + installWindowMock(); + let statusRequestCount = 0; + globalThis.fetch = async () => { + statusRequestCount += 1; + return jsonResponse(statusPayload({ behind: statusRequestCount })); + }; + + try { + const directory = '/repo-cache-tool-mutation'; + const first = await getGitStatus(directory); + sessionEvents.requestGitRefresh({ directory }); + const afterMutation = await getGitStatus(directory); + + expect(first.behind).toBe(1); + expect(afterMutation.behind).toBe(2); + expect(statusRequestCount).toBe(2); + } finally { + restoreMocks(); + } + }); + test('invalidates cached status after fetch', async () => { installWindowMock(); const calls: FetchCall[] = []; @@ -188,6 +211,58 @@ describe('gitApiHttp status cache', () => { restoreMocks(); } }); + + test('fresh status bypasses an unexpired cached snapshot', async () => { + installWindowMock(); + let statusRequestCount = 0; + globalThis.fetch = (async () => { + statusRequestCount += 1; + return jsonResponse(statusPayload({ behind: statusRequestCount })); + }) as typeof fetch; + + try { + const directory = '/repo-cache-fresh'; + const first = await getGitStatus(directory); + const cached = await getGitStatus(directory); + const fresh = await getGitStatus(directory, { fresh: true }); + + expect(first.behind).toBe(1); + expect(cached.behind).toBe(1); + expect(fresh.behind).toBe(2); + expect(statusRequestCount).toBe(2); + } finally { + restoreMocks(); + } + }); + + test('fresh status cannot be replaced in cache by an older in-flight response', async () => { + installWindowMock(); + const statusResolvers: Array<(response: Response) => void> = []; + // SAFETY: the mock accepts the same arguments as fetch and always returns + // a pending Response promise controlled by this test. + globalThis.fetch = (async () => new Promise((resolve) => { + statusResolvers.push(resolve); + })) as typeof fetch; + + try { + const directory = '/repo-cache-fresh-race'; + const older = getGitStatus(directory); + await new Promise((resolve) => setTimeout(resolve, 0)); + const fresh = getGitStatus(directory, { fresh: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(statusResolvers).toHaveLength(2); + statusResolvers[1](jsonResponse(statusPayload({ current: 'fresh' }))); + statusResolvers[0](jsonResponse(statusPayload({ current: 'stale' }))); + + expect((await fresh).current).toBe('fresh'); + expect((await older).current).toBe('stale'); + expect((await getGitStatus(directory)).current).toBe('fresh'); + expect(statusResolvers).toHaveLength(2); + } finally { + restoreMocks(); + } + }); }); const statusPayload = (overrides: Partial = {}): GitStatus => ({ diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 197eb7cf..7eda4da4 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -40,7 +40,7 @@ import { normalizePath } from './pathNormalization'; import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeKey } from './runtime-switch'; -import { notifyGitStatusInvalidated } from './gitStatusInvalidation'; +import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation'; const API_BASE = '/api/git'; const GIT_STATUS_CACHE_TTL_MS = 1200; @@ -60,8 +60,7 @@ const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light' const getStatusCacheVersion = (runtimeKey: string, directory: string): number => gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0; -const invalidateGitStatusCache = (directory: string): void => { - const runtimeKey = getRuntimeKey(); +const clearGitStatusCache = (runtimeKey: string, directory: string): void => { const key = getDirectoryCacheKey(runtimeKey, directory); gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1); for (const mode of [undefined, 'light'] as const) { @@ -69,6 +68,13 @@ const invalidateGitStatusCache = (directory: string): void => { gitStatusCache.delete(statusKey); gitStatusInFlight.delete(statusKey); } +}; + +subscribeGitStatusInvalidations((directory) => { + clearGitStatusCache(getRuntimeKey(), directory); +}); + +const invalidateGitStatusCache = (directory: string): void => { notifyGitStatusInvalidated(directory); }; @@ -162,9 +168,14 @@ export async function listGitDirectories(root: string): Promise { .filter((path): path is string => path !== null); } -export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { +export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise { const mode = options?.mode; const runtimeKey = getRuntimeKey(); + if (options?.fresh) { + // A forced read must cross the transport cache boundary too. Advancing the + // version also prevents an older in-flight response from repopulating it. + clearGitStatusCache(runtimeKey, directory); + } const key = getStatusCacheKey(runtimeKey, directory, mode); const now = Date.now(); const cached = gitStatusCache.get(key); diff --git a/packages/ui/src/lib/gitStatusInvalidation.ts b/packages/ui/src/lib/gitStatusInvalidation.ts index 9e337365..effc8254 100644 --- a/packages/ui/src/lib/gitStatusInvalidation.ts +++ b/packages/ui/src/lib/gitStatusInvalidation.ts @@ -1,18 +1,18 @@ /** * Minimal notification channel for git status invalidation. * - * Every successful status-affecting git mutation must call + * Every confirmed status-affecting mutation must call * `notifyGitStatusInvalidated`. `useGitStore` subscribes and bumps its * per-directory status mutation revision so an immediate refresh cannot join an * in-flight status request admitted before the mutation, and a stale response - * cannot commit over newer authoritative state. + * cannot commit over newer authoritative state. The HTTP adapter also subscribes + * and clears its short-lived status cache. * * Runtime parity: this is about the store's in-flight status request, not about - * adapter caching, so it applies to every runtime. The HTTP adapter in - * `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the - * VS Code bridge) have no cache of their own, so the dispatch layer in - * `gitApi.ts` emits it for them after a successful runtime mutation. Either - * path announces a mutation exactly once. + * adapter caching, so it applies to every runtime. HTTP mutations emit from + * `gitApiHttp.ts`; runtime adapters such as the VS Code bridge emit from the + * dispatch layer in `gitApi.ts`. Tool and editor mutations emit through the + * shared Git refresh hint. Each path announces a mutation exactly once. */ type GitStatusInvalidationListener = (directory: string) => void; diff --git a/packages/ui/src/lib/sessionEvents.ts b/packages/ui/src/lib/sessionEvents.ts index 331383b6..07eba0f1 100644 --- a/packages/ui/src/lib/sessionEvents.ts +++ b/packages/ui/src/lib/sessionEvents.ts @@ -1,4 +1,6 @@ import type { Session } from '@opencode-ai/sdk/v2'; +import type { Part } from '@opencode-ai/sdk/v2/client'; +import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation'; import type { WorktreeMetadata } from '@/types/worktree'; export type SessionDeleteRequest = { @@ -24,6 +26,12 @@ const deleteListeners = new Set(); const createListeners = new Set(); const directoryListeners = new Set(); const gitRefreshListeners = new Set(); +const gitMutatingTools = new Set(['bash', 'edit', 'write', 'apply_patch', 'patch']); + +const normalizeToolName = (tool: string): string => { + const parts = tool.trim().toLowerCase().split('.').filter(Boolean); + return parts[parts.length - 1] ?? ''; +}; export const sessionEvents = { onDeleteRequest(listener: DeleteListener) { @@ -67,6 +75,19 @@ export const sessionEvents = { if (!hint.directory.trim()) { return; } + notifyGitStatusInvalidated(hint.directory); gitRefreshListeners.forEach((listener) => listener(hint)); }, + requestGitRefreshForToolTransition(directory: string, previousPart: Part | undefined, nextPart: Part) { + if (nextPart.type !== 'tool' || nextPart.state.status !== 'completed') { + return; + } + if (previousPart?.type === 'tool' && previousPart.state.status === 'completed') { + return; + } + if (!gitMutatingTools.has(normalizeToolName(nextPart.tool))) { + return; + } + sessionEvents.requestGitRefresh({ directory }); + }, }; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 6a8698cc..fe6b0820 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -106,6 +106,7 @@ const { getLatestWorktreeMetadata, listProjectWorktrees, partitionWorktreesByRegisteredProject, + removeProjectWorktree, validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -365,6 +366,44 @@ describe('worktreeManager list invalidation', () => { expect(metadata.worktreeStatus).toBe('pending'); expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready'); }); + + test('removes a worktree from sidebar topology owned by another registered checkout', async () => { + const removed: WorktreeMetadata = { + path: '/worktrees/removed', + projectDirectory: '/repo', + branch: 'removed', + label: 'removed', + }; + const sibling: WorktreeMetadata = { + path: '/worktrees/sibling', + projectDirectory: '/repo', + branch: 'sibling', + label: 'sibling', + }; + const unrelatedEntries: WorktreeMetadata[] = [{ + path: '/other/worktree', + projectDirectory: '/other', + branch: 'other', + label: 'other', + }]; + sessionState.availableWorktreesByProject = new Map([ + ['/worktrees/configured', [removed, sibling]], + ['/other', unrelatedEntries], + ]); + sessionState.availableWorktrees = [removed, sibling, ...unrelatedEntries]; + sessionState.worktreeMetadata = new Map([ + ['removed-session', removed], + ['sibling-session', sibling], + ]); + + await removeProjectWorktree({ id: 'path:/repo', path: '/repo' }, removed); + + expect(sessionState.availableWorktreesByProject.get('/worktrees/configured')).toEqual([sibling]); + expect(sessionState.availableWorktreesByProject.get('/other')).toBe(unrelatedEntries); + expect(sessionState.availableWorktrees).toEqual([sibling, ...unrelatedEntries]); + expect(sessionState.worktreeMetadata.has('removed-session')).toBe(false); + expect(sessionState.worktreeMetadata.get('sibling-session')).toBe(sibling); + }); }); describe('worktreeMapsEqual', () => { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 67cf5235..86ca8ed7 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -595,14 +595,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt // Update sidebar store so removed worktree disappears immediately const normalizedWorktreePath = normalizePath(worktree.path); - const sidebarProjectKey = projectDirectory; const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; const updatedByProject = new Map(currentByProject); - const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? []; - updatedByProject.set( - sidebarProjectKey, - projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath), - ); + for (const [projectKey, projectWorktrees] of currentByProject) { + const remainingWorktrees = projectWorktrees.filter( + (candidate) => normalizePath(candidate.path) !== normalizedWorktreePath, + ); + if (remainingWorktrees.length !== projectWorktrees.length) { + updatedByProject.set(projectKey, remainingWorktrees); + } + } // Clean up worktreeMetadata for sessions in the removed worktree const currentMetadata = useSessionUIStore.getState().worktreeMetadata; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 96152f62..1cb4780d 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -154,7 +154,8 @@ Important properties: - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes - a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today) -- `fetchAll({ force: true })` forces the status fetch as well as the log refresh +- `fetchStatus({ force: true })` and `fetchAll({ force: true })` cross both the store and runtime transport caches; a forced reconciliation must reach the active runtime rather than reuse an unexpired browser status snapshot +- status requests do not start while a managed worktree bootstrap is pending, and a response admitted before bootstrap began is discarded if it completes after the directory enters `pending`; the `--no-checkout` population window is not user working-tree state - branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once - diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected @@ -326,9 +327,11 @@ Do not raise limits casually. Expected model: - `GitView` / `DiffView` ensure current-directory Git state when visible +- the Git view gates its status-derived content and actions while a managed worktree bootstrap is pending, then keeps the gate closed until one forced fresh status read succeeds; refresh failure exposes retry without revealing the cached bootstrap snapshot - explicit Git actions refresh status/branches/log as needed - every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry -- a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint +- the sync event handler issues one Git refresh hint when a live file-mutating tool first reaches `completed`; this does not depend on `ToolPart` mounting, and duplicate terminal events do not replay the hint +- every Git refresh hint invalidates the store request generation and the HTTP status cache before visible consumers request status, so they share one post-mutation read instead of accepting a cached or pre-mutation response - a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops - refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView - targeted diff remounts preserve the user's current file-section anchor and intra-file offset before paint instead of resetting the stacked view to the top diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index e96445b9..9ba64150 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -3,6 +3,7 @@ import type { GitStatus } from '@/lib/api/types'; import { useGitStore } from './useGitStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation'; +import { clearWorktreeBootstrapState, markWorktreeBootstrapPending } from '@/lib/worktrees/worktreeBootstrap'; // The real transport has no server in tests and fails as a generic error. // Tests that exercise other failure modes swap this implementation; the @@ -86,6 +87,7 @@ const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({ describe('useGitStore', () => { beforeEach(() => { + clearWorktreeBootstrapState('/repo'); useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); }); @@ -198,8 +200,10 @@ describe('useGitStore', () => { setDirectoryStatus(createStatus()); const requests: Deferred[] = []; let statusCalls = 0; - const git = createGitApi(() => { + const statusOptions: Array<{ mode?: 'light'; fresh?: boolean } | undefined> = []; + const git = createGitApi((_directory, options) => { statusCalls += 1; + statusOptions.push(options); const request = createDeferred(); requests.push(request); return request.promise; @@ -212,6 +216,7 @@ describe('useGitStore', () => { const all = useGitStore.getState().fetchAll('/repo', git, { force: true }); await Promise.resolve(); expect(statusCalls).toBe(2); + expect(statusOptions).toEqual([undefined, { fresh: true }]); requests[1].resolve({ ...createStatus(), current: 'feature' }); requests[0].resolve(createStatus()); @@ -220,6 +225,56 @@ describe('useGitStore', () => { expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); }); + test('does not request status while worktree bootstrap is pending', async () => { + setDirectoryStatus(createStatus()); + let statusCalls = 0; + const git = createGitApi(async () => { + statusCalls += 1; + return createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]); + }); + + markWorktreeBootstrapPending('/repo'); + const changed = await useGitStore.getState().fetchStatus('/repo', git, { force: true }); + + expect(changed).toBe(false); + expect(statusCalls).toBe(0); + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]); + }); + + test('does not publish a status response after bootstrap becomes pending', async () => { + setDirectoryStatus(createStatus()); + const request = createDeferred(); + const git = createGitApi(() => request.promise); + + const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + markWorktreeBootstrapPending('/repo'); + request.resolve(createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }])); + + expect(await loading).toBe(false); + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]); + }); + + test('can propagate a forced status failure to a reconciliation owner', async () => { + setDirectoryStatus(createStatus()); + const git = createGitApi(async () => { + throw new Error('offline'); + }); + const originalConsoleError = console.error; + console.error = () => undefined; + + try { + await expect(useGitStore.getState().fetchStatus('/repo', git, { + force: true, + silent: true, + throwOnError: true, + })).rejects.toThrow('offline'); + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]); + } finally { + console.error = originalConsoleError; + } + }); + test('does not let an older status fetch undo an optimistic mutation', async () => { const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]); setDirectoryStatus(initial); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 341a77a6..a91795e3 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -11,6 +11,7 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp'; import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation'; +import { getWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -28,6 +29,7 @@ const DIFF_CACHE_MAX_ENTRIES = 30; const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200; type GitStatusFetchMode = 'full' | 'light'; +type GitStatusRequestOptions = { mode?: 'light'; fresh?: boolean }; // Discovery outcome for a root that is not itself a git repository. The three // states are mutually exclusive: a repository list (possibly empty), a failed @@ -64,7 +66,7 @@ interface GitStore { setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; - fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise; + fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean; throwOnError?: boolean }) => Promise; fetchBranches: (directory: string, git: GitAPI) => Promise; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise; fetchIdentity: (directory: string, git: GitAPI) => Promise; @@ -115,7 +117,7 @@ interface GitFileDiffResponse { interface GitAPI { checkIsGitRepository: (directory: string) => Promise; - getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise; + getGitStatus: (directory: string, options?: GitStatusRequestOptions) => Promise; getGitBranches: (directory: string) => Promise; getGitLog: (directory: string, options?: { maxCount?: number }) => Promise; getCurrentGitIdentity: (directory: string) => Promise; @@ -692,6 +694,9 @@ export const useGitStore = create()( }, fetchStatus: async (directory, git, options = {}) => { + if (getWorktreeBootstrapState(directory)?.status === 'pending') { + return false; + } const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full'; const runtimeKey = getRuntimeKey(); const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode); @@ -757,8 +762,17 @@ export const useGitStore = create()( return false; } - const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined); + let statusOptions: GitStatusRequestOptions | undefined; + if (options.mode || options.force) { + statusOptions = {}; + if (options.mode) statusOptions.mode = options.mode; + if (options.force) statusOptions.fresh = true; + } + const newStatus = await git.getGitStatus(directory, statusOptions); if (!isRequestCurrent(token, directory)) return false; + // A request admitted before worktree creation must not publish a + // transient --no-checkout/reset snapshot after bootstrap begins. + if (getWorktreeBootstrapState(directory)?.status === 'pending') return false; const latestState = get().directories.get(directory) ?? createEmptyDirectoryState(); if (hasStatusChanged(latestState.status, newStatus)) { @@ -830,6 +844,9 @@ export const useGitStore = create()( } } catch (error) { console.error('Failed to fetch git status:', error); + if (options.throwOnError) { + throw error; + } } finally { if (!silent && isRequestCurrent(token, directory)) { const newDirectories = new Map(get().directories); diff --git a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts index b3874a0d..c03bfbe5 100644 --- a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts +++ b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts @@ -67,6 +67,7 @@ mock.module("@/components/ui", () => ({ import { INITIAL_STATE, type State } from "../types" import { ChildStoreManager, type DirectoryStore } from "../child-store" import { getRuntimeKey } from "@/lib/runtime-switch" +import { sessionEvents } from "@/lib/sessionEvents" const { createEventRoutingIndex, handleEvent, @@ -340,4 +341,40 @@ describe("resyncBlockingRequestsForDirectory", () => { unsubscribe() childStores.disposeAll() }) + + test("refreshes Git once when a live mutating tool completes between renders", () => { + const childStores = new ChildStoreManager() + childStores.ensureChild("/repo", { bootstrap: false }) + const routingIndex = createEventRoutingIndex() + const refreshes: Array<{ directory: string; paths?: string[] }> = [] + const unsubscribe = sessionEvents.onGitRefreshHint((hint) => refreshes.push(hint)) + // SAFETY: this fixture supplies the SDK event discriminator and the tool + // part identity, tool name, and state fields consumed by the reducer. + const toolEvent = (tool: string, status: "pending" | "completed" | "error") => ({ + type: "message.part.updated", + properties: { + part: { + id: "prt_tool", + messageID: "msg_assistant", + sessionID: "ses_a", + type: "tool", + tool, + state: { status, input: {}, metadata: {} }, + }, + }, + }) as Event + + try { + handleEvent("/repo", toolEvent("apply_patch", "pending"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("read", "completed"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("edit", "error"), childStores, routingIndex, getRuntimeKey()) + + expect(refreshes).toEqual([{ directory: "/repo" }]) + } finally { + unsubscribe() + childStores.disposeAll() + } + }) }) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index ed2772ed..b05145b2 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -78,6 +78,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch" import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { isFilesystemError } from "@/lib/api/files-errors" import { formatMessage, useI18nStore } from "@/lib/i18n" +import { sessionEvents } from "@/lib/sessionEvents" import { listGlobalSessionPages } from "@/stores/globalSessions" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" @@ -1818,6 +1819,10 @@ export function handleEvent( // type will mutate. This preserves reference identity for untouched slices // so Zustand selectors skip re-renders for unrelated subscribers. const current = getDirectoryEventState(store, batch) + const updatedPart = payload.type === "message.part.updated" ? payload.properties.part : undefined + const previousPart = updatedPart && "messageID" in updatedPart + ? current.part[updatedPart.messageID]?.find((part) => part.id === updatedPart.id) + : undefined const draft: State = { ...current } const clonedFields = batch?.clonedFields.get(store) ?? new Set() const newlyClonedFields: Array = [] @@ -1894,6 +1899,10 @@ export function handleEvent( const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization + if (reducerChanged && updatedPart) { + sessionEvents.requestGitRefreshForToolTransition(resolvedDirectory, previousPart, updatedPart) + } + if (reducerChanged) { countSyncPerformance("reducerChangedEvents") const eventSessionID = getSessionIdFromPayload(payload) ?? undefined diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 7ed75e21..94de428f 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -69,7 +69,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage('api:git/check', { directory }); }, - getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise => { + getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise => { return sendBridgeMessage('api:git/status', { directory, mode: options?.mode }); },