feat: move sessions to new worktrees
Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API. Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely. Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks. Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators. Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
|
||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- folder cleanup sync
|
||||
|
||||
@@ -42,6 +42,7 @@ import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
||||
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
|
||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
@@ -346,6 +347,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const collectNodeDescendantSessions = React.useCallback((root: SessionNode): Session[] => {
|
||||
const out: Session[] = [];
|
||||
const walk = (current: SessionNode) => {
|
||||
current.children.forEach((child) => {
|
||||
out.push(child.session);
|
||||
walk(child);
|
||||
});
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
||||
const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true);
|
||||
|
||||
@@ -354,6 +367,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
@@ -578,7 +592,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const statusType = sessionStatus?.type ?? 'idle';
|
||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||
const pendingPermissionCount = sessionPermissions.length;
|
||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
? (
|
||||
@@ -595,8 +609,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
title={t('sessions.sidebar.session.status.unread')}
|
||||
/>
|
||||
);
|
||||
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (showStatusMarker || isPinnedSession);
|
||||
const showPinnedMarker = isPinnedSession && !showStatusMarker;
|
||||
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
|
||||
const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
|
||||
const pinnedMarkerContent = (
|
||||
<Icon
|
||||
name="pushpin"
|
||||
@@ -604,7 +618,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
aria-label={t('sessions.sidebar.session.status.pinned')}
|
||||
/>
|
||||
);
|
||||
const leadingIndicators = showStatusMarker || showPinnedMarker ? (
|
||||
const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? (
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity',
|
||||
@@ -612,11 +626,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||||
)}
|
||||
>
|
||||
{showStatusMarker ? statusMarkerContent : null}
|
||||
{showPinnedMarker ? pinnedMarkerContent : null}
|
||||
{isMovingToWorktree ? (
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className="h-3 w-3 animate-spin text-primary"
|
||||
aria-label={t('sessions.sidebar.session.status.movingToWorktree')}
|
||||
/>
|
||||
) : showStatusMarker ? statusMarkerContent : showPinnedMarker ? pinnedMarkerContent : null}
|
||||
</span>
|
||||
) : null;
|
||||
const hideChevronUntilHover = hasChildren && !alwaysShowActions && (showStatusMarker || isPinnedSession);
|
||||
const hideChevronUntilHover = hasChildren && !alwaysShowActions && (isMovingToWorktree || showStatusMarker || isPinnedSession);
|
||||
const subsessionChevron = hasChildren ? (
|
||||
<span
|
||||
role="button"
|
||||
@@ -839,6 +858,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="download" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||||
</Item>
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block">
|
||||
<Item
|
||||
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
|
||||
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="w-full [&>svg]:mr-1"
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktree')}
|
||||
</Item>
|
||||
</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.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isMultiRunLikeSession ? (
|
||||
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
|
||||
<FusionIcon className="mr-1 h-4 w-4" />
|
||||
|
||||
Reference in New Issue
Block a user