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:
@@ -112,6 +112,7 @@ export const iconSpriteData = {
|
||||
"folder-open-fill": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H4V18.996L6 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3Z" fill="currentColor"/>`,
|
||||
"folder-open": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H19V7H11.5858L9.58579 5H4V16.998L5.5 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3ZM19.9384 13H7.06155L5.56155 19H18.4384L19.9384 13Z" fill="currentColor"/>`,
|
||||
"folder-received": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM20 17H23V19H20V22.5L15 18L20 13.5V17Z" fill="currentColor"/>`,
|
||||
"folder-shared": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM18 17V13.5L23 18L18 22.5V19H15V17H18Z" fill="currentColor"/>`,
|
||||
"folders": `<path d="M6 7V4C6 3.44772 6.44772 3 7 3H13.4142L15.4142 5H21C21.5523 5 22 5.44772 22 6V16C22 16.5523 21.5523 17 21 17H18V20C18 20.5523 17.5523 21 17 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H6ZM6 9H4V19H16V17H6V9ZM8 5V15H20V7H14.5858L12.5858 5H8Z" fill="currentColor"/>`,
|
||||
"fullscreen-exit": `<path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z" fill="currentColor"/>`,
|
||||
"fullscreen": `<path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -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