fix(ui): reconcile git state after worktree changes
This commit is contained in:
@@ -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<string | null>(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);
|
||||
}
|
||||
|
||||
@@ -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<ToolPartProps> = ({
|
||||
|
||||
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<boolean>(!isFinalized);
|
||||
const previousPartIdRef = React.useRef<string | undefined>(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<ToolPartProps> = ({
|
||||
}
|
||||
}, [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<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -200,29 +200,6 @@ export const getPrimaryToolPath = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getMutatedToolPaths = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string[] => {
|
||||
if (toolName === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
const paths = new Set<string>();
|
||||
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'
|
||||
);
|
||||
|
||||
@@ -90,12 +90,17 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ 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;
|
||||
};
|
||||
|
||||
@@ -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<GitViewProps> = ({ 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<GitViewProps> = ({ isActive }) => {
|
||||
});
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
|
||||
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
|
||||
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
|
||||
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
|
||||
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
|
||||
@@ -438,11 +444,13 @@ export const GitView: React.FC<GitViewProps> = ({ 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<GitViewProps> = ({ 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<GitViewProps> = ({ isActive }) => {
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setWorktreeBootstrapStatus(null);
|
||||
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -475,37 +483,84 @@ export const GitView: React.FC<GitViewProps> = ({ 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<GitViewProps> = ({ isActive }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldHideGitState) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
{!postBootstrapRefreshFailed ? (
|
||||
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
<p className="typography-ui-label font-semibold text-foreground">
|
||||
{postBootstrapRefreshFailed
|
||||
? t('gitView.toast.refreshRepositoryFailed')
|
||||
: t('gitView.empty.worktreeSetupInProgress')}
|
||||
</p>
|
||||
{!postBootstrapRefreshFailed ? (
|
||||
<p className="typography-meta mt-1 text-muted-foreground">
|
||||
{t('gitView.empty.worktreeSetupDescription')}
|
||||
</p>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
if (!normalizedCurrentBootstrapDirectory) return;
|
||||
setPostBootstrapRefresh({
|
||||
directory: normalizedCurrentBootstrapDirectory,
|
||||
status: 'refreshing',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('gitView.empty.retryDiscovery')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGitRepo === null || (isGitRepo === true && !status)) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -2348,20 +2439,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
if (shouldHideNotGitState) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
|
||||
<p className="typography-ui-label font-semibold text-foreground">
|
||||
{t('gitView.empty.worktreeSetupInProgress')}
|
||||
</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">
|
||||
{t('gitView.empty.worktreeSetupDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nested repository discovery states (discovering, failed, unsupported,
|
||||
// none found, or settling on the auto-selected repository).
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user