diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index cd774c29..82f31283 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -17,6 +17,7 @@ import React from 'react'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending'; import { formatDirectoryName } from '@/lib/utils'; import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -191,6 +192,13 @@ export function useDraftTarget(enabled: boolean) { [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath], ); + // The draft's own pending flags clear once the directory exists, which is + // before setup commands and the initial git reset finish; the bootstrap + // state covers that remaining window (and creations the draft never knew + // 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); + React.useEffect(() => { if ( !enabled @@ -198,6 +206,7 @@ export function useDraftTarget(enabled: boolean) { || selectedDraftProject?.kind === 'chat' || newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory + || selectedDraftDirectoryBootstrapPending ) { setDirtyDraftDirectory(null); return; @@ -218,7 +227,7 @@ export function useDraftTarget(enabled: boolean) { return () => { cancelled = true; }; - }, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]); + }, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftDirectoryBootstrapPending, selectedDraftProject?.kind]); const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => { const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index d96a6b6d..1c43a17f 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -179,6 +179,13 @@ including edits the user made by hand and excluding session edits that are already committed. If a session-authored count is ever needed, it has to come from aggregating message summaries, not from `Session.summary`. +One exception: while the directory is a worktree whose creation has not +finished (`useWorktreeBootstrapPending`), the working tree transiently holds +bootstrap files that the initial git reset is about to remove. Those are not +changes on the branch, so the panel neither fetches status nor renders the +changed-files row until the bootstrap settles, then forces one status fetch so +the row reflects the reset tree rather than a mid-creation snapshot. + ## Section order Ordering is by durability, not category: diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index fc0cada6..1b0a4763 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -3,6 +3,7 @@ import { useI18n } from '@/lib/i18n'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; import { useSessionMessages } from '@/sync/sync-context'; @@ -67,12 +68,34 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, ), ); + // A worktree that is still being created transiently looks dirty until its + // setup commands and initial git reset finish. Those files are not changes + // on the branch, so status is neither fetched nor displayed until the + // bootstrap settles. + const worktreeCreationPending = useWorktreeBootstrapPending(gitDirectory); + const awaitingPostBootstrapStatusRef = React.useRef(null); + // Warm the shared git cache through the background-network gate so the panel // never competes with the chat's own bootstrap traffic for sockets. React.useEffect(() => { if (!showRepository || !gitDirectory || !git) return; - void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git)); - }, [gitDirectory, git, ensureStatus, showRepository]); + if (worktreeCreationPending) { + awaitingPostBootstrapStatusRef.current = gitDirectory; + return; + } + // Right after bootstrap the cache may still hold a status captured + // mid-creation; force one fetch so the lifted gate reveals the real + // (reset) working tree instead of the transient one. + const finishedBootstrap = awaitingPostBootstrapStatusRef.current === gitDirectory; + awaitingPostBootstrapStatusRef.current = null; + void runBackgroundNetworkTask(async () => { + if (finishedBootstrap) { + await fetchStatus(gitDirectory, git, { silent: true }); + } else { + await ensureStatus(gitDirectory, git); + } + }); + }, [gitDirectory, git, ensureStatus, fetchStatus, showRepository, worktreeCreationPending]); // Own the live invalidation for the repository readout. The desktop // composer's changed-files row no longer renders, so this panel must not @@ -175,6 +198,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, // event is reset to an empty array too, and carries real content only on // revert. Git status is the one authoritative, already-cached answer. const changed = React.useMemo(() => { + if (worktreeCreationPending) return null; const files = gitStatus?.files ?? []; if (files.length === 0) return null; const stats = gitStatus?.diffStats; @@ -187,7 +211,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, } } return { files: files.length, additions, deletions, hasStats: Boolean(stats) }; - }, [gitStatus?.files, gitStatus?.diffStats]); + }, [gitStatus?.files, gitStatus?.diffStats, worktreeCreationPending]); const attentionReason = gitStatus?.attentionReason ?? (gitStatus?.rebaseInProgress ? 'rebase' : null) diff --git a/packages/ui/src/hooks/useWorktreeBootstrapPending.ts b/packages/ui/src/hooks/useWorktreeBootstrapPending.ts new file mode 100644 index 00000000..1108d84d --- /dev/null +++ b/packages/ui/src/hooks/useWorktreeBootstrapPending.ts @@ -0,0 +1,18 @@ +import React from 'react'; + +import { getWorktreeBootstrapState, subscribeWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap'; + +/** + * Whether `directory` is a worktree whose creation has not finished yet: the + * directory exists, but setup commands and the initial git reset are still + * running. Until that completes the working tree transiently looks dirty, so + * surfaces that report uncommitted changes consult this and show nothing + * instead of presenting bootstrap noise as real changes on the branch. + */ +export const useWorktreeBootstrapPending = (directory: string | null): boolean => { + const getSnapshot = React.useCallback( + () => (directory ? getWorktreeBootstrapState(directory)?.status === 'pending' : false), + [directory], + ); + return React.useSyncExternalStore(subscribeWorktreeBootstrapState, getSnapshot, getSnapshot); +}; diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts index 89204a80..d94eb417 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts @@ -51,6 +51,7 @@ const { markWorktreeBootstrapPending, setWorktreeBootstrapState, startWorktreeBootstrapWatcher, + subscribeWorktreeBootstrapState, waitForWorktreeBootstrap, waitForWorktreeGitReady, } = await import('./worktreeBootstrap'); @@ -246,3 +247,39 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => { clearWorktreeBootstrapState('/repo-wt'); }); }); + +describe('worktreeBootstrap subscription', () => { + beforeEach(() => { + clearWorktreeBootstrapState('/repo-wt'); + }); + + test('notifies subscribers as a directory enters and leaves bootstrap', () => { + const pendingSnapshots: boolean[] = []; + const unsubscribe = subscribeWorktreeBootstrapState(() => { + pendingSnapshots.push(getWorktreeBootstrapState('/repo-wt')?.status === 'pending'); + }); + + try { + markWorktreeBootstrapPending('/repo-wt'); + setWorktreeBootstrapState('/repo-wt', { status: 'ready', error: null, updatedAt: 2 }); + clearWorktreeBootstrapState('/repo-wt'); + } finally { + unsubscribe(); + } + + expect(pendingSnapshots).toEqual([true, false, false]); + }); + + test('stops notifying after unsubscribe', () => { + let notifications = 0; + const unsubscribe = subscribeWorktreeBootstrapState(() => { + notifications += 1; + }); + + markWorktreeBootstrapPending('/repo-wt'); + unsubscribe(); + clearWorktreeBootstrapState('/repo-wt'); + + expect(notifications).toBe(1); + }); +}); diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts index 9b047a70..52b649ef 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts @@ -24,6 +24,23 @@ const watchers = new Map normalizePath(directory); const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`; +// UI surfaces subscribe to know when a directory enters or leaves bootstrap, +// so a half-created worktree's transient files are never shown as changes. +const bootstrapListeners = new Set<() => void>(); + +const notifyBootstrapListeners = (): void => { + for (const listener of bootstrapListeners) { + listener(); + } +}; + +export const subscribeWorktreeBootstrapState = (listener: () => void): (() => void) => { + bootstrapListeners.add(listener); + return () => { + bootstrapListeners.delete(listener); + }; +}; + const startLifecycle = (key: string): void => { const watcher = watchers.get(key); if (watcher) { @@ -72,6 +89,7 @@ const storePolledState = ( } state.set(key, next); + notifyBootstrapListeners(); return next; }; @@ -98,6 +116,7 @@ export const markWorktreeBootstrapPending = (directory: string): void => { error: null, updatedAt: Date.now(), }); + notifyBootstrapListeners(); }; export const clearWorktreeBootstrapState = (directory: string): void => { @@ -108,6 +127,7 @@ export const clearWorktreeBootstrapState = (directory: string): void => { startLifecycle(key); state.delete(key); lifecycleVersions.delete(key); + notifyBootstrapListeners(); }; export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => { @@ -117,6 +137,7 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots } startLifecycle(key); state.set(key, next); + notifyBootstrapListeners(); }; export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {