fix(sessions): preflight every worktree move

This commit is contained in:
mattv8
2026-08-27 18:43:37 -06:00
parent 8fc08853b3
commit 26d6d72551
5 changed files with 45 additions and 80 deletions
+9 -4
View File
@@ -71,7 +71,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; import { requestSessionTreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
@@ -1059,12 +1059,17 @@ export const Header: React.FC = () => {
} }
} }
startSessionTreeWorktreeMove({ requestSessionTreeMove({
kind: 'quick',
root, root,
descendants, descendants,
sourceDirectory: sessionDirectory, sourceDirectory: sessionDirectory,
successMessage: t('sessions.sidebar.session.moveToWorktree.success'), messages: {
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), success: t('sessions.sidebar.session.moveToWorktree.success'),
failure: t('sessions.sidebar.session.moveToWorktree.failed'),
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
},
}); });
}, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]); }, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]);
@@ -11,6 +11,7 @@ import { HelpDialog } from '../ui/HelpDialog';
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog'; import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs'; import { SessionDialogs } from '@/components/session/SessionDialogs';
import { SessionWorktreeMoveConfirmDialog } from '@/components/session/sidebar/SessionWorktreeMoveConfirmDialog';
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog'; import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
import { ArchiveView } from '@/components/views/ArchiveView'; import { ArchiveView } from '@/components/views/ArchiveView';
import { WorktreesView } from '@/components/views/WorktreesView'; import { WorktreesView } from '@/components/views/WorktreesView';
@@ -19,6 +20,11 @@ import { MultiRunLauncher } from '@/components/multirun';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import {
cancelSessionTreeMove,
confirmSessionTreeMove,
useSessionTreeMoveConfirmation,
} from '@/lib/worktrees/sessionWorktreeMove';
import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useUpdatePolling } from '@/hooks/useUpdatePolling';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -80,6 +86,8 @@ export const MainLayout: React.FC = () => {
useUpdatePolling(); useUpdatePolling();
const sessionTreeMoveConfirmation = useSessionTreeMoveConfirmation();
React.useEffect(() => { React.useEffect(() => {
const previous = useUIStore.getState().isMobile; const previous = useUIStore.getState().isMobile;
if (previous !== isMobile) { if (previous !== isMobile) {
@@ -97,6 +105,12 @@ export const MainLayout: React.FC = () => {
<HelpDialog /> <HelpDialog />
<OpenCodeStatusDialog /> <OpenCodeStatusDialog />
<SessionDialogs /> <SessionDialogs />
<SessionWorktreeMoveConfirmDialog
value={sessionTreeMoveConfirmation}
onMoveSessionOnly={() => confirmSessionTreeMove(false)}
onMoveAllChanges={() => confirmSessionTreeMove(true)}
onCancel={cancelSessionTreeMove}
/>
{/* Persistent top-left controls (toggle + project actions) that {/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */} stay put while the sidebar/header animate beneath them. */}
@@ -48,8 +48,7 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
import { FusionIcon } from '@/components/icons/FusionIcon'; import { FusionIcon } from '@/components/icons/FusionIcon';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { import {
startSessionTreeExistingWorktreeMove, requestSessionTreeMove,
startSessionTreeWorktreeMove,
useIsSessionWorktreeMovePending, useIsSessionWorktreeMovePending,
} from '@/lib/worktrees/sessionWorktreeMove'; } from '@/lib/worktrees/sessionWorktreeMove';
import { streamPerfCount } from '@/stores/utils/streamDebug'; import { streamPerfCount } from '@/stores/utils/streamDebug';
@@ -1042,13 +1041,18 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
if (isDisabled || !sessionDirectory) { if (isDisabled || !sessionDirectory) {
return; return;
} }
startSessionTreeExistingWorktreeMove({ requestSessionTreeMove({
kind: 'existing',
root: resolvedSession, root: resolvedSession,
descendants: collectNodeDescendantSessions(node), descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory, sourceDirectory: sessionDirectory,
destination: target.metadata, destination: target.metadata,
successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), messages: {
failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'), success: t('sessions.sidebar.session.moveToWorktree.existingSuccess'),
failure: t('sessions.sidebar.session.moveToWorktree.existingFailed'),
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
},
}); });
}} }}
> >
@@ -1077,12 +1081,17 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
data-session-worktree-new-action="true" data-session-worktree-new-action="true"
onClick={() => { onClick={() => {
if (isWorktreeMenuDisabled || !sessionDirectory) return; if (isWorktreeMenuDisabled || !sessionDirectory) return;
startSessionTreeWorktreeMove({ requestSessionTreeMove({
kind: 'quick',
root: resolvedSession, root: resolvedSession,
descendants: collectNodeDescendantSessions(node), descendants: collectNodeDescendantSessions(node),
sourceDirectory: sessionDirectory, sourceDirectory: sessionDirectory,
successMessage: t('sessions.sidebar.session.moveToWorktree.success'), messages: {
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), success: t('sessions.sidebar.session.moveToWorktree.success'),
failure: t('sessions.sidebar.session.moveToWorktree.failed'),
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
},
}); });
}} }}
className="[&>svg]:mr-1" className="[&>svg]:mr-1"
@@ -204,7 +204,6 @@ const {
cancelSessionTreeMove, cancelSessionTreeMove,
useSessionTreeMoveConfirmation, useSessionTreeMoveConfirmation,
getSessionTreeMoveConfirmation, getSessionTreeMoveConfirmation,
startSessionTreeWorktreeMove,
} = await import('./sessionWorktreeMove'); } = await import('./sessionWorktreeMove');
const makeSession = (id: string, directory = '/source'): Session => ({ const makeSession = (id: string, directory = '/source'): Session => ({
@@ -659,16 +658,10 @@ describe('moveSessionTreeToExistingWorktree', () => {
throw new Error('git-ready failed'); throw new Error('git-ready failed');
}; };
startSessionTreeWorktreeMove({ requestSessionTreeMove(makeQuickIntent());
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
successMessage: 'success',
failureMessage: 'failed',
});
await waitFor(() => toastErrors.length === 1); await waitFor(() => toastErrors.length === 1);
expect(toastErrors).toEqual([{ title: 'failed', description: 'git-ready failed' }]); expect(toastErrors).toEqual([{ title: 'move failed', description: 'git-ready failed' }]);
expect(removeWorktreeCalls).toEqual([{ expect(removeWorktreeCalls).toEqual([{
projectDirectory: '/repo', projectDirectory: '/repo',
directory: '/created-worktree', directory: '/created-worktree',
@@ -683,13 +676,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
setStatuses('/source', { root: 'busy' }); setStatuses('/source', { root: 'busy' });
}; };
startSessionTreeWorktreeMove({ requestSessionTreeMove(makeQuickIntent());
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
successMessage: 'success',
failureMessage: 'failed',
});
await waitFor(() => toastErrors.length === 1); await waitFor(() => toastErrors.length === 1);
expect(removeWorktreeCalls).toEqual([{ expect(removeWorktreeCalls).toEqual([{
@@ -1020,16 +1007,10 @@ describe('moveSessionTreeToExistingWorktree', () => {
setStatuses('/source', { root: 'idle' }); setStatuses('/source', { root: 'idle' });
resolveProjectRefImplementation = () => null; resolveProjectRefImplementation = () => null;
startSessionTreeWorktreeMove({ requestSessionTreeMove(makeQuickIntent());
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
successMessage: 'success',
failureMessage: 'failed',
});
await waitFor(() => toastErrors.length === 1); await waitFor(() => toastErrors.length === 1);
expect(toastErrors).toEqual([{ title: 'failed', description: 'Unable to find the project for this session' }]); expect(toastErrors).toEqual([{ title: 'move failed', description: 'Unable to find the project for this session' }]);
expect(removeWorktreeCalls).toEqual([]); expect(removeWorktreeCalls).toEqual([]);
expect(moveCalls).toEqual([]); expect(moveCalls).toEqual([]);
}); });
@@ -405,47 +405,3 @@ export const requestSessionTreeMove = (intent: SessionTreeMoveIntent): void => {
} }
})(); })();
}; };
export const startSessionTreeExistingWorktreeMove = (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
destination: WorktreeMetadata;
successMessage: string;
failureMessage: string;
}): void => {
requestSessionTreeMove({
kind: 'existing',
root: input.root,
descendants: input.descendants,
sourceDirectory: input.sourceDirectory,
destination: input.destination,
messages: {
success: input.successMessage,
failure: input.failureMessage,
sourceVerificationFailed: input.failureMessage,
applyChangesFailed: input.failureMessage,
},
});
};
export const startSessionTreeWorktreeMove = (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
successMessage: string;
failureMessage: string;
}): void => {
requestSessionTreeMove({
kind: 'quick',
root: input.root,
descendants: input.descendants,
sourceDirectory: input.sourceDirectory,
messages: {
success: input.successMessage,
failure: input.failureMessage,
sourceVerificationFailed: input.failureMessage,
applyChangesFailed: input.failureMessage,
},
});
};