From 26d6d7255184bb854711eae2a14774e022a417bc Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 20 Aug 2026 13:52:36 -0600 Subject: [PATCH] fix(sessions): preflight every worktree move --- packages/ui/src/components/layout/Header.tsx | 13 ++++-- .../ui/src/components/layout/MainLayout.tsx | 14 ++++++ .../sidebar/sessions/SessionNodeItem.tsx | 25 +++++++---- .../lib/worktrees/sessionWorktreeMove.test.ts | 29 +++--------- .../src/lib/worktrees/sessionWorktreeMove.ts | 44 ------------------- 5 files changed, 45 insertions(+), 80 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 2d48185d..93eeb41a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -71,7 +71,7 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; 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'; @@ -1059,12 +1059,17 @@ export const Header: React.FC = () => { } } - startSessionTreeWorktreeMove({ + requestSessionTreeMove({ + kind: 'quick', root, descendants, sourceDirectory: sessionDirectory, - successMessage: t('sessions.sidebar.session.moveToWorktree.success'), - failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), + messages: { + 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]); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 7e392460..847c6894 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -11,6 +11,7 @@ import { HelpDialog } from '../ui/HelpDialog'; import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog'; import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; +import { SessionWorktreeMoveConfirmDialog } from '@/components/session/sidebar/SessionWorktreeMoveConfirmDialog'; import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog'; import { ArchiveView } from '@/components/views/ArchiveView'; import { WorktreesView } from '@/components/views/WorktreesView'; @@ -19,6 +20,11 @@ import { MultiRunLauncher } from '@/components/multirun'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { + cancelSessionTreeMove, + confirmSessionTreeMove, + useSessionTreeMoveConfirmation, +} from '@/lib/worktrees/sessionWorktreeMove'; import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; @@ -80,6 +86,8 @@ export const MainLayout: React.FC = () => { useUpdatePolling(); + const sessionTreeMoveConfirmation = useSessionTreeMoveConfirmation(); + React.useEffect(() => { const previous = useUIStore.getState().isMobile; if (previous !== isMobile) { @@ -97,6 +105,12 @@ export const MainLayout: React.FC = () => { + confirmSessionTreeMove(false)} + onMoveAllChanges={() => confirmSessionTreeMove(true)} + onCancel={cancelSessionTreeMove} + /> {/* Persistent top-left controls (toggle + project actions) that stay put while the sidebar/header animate beneath them. */} diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 5cb49006..08b548dc 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -48,8 +48,7 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { - startSessionTreeExistingWorktreeMove, - startSessionTreeWorktreeMove, + requestSessionTreeMove, useIsSessionWorktreeMovePending, } from '@/lib/worktrees/sessionWorktreeMove'; import { streamPerfCount } from '@/stores/utils/streamDebug'; @@ -1042,13 +1041,18 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode if (isDisabled || !sessionDirectory) { return; } - startSessionTreeExistingWorktreeMove({ + requestSessionTreeMove({ + kind: 'existing', root: resolvedSession, descendants: collectNodeDescendantSessions(node), sourceDirectory: sessionDirectory, destination: target.metadata, - successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), - failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'), + messages: { + 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" onClick={() => { if (isWorktreeMenuDisabled || !sessionDirectory) return; - startSessionTreeWorktreeMove({ + requestSessionTreeMove({ + kind: 'quick', root: resolvedSession, descendants: collectNodeDescendantSessions(node), sourceDirectory: sessionDirectory, - successMessage: t('sessions.sidebar.session.moveToWorktree.success'), - failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), + messages: { + 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" diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 3e61b4ec..125a5c93 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -204,7 +204,6 @@ const { cancelSessionTreeMove, useSessionTreeMoveConfirmation, getSessionTreeMoveConfirmation, - startSessionTreeWorktreeMove, } = await import('./sessionWorktreeMove'); const makeSession = (id: string, directory = '/source'): Session => ({ @@ -659,16 +658,10 @@ describe('moveSessionTreeToExistingWorktree', () => { throw new Error('git-ready failed'); }; - startSessionTreeWorktreeMove({ - root: makeSession('root'), - descendants: [], - sourceDirectory: '/source', - successMessage: 'success', - failureMessage: 'failed', - }); + requestSessionTreeMove(makeQuickIntent()); 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([{ projectDirectory: '/repo', directory: '/created-worktree', @@ -683,13 +676,7 @@ describe('moveSessionTreeToExistingWorktree', () => { setStatuses('/source', { root: 'busy' }); }; - startSessionTreeWorktreeMove({ - root: makeSession('root'), - descendants: [], - sourceDirectory: '/source', - successMessage: 'success', - failureMessage: 'failed', - }); + requestSessionTreeMove(makeQuickIntent()); await waitFor(() => toastErrors.length === 1); expect(removeWorktreeCalls).toEqual([{ @@ -1020,16 +1007,10 @@ describe('moveSessionTreeToExistingWorktree', () => { setStatuses('/source', { root: 'idle' }); resolveProjectRefImplementation = () => null; - startSessionTreeWorktreeMove({ - root: makeSession('root'), - descendants: [], - sourceDirectory: '/source', - successMessage: 'success', - failureMessage: 'failed', - }); + requestSessionTreeMove(makeQuickIntent()); 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(moveCalls).toEqual([]); }); diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 57f773ca..5ea8b8ed 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -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, - }, - }); -};