From fd8ec60a6a5318fbc7dd7fa4dea9b412d4566367 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 31 Jul 2026 21:24:17 +0300 Subject: [PATCH] feat: add move current session to worktree action Adds a header menu item to move the current root session to a worktree Collects descendant sessions so the full session tree moves together Disables the action while the session is busy or a move is already pending --- packages/ui/src/components/layout/Header.tsx | 57 +++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 2a8433ce..3633e6c6 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -21,7 +21,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract'; -import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useSessionMessagesResolved } from '@/sync/sync-context'; +import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; @@ -82,6 +82,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'; 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 MOBILE_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors'; @@ -715,6 +716,7 @@ type HeaderSessionSnapshot = { created: number | null; slug: string | null; shareUrl: string | null; + parentId: string | null; }; export const Header: React.FC = ({ @@ -744,6 +746,8 @@ export const Header: React.FC = ({ const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? ''); + const currentSessionStatus = useGlobalSessionStatus(currentSessionId ?? ''); + const isCurrentSessionMovingToWorktree = useIsSessionWorktreeMovePending(currentSessionId ?? ''); const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback( (state): HeaderSessionSnapshot | null => { if (!currentSessionId) return null; @@ -756,6 +760,7 @@ export const Header: React.FC = ({ created: session.time?.created ?? null, slug: record.slug ?? null, shareUrl: session.share?.url ?? null, + parentId: session.parentID ?? null, }; }, [currentSessionId], @@ -1422,6 +1427,33 @@ export const Header: React.FC = ({ toast.success(t('sessions.sidebar.session.export.success')); }, [currentSession?.title, currentSessionId, headerDirectoryStore, openDirectory, sync, t]); + const isCurrentSessionActive = currentSessionStatus?.type === 'busy' || currentSessionStatus?.type === 'retry'; + const moveCurrentSessionToWorktree = React.useCallback(() => { + if (!currentSessionId || !sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree) return; + const sessions = useGlobalSessionsStore.getState().activeSessions; + const root = sessions.find((session) => session.id === currentSessionId); + if (!root) return; + + const descendants: typeof sessions = []; + const pendingParentIds = [currentSessionId]; + for (let index = 0; index < pendingParentIds.length; index += 1) { + const parentId = pendingParentIds[index]; + for (const session of sessions) { + if (session.parentID !== parentId) continue; + descendants.push(session); + pendingParentIds.push(session.id); + } + } + + startSessionTreeWorktreeMove({ + root, + descendants, + sourceDirectory: sessionDirectory, + successMessage: t('sessions.sidebar.session.moveToWorktree.success'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), + }); + }, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]); + const confirmHeaderRetentionAction = React.useCallback(async () => { if (!currentSessionId || !pendingHeaderRetentionAction) return; const sessions = useGlobalSessionsStore.getState().activeSessions; @@ -2340,6 +2372,29 @@ export const Header: React.FC = ({ void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} )} void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} + {!isVSCode && currentSession && !currentSession.parentId ? ( + + + + + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isCurrentSessionMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isCurrentSessionActive + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')}