fix(ui): reconcile git state after worktree changes

This commit is contained in:
Iuliia Ivashko
2026-09-04 16:41:39 +03:00
parent bde6fed8a2
commit 94c90a16b6
20 changed files with 438 additions and 165 deletions
@@ -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;
};