fix(sessions): harden worktree move recovery

This commit is contained in:
mattv8
2026-08-27 18:43:37 -06:00
parent 415f443a79
commit 52bea6d1f3
10 changed files with 478 additions and 133 deletions
@@ -564,6 +564,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
: (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null);
return startSessionWorktreeMenuLoad(args, {
projects,
getCurrentProjects: () => useProjectsStore.getState().projects,
rawWorktreesByProjectRef,
getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject,
resolveProject: (directory) => resolveProjectRef(directory),
@@ -197,6 +197,10 @@ describe('startSessionWorktreeMenuLoad', () => {
{ id: 'linked', path: '/repo-linked' },
{ id: 'other', path: '/repo-other' },
],
getCurrentProjects: () => [
{ id: 'linked', path: '/repo-linked' },
{ id: 'other', path: '/repo-other' },
],
rawWorktreesByProjectRef: rawRef,
getPublishedWorktreesByProject: () => new Map(),
resolveProject: () => null,
@@ -259,6 +263,7 @@ describe('startSessionWorktreeMenuLoad', () => {
},
{
projects: [{ id: 'linked', path: '/repo-linked' }],
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
rawWorktreesByProjectRef: rawRef,
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
resolveProject: () => null,
@@ -303,6 +308,7 @@ describe('startSessionWorktreeMenuLoad', () => {
},
{
projects: [{ id: 'linked', path: '/repo-linked' }],
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
rawWorktreesByProjectRef: rawRef,
getPublishedWorktreesByProject: () => publishedTopology,
resolveProject: () => null,
@@ -352,6 +358,10 @@ describe('startSessionWorktreeMenuLoad', () => {
{ id: 'owner', path: '/repo' },
{ id: 'linked', path: '/repo-linked' },
],
getCurrentProjects: () => [
{ id: 'owner', path: '/repo' },
{ id: 'linked', path: '/repo-linked' },
],
rawWorktreesByProjectRef: rawRef,
getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]),
resolveProject: () => null,
@@ -398,6 +408,7 @@ describe('startSessionWorktreeMenuLoad', () => {
},
{
projects: [{ id: 'linked', path: '/repo-linked' }],
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
rawWorktreesByProjectRef: rawRef,
getPublishedWorktreesByProject: () => publishedCurrentRuntime,
resolveProject: () => null,
@@ -433,6 +444,53 @@ describe('startSessionWorktreeMenuLoad', () => {
expect(published).toEqual([]);
});
test('rejects a deferred refresh when the owning project is removed before commit', async () => {
const refreshDeferred = createDeferred<WorktreeMetadata[]>();
const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' });
const published: Array<{ availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
const rawRef = rawScope('runtime-1', [
['/repo-linked', [existing]],
]);
let currentProjects = [{ id: 'linked', path: '/repo-linked' }];
const load = startSessionWorktreeMenuLoad(
{
projectId: 'linked',
sourceDirectory: '/repo-current',
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
},
{
projects: currentProjects,
rawWorktreesByProjectRef: rawRef,
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
resolveProject: () => null,
listProjectWorktrees: async () => refreshDeferred.promise,
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
worktreeMapsEqual: () => false,
recordWorktreesSeen: () => {},
publishTopology: (next) => {
published.push({ availableWorktreesByProject: next.availableWorktreesByProject });
},
getCurrentProjects: () => currentProjects,
getRuntimeKey: () => 'runtime-1',
now: () => 123,
projectRootBranch: null,
},
);
currentProjects = [];
refreshDeferred.resolve([
worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }),
]);
const refreshError = await load.refreshTargets.catch((error) => error);
expect(refreshError).toBeInstanceOf(Error);
expect(refreshError.message).toBe('Project removed during worktree refresh');
expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]);
expect(published).toEqual([]);
});
test('falls back to resolving the owning configured project from the source directory when projectId is missing', async () => {
const calls: string[] = [];
const load = startSessionWorktreeMenuLoad(
@@ -443,6 +501,7 @@ describe('startSessionWorktreeMenuLoad', () => {
},
{
projects: [{ id: 'owner', path: '/repo' }],
getCurrentProjects: () => [{ id: 'owner', path: '/repo' }],
rawWorktreesByProjectRef: rawScope('runtime-1', []),
getPublishedWorktreesByProject: () => new Map(),
resolveProject: (directory) => {
@@ -26,6 +26,7 @@ type SessionWorktreeMenuState = {
type StartSessionWorktreeMenuLoadDependencies = {
projects: ReadonlyArray<ProjectRef>;
getCurrentProjects: () => ReadonlyArray<ProjectRef>;
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
getPublishedWorktreesByProject: () => Map<string, WorktreeMetadata[]>;
resolveProject: (directory: string) => ProjectRef | null;
@@ -285,6 +286,12 @@ export const startSessionWorktreeMenuLoad = (
throw new Error('Runtime changed during worktree refresh');
}
const currentProjects = deps.getCurrentProjects();
const currentProject = currentProjects.find((candidate) => candidate.id === project.id) ?? null;
if (!currentProject || normalizePath(currentProject.path ?? null) !== normalizedProjectPath) {
throw new Error('Project removed during worktree refresh');
}
const currentRawScope = ensureRawWorktreesByProjectScope({
rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef,
publishedWorktreesByProject: deps.getPublishedWorktreesByProject(),
@@ -327,7 +334,7 @@ export const startSessionWorktreeMenuLoad = (
worktreesByProject: nextRawTopology,
};
const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(deps.projects, nextRawTopology);
const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(currentProjects, nextRawTopology);
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now());
@@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import { getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import type { SessionNode } from '../types';
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -993,102 +993,106 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
{t('sessions.sidebar.session.menu.exportMarkdown')}
</Item>
{!isSubtaskSession && !archivedBucket && !isVSCode ? (() => {
const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({
sessionDirectory,
isStreaming,
isMovingToWorktree,
});
const worktreeMenuState = getSessionWorktreeMenuState({
targets: worktreeTargets,
isRefreshing: worktreeTargetsLoading,
loadFailed: worktreeTargetsLoadFailed,
});
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<Sub onOpenChange={handleWorktreeSubmenuOpenChange}>
<SubTrigger
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
className="w-full [&>svg]:mr-1"
data-session-worktree-submenu-trigger={session.id}
>
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.moveToWorktreeTargets')}
</SubTrigger>
<SubContent className="min-w-[220px]" data-session-worktree-submenu={session.id}>
{worktreeTargets.map((target) => {
const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path;
const itemLabel = target.isPrimary
? t('sessions.sidebar.session.moveToWorktree.main')
: (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path);
const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready';
<Sub onOpenChange={handleWorktreeSubmenuOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<SubTrigger
disabled={isWorktreeMenuDisabled}
className="w-full [&>svg]:mr-1"
data-session-worktree-submenu-trigger={session.id}
>
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.moveToWorktreeTargets')}
</SubTrigger>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isStreaming
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltipTargets')}
</TooltipContent>
</Tooltip>
<SubContent className="min-w-[220px]" data-session-worktree-submenu={session.id}>
{worktreeTargets.map((target) => {
const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path;
const itemLabel = target.isPrimary
? t('sessions.sidebar.session.moveToWorktree.main')
: (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path);
const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready';
return (
<Item
key={targetPath}
disabled={isDisabled}
title={target.metadata.path}
data-session-worktree-target={targetPath}
onClick={() => {
if (isDisabled || !sessionDirectory) {
return;
}
startSessionTreeExistingWorktreeMove({
root: resolvedSession,
descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory,
destination: target.metadata,
successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'),
failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'),
});
}}
>
<span className="flex min-w-0 flex-1 items-center gap-1 truncate">
<span className="truncate">{itemLabel}</span>
{target.isCurrent ? <span className="sr-only">{t('sessions.sidebar.session.moveToWorktree.current')}</span> : null}
</span>
{target.isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 flex-shrink-0 text-primary" aria-hidden="true" /> : null}
</Item>
);
})}
{worktreeMenuState.refreshState === 'loading' ? (
<Item disabled data-session-worktree-refresh-state="loading" className="py-0.5 text-muted-foreground typography-micro">
{t('sessions.sidebar.session.moveToWorktree.refreshing')}
</Item>
) : null}
{worktreeMenuState.refreshState === 'error' ? (
<Item disabled data-session-worktree-refresh-state="error" className="py-0.5 text-muted-foreground typography-micro">
{t('sessions.sidebar.session.moveToWorktree.loadFailed')}
</Item>
) : null}
<Separator />
{worktreeMenuState.showNewWorktreeAction ? (
<Item
data-session-worktree-new-action="true"
onClick={() => {
if (!sessionDirectory || isStreaming || isMovingToWorktree) return;
startSessionTreeWorktreeMove({
root: resolvedSession,
descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory,
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
});
}}
className="[&>svg]:mr-1"
>
<Icon name="add" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.newWorktree')}
</Item>
) : null}
</SubContent>
</Sub>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isStreaming
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltipTargets')}
</TooltipContent>
</Tooltip>
return (
<Item
key={targetPath}
disabled={isDisabled}
title={target.metadata.path}
data-session-worktree-target={targetPath}
onClick={() => {
if (isDisabled || !sessionDirectory) {
return;
}
startSessionTreeExistingWorktreeMove({
root: resolvedSession,
descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory,
destination: target.metadata,
successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'),
failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'),
});
}}
>
<span className="flex min-w-0 flex-1 items-center gap-1 truncate">
<span className="truncate">{itemLabel}</span>
{target.isCurrent ? <span className="sr-only">{t('sessions.sidebar.session.moveToWorktree.current')}</span> : null}
</span>
{target.isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 flex-shrink-0 text-primary" aria-hidden="true" /> : null}
</Item>
);
})}
{worktreeMenuState.refreshState === 'loading' ? (
<Item disabled data-session-worktree-refresh-state="loading" className="py-0.5 text-muted-foreground typography-micro">
{t('sessions.sidebar.session.moveToWorktree.refreshing')}
</Item>
) : null}
{worktreeMenuState.refreshState === 'error' ? (
<Item disabled data-session-worktree-refresh-state="error" className="py-0.5 text-muted-foreground typography-micro">
{t('sessions.sidebar.session.moveToWorktree.loadFailed')}
</Item>
) : null}
<Separator />
{worktreeMenuState.showNewWorktreeAction ? (
<Item
disabled={isWorktreeMenuDisabled}
data-session-worktree-new-action="true"
onClick={() => {
if (isWorktreeMenuDisabled || !sessionDirectory) return;
startSessionTreeWorktreeMove({
root: resolvedSession,
descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory,
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
});
}}
className="[&>svg]:mr-1"
>
<Icon name="add" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.newWorktree')}
</Item>
) : null}
</SubContent>
</Sub>
);
})() : null}
{isMultiRunLikeSession ? (
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import {
computeNodeStructureKey,
getSessionWorktreeMenuDisabled,
nodeHasPinnedMembershipChange,
selectFolderRootNodes,
selectQuestionBadgeSessionScopes,
} from './sessionNodeItemUtils';
import type { SessionNode } from '../types';
const session = (id: string, title: string): Session => ({
@@ -158,3 +164,31 @@ describe('selectFolderRootNodes', () => {
expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]);
});
});
describe('getSessionWorktreeMenuDisabled', () => {
test('shares the parent trigger disabled contract with the new worktree action', () => {
expect(getSessionWorktreeMenuDisabled({
sessionDirectory: '/repo-feature',
isStreaming: false,
isMovingToWorktree: false,
})).toBe(false);
expect(getSessionWorktreeMenuDisabled({
sessionDirectory: null,
isStreaming: false,
isMovingToWorktree: false,
})).toBe(true);
expect(getSessionWorktreeMenuDisabled({
sessionDirectory: '/repo-feature',
isStreaming: true,
isMovingToWorktree: false,
})).toBe(true);
expect(getSessionWorktreeMenuDisabled({
sessionDirectory: '/repo-feature',
isStreaming: false,
isMovingToWorktree: true,
})).toBe(true);
});
});
@@ -78,6 +78,16 @@ export type QuestionBadgeSessionScope = {
sessionIDs: string[];
};
export const getSessionWorktreeMenuDisabled = ({
sessionDirectory,
isStreaming,
isMovingToWorktree,
}: {
sessionDirectory: string | null;
isStreaming: boolean;
isMovingToWorktree: boolean;
}): boolean => !sessionDirectory || isStreaming || isMovingToWorktree;
/**
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
* badge should count. An expanded row counts only its own session; a collapsed