From 9babd19d9b1d0bfda570d8f7bbc50c991fd74a10 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Tue, 18 Aug 2026 20:51:27 -0600 Subject: [PATCH 01/14] feat(sessions): move sessions to existing worktrees --- CHANGELOG.md | 1 + .../src/components/session/SessionSidebar.tsx | 84 ++- .../session/sidebar/DOCUMENTATION.md | 26 + .../sidebar/list/SessionProjectCollection.tsx | 4 + .../SessionGroupSection.behavior.test.tsx | 4 + .../sidebar/projects/SessionGroupSection.tsx | 14 +- .../projects/SessionProjectScroller.tsx | 1 + .../sidebar/recent/RecentSessionSection.tsx | 2 + .../recent/SidebarActivitySections.tsx | 2 + .../sidebar/sessionWorktreeMenu.test.ts | 490 ++++++++++++++++++ .../session/sidebar/sessionWorktreeMenu.ts | 402 ++++++++++++++ .../sidebar/sessions/SessionNodeItem.tsx | 191 +++++-- .../SessionTreeItem.behavior.test.tsx | 7 + .../sidebar/sessions/SessionTreeItem.tsx | 24 +- packages/ui/src/lib/i18n/messages/de.ts | 9 + packages/ui/src/lib/i18n/messages/en.ts | 9 + packages/ui/src/lib/i18n/messages/es.ts | 9 + packages/ui/src/lib/i18n/messages/fr.ts | 9 + packages/ui/src/lib/i18n/messages/ja.ts | 9 + packages/ui/src/lib/i18n/messages/ko.ts | 9 + packages/ui/src/lib/i18n/messages/pl.ts | 9 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 9 + packages/ui/src/lib/i18n/messages/uk.ts | 9 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 9 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 9 + .../lib/worktrees/sessionWorktreeMove.test.ts | 485 +++++++++++++++++ .../src/lib/worktrees/sessionWorktreeMove.ts | 114 +++- .../src/lib/worktrees/worktreeManager.test.ts | 85 ++- .../ui/src/lib/worktrees/worktreeManager.ts | 30 +- 29 files changed, 1967 insertions(+), 98 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts create mode 100644 packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts create mode 100644 packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 720e1db6..8f3325cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ All notable changes to this project will be documented in this file. - Usage/Command Code: Command Code plan limits now appear in the Usage page and work status panel. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Git/Worktrees: creating a worktree from a pull request now falls back to GitHub's pull-request reference when the source fork was deleted or cannot be reached, instead of failing before creating the worktree (thanks to @makeittech). +- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech). - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 9a7361bd..f3fc8c15 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -40,6 +40,14 @@ import { runBackgroundNetworkTask } from '@/lib/background-network'; import { buildKnownSessionDirectories } from './sidebar/list/sessionListDirectories'; import { z } from 'zod'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; +import { + commitDiscoveredRawWorktreesByProject, + ensureRawWorktreesByProjectScope, + startSessionWorktreeMenuLoad, + type RawWorktreesByProjectScope, + type StartSessionWorktreeMenuLoadArgs, +} from './sidebar/sessionWorktreeMenu'; +import { resolveProjectRef } from '@/lib/worktreeSessionCreator'; const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const EMPTY_STRING_ARRAY: string[] = []; @@ -189,6 +197,11 @@ const SessionSidebarComponent: React.FC = ({ const [worktreeDiscoveryRevision, requestWorktreeDiscovery] = React.useReducer((revision) => revision + 1, 0); const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey; const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState>(new Set()); + const rawWorktreesByProjectRef = React.useRef({ + runtimeKey: null, + revision: 0, + worktreesByProject: new Map(), + }); React.useEffect(() => { let cancelled = false; @@ -198,14 +211,25 @@ const SessionSidebarComponent: React.FC = ({ const projectEntries = useProjectsStore.getState().projects; if (projectEntries.length === 0 || isVSCode) { if (!cancelled) { + rawWorktreesByProjectRef.current = { + runtimeKey: null, + revision: 0, + worktreesByProject: new Map(), + }; setUnresolvedWorktreeProjectPaths(new Set()); setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey); } return; } - const knownWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; - const worktreesByProject = new Map(knownWorktreesByProject); + const knownPublishedWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; + const seededRawScope = ensureRawWorktreesByProjectScope({ + rawWorktreesByProjectRef, + publishedWorktreesByProject: knownPublishedWorktreesByProject, + runtimeKey: discoveryRuntimeKey, + }); + const capturedRawRevision = seededRawScope.revision; + const worktreesByProject = new Map(seededRawScope.worktreesByProject); const unresolvedProjectPaths = new Set(); // Constrain fanout: previously `Promise.all(projects.map(...))` could @@ -258,18 +282,26 @@ const SessionSidebarComponent: React.FC = ({ worktreesByProject.delete(projectPath); } } - const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projectEntries, worktreesByProject); - const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); - // Newly appearing worktrees sort to the top of their project's - // worktree list (see worktreeFirstSeen.ts). - recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), Date.now()); - - // Skip update if nothing changed — see worktreeMapsEqual JSDoc. - if (!worktreeMapsEqual(partitionedWorktreesByProject, knownWorktreesByProject)) { - useSessionUIStore.setState({ - availableWorktrees: allWorktrees, - availableWorktreesByProject: partitionedWorktreesByProject, - }); + const committed = commitDiscoveredRawWorktreesByProject({ + rawWorktreesByProjectRef, + runtimeKey: discoveryRuntimeKey, + capturedRevision: capturedRawRevision, + nextRawWorktreesByProject: worktreesByProject, + publishedWorktreesByProject: knownPublishedWorktreesByProject, + partitionWorktreesByRegisteredProject, + projects: projectEntries, + worktreeMapsEqual, + recordWorktreesSeen, + publishTopology: (next) => { + useSessionUIStore.setState(next); + }, + requestRediscovery: () => { + requestWorktreeDiscovery(); + }, + now: () => Date.now(), + }); + if (!committed) { + return; } setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths); setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey); @@ -367,7 +399,6 @@ const SessionSidebarComponent: React.FC = ({ }, []); - const normalizedProjects = React.useMemo(() => { return projects.flatMap((project) => { const normalizedPath = normalizePath(project.path); @@ -527,6 +558,28 @@ const SessionSidebarComponent: React.FC = ({ openMultiRunLauncher(); }, [mobileVariant, openMultiRunLauncher, setSessionSwitcherOpen]); + const handleSessionWorktreeMenuLoad = React.useCallback((args: StartSessionWorktreeMenuLoadArgs) => { + const resolvedProject = args.projectId + ? (projects.find((candidate) => candidate.id === args.projectId) ?? null) + : (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null); + return startSessionWorktreeMenuLoad(args, { + projects, + rawWorktreesByProjectRef, + getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject, + resolveProject: (directory) => resolveProjectRef(directory), + listProjectWorktrees, + partitionWorktreesByRegisteredProject, + worktreeMapsEqual, + recordWorktreesSeen, + publishTopology: (next) => { + useSessionUIStore.setState(next); + }, + getRuntimeKey, + now: () => Date.now(), + projectRootBranch: resolvedProject ? (projectRootBranches.get(resolvedProject.id) ?? null) : null, + }); + }, [projectRootBranches, projects]); + const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { useUIStore.getState().closeMainSurfaces(); if (mobileVariant) { @@ -637,6 +690,7 @@ const SessionSidebarComponent: React.FC = ({ openProjectEditDialog: setEditingProjectDialogId, removeProject, reorderProjects, + startSessionWorktreeMenuLoad: handleSessionWorktreeMenuLoad, initialActiveSessionByProject, persistActiveSessionByProject, projectViewActions: projectView.actions, diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 15a94964..0cc1692e 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -11,6 +11,11 @@ kept at this root in `types.ts` and `utils.tsx`. - `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators. - `recent/` owns Recent and managed Chats activity projections. - `folders/` owns folder DnD, bulk actions, archived folders, and folder UI. +- Root session right-click and overflow menus expose `Move to worktree`: a submenu + listing existing primary and linked worktree destinations, the current target + greyed and disabled, plus a `New worktree...` action. Moving to an existing or + new destination transfers the full idle subtree; only the root session carries + uncommitted changes. `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })` unconditionally. The hook publishes complete directory bootstrap demand, @@ -35,3 +40,24 @@ Directory demand always includes known project roots and worktrees. Visibility only changes priority. Row mounts must not start bootstrap work. Selection and activity subscriptions stay session-scoped so a structural list update does not make every row observe unrelated streaming updates. + +## Loading rules + +- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh. +- Current directory and selected-session directory are `selected` demand and therefore run first. +- Expanded projects/worktrees outrank merely visible and background groups. +- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects. +- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns. +- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index. +- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers. +- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract. +- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics. +- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling. +- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent. +- Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree. +- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions. +- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders. +- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave. +- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data. +- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action. +- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events. diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index 9ed3e620..4bd79c52 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -103,6 +103,7 @@ type SessionProjectCollectionProps = { openProjectEditDialog: (id: string) => void; removeProject: (id: string) => void; reorderProjects: (fromIndex: number, toIndex: number) => void; + startSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad']; renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode; initialActiveSessionByProject: Map; persistActiveSessionByProject: (value: Map) => void; @@ -330,6 +331,7 @@ const VisibleSessionProjects: React.FC = ({ topol setDeleteSessionConfirm, startFolderRename, setCopiedSessionId, + startSessionWorktreeMenuLoad: actions.startSessionWorktreeMenuLoad, folderRename, setFolderRenameDraft, clearFolderRename, @@ -350,6 +352,7 @@ const VisibleSessionProjects: React.FC = ({ topol deleteSessionConfirm, copiedSessionId, setCopiedSessionId, + actions.startSessionWorktreeMenuLoad, rowActions, toggleParent, view.hideDirectoryControls, @@ -457,6 +460,7 @@ const VisibleSessionProjects: React.FC = ({ topol setDeleteSessionConfirm={setDeleteSessionConfirm} startFolderRename={startFolderRename} setCopiedSessionId={setCopiedSessionId} + startSessionWorktreeMenuLoad={actions.startSessionWorktreeMenuLoad} chatSessions={collection.chatSessions} renderChatsSection={renderChatsSection} onNewChat={handleOpenNewChat} diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx index 7665a74b..83882dc1 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx @@ -138,6 +138,10 @@ const createProps = (): SessionGroupSectionProps => ({ deleteSessionConfirm: null, setDeleteSessionConfirm: () => undefined, setCopiedSessionId: () => undefined, + startSessionWorktreeMenuLoad: () => ({ + cachedTargets: [], + refreshTargets: Promise.resolve([]), + }), onToggleCollapsedGroup: () => undefined, folderRename: null, setFolderRenameDraft: () => undefined, diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx index 7a571e30..42a4b474 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx @@ -108,6 +108,7 @@ export type SessionGroupSectionProps = { | 'setDeleteSessionConfirm' | 'startFolderRename' | 'setCopiedSessionId' + | 'startSessionWorktreeMenuLoad' >; const CollapsedFolderActivity: React.FC<{ @@ -253,6 +254,7 @@ const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSe && prev.setDeleteSessionConfirm === next.setDeleteSessionConfirm && prev.startFolderRename === next.startFolderRename && prev.setCopiedSessionId === next.setCopiedSessionId + && prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad && prev.setFolderRenameDraft === next.setFolderRenameDraft && prev.clearFolderRename === next.clearFolderRename ); @@ -852,10 +854,11 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo setSessionSearchQuery={props.setSessionSearchQuery} setIsSessionSearchOpen={props.setIsSessionSearchOpen} deleteSessionConfirm={props.deleteSessionConfirm} - setDeleteSessionConfirm={props.setDeleteSessionConfirm} - startFolderRename={props.startFolderRename} - setCopiedSessionId={props.setCopiedSessionId} - />)} + setDeleteSessionConfirm={props.setDeleteSessionConfirm} + startFolderRename={props.startFolderRename} + setCopiedSessionId={props.setCopiedSessionId} + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} + />)} )} @@ -962,7 +965,8 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo setDeleteSessionConfirm={props.setDeleteSessionConfirm} startFolderRename={props.startFolderRename} setCopiedSessionId={props.setCopiedSessionId} - />; + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} + />; const body = ( & { pinnedSessionIds: Set; sessionOrderIndex: Map; diff --git a/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx b/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx index b3abac77..b17a9424 100644 --- a/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx +++ b/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx @@ -49,6 +49,7 @@ type Props = { | 'setDeleteSessionConfirm' | 'startFolderRename' | 'setCopiedSessionId' + | 'startSessionWorktreeMenuLoad' >; export const RecentSessionSection: React.FC = (props) => { @@ -162,6 +163,7 @@ export const RecentSessionSection: React.FC = (props) => { setDeleteSessionConfirm={props.setDeleteSessionConfirm} startFolderRename={props.startFolderRename} setCopiedSessionId={props.setCopiedSessionId} + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} /> ); }; diff --git a/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx index 1cd444d0..9e44e178 100644 --- a/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx @@ -63,6 +63,7 @@ type Props = { | 'setDeleteSessionConfirm' | 'startFolderRename' | 'setCopiedSessionId' + | 'startSessionWorktreeMenuLoad' >; type RenderExtras = SessionNodeRenderExtras; @@ -198,6 +199,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode { setDeleteSessionConfirm={props.setDeleteSessionConfirm} startFolderRename={props.startFolderRename} setCopiedSessionId={props.setCopiedSessionId} + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} /> ); diff --git a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts new file mode 100644 index 00000000..4b87fbea --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, test } from 'bun:test'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { + buildSessionWorktreeMenuTargets, + commitDiscoveredRawWorktreesByProject, + getSessionWorktreeMenuState, + markRawWorktreesByProjectMutation, + startSessionWorktreeMenuLoad, +} from './sessionWorktreeMenu'; + +const rawScope = (runtimeKey: string | null, entries: Array<[string, WorktreeMetadata[]]>) => ({ + current: { + runtimeKey, + revision: 0, + worktreesByProject: new Map(entries), + }, +}); + +const worktree = (overrides: Partial = {}): WorktreeMetadata => ({ + path: '/repo-feature', + projectDirectory: '/repo', + branch: 'feature', + label: 'feature', + name: 'feature', + worktreeStatus: 'ready', + worktreeSource: 'existing', + ...overrides, +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +describe('buildSessionWorktreeMenuTargets', () => { + test('adds the canonical main worktree, includes the current source, dedupes by path, and sorts linked targets', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo-linked', + discoveredWorktrees: [ + worktree({ path: '/repo-zebra', branch: 'zebra', label: 'zebra', name: 'zebra' }), + worktree({ path: '/repo-alpha', branch: 'alpha', label: 'alpha', name: 'alpha' }), + worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }), + worktree({ path: '/repo-alpha/', branch: 'alpha', label: 'alpha duplicate', name: 'alpha-duplicate' }), + ], + sourceDirectory: '/repo-current/', + currentWorktree: worktree({ + path: '/repo-current', + projectDirectory: '/repo', + branch: 'current', + label: 'Current branch', + }), + }); + + expect(targets.map((target) => ({ + path: target.metadata.path, + isPrimary: target.isPrimary, + isCurrent: target.isCurrent, + }))).toEqual([ + { path: '/repo', isPrimary: true, isCurrent: false }, + { path: '/repo-alpha', isPrimary: false, isCurrent: false }, + { path: '/repo-current', isPrimary: false, isCurrent: true }, + { path: '/repo-zebra', isPrimary: false, isCurrent: false }, + ]); + expect(targets[0]?.metadata.worktreeStatus).toBe('ready'); + expect(targets[0]?.metadata.worktreeSource).toBe('existing'); + }); + + test('prefers discovered primary metadata instead of synthetic fallback metadata', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo-linked', + discoveredWorktrees: [ + worktree({ + path: '/repo', + projectDirectory: '/repo', + branch: 'main', + label: 'main', + name: 'repo-primary', + headState: 'branch', + }), + ], + sourceDirectory: '/repo-linked', + currentWorktree: worktree({ + path: '/repo-linked', + projectDirectory: '/repo', + branch: 'feature', + label: 'feature', + }), + }); + + expect(targets[0]?.isPrimary).toBe(true); + expect(targets[0]?.metadata.path).toBe('/repo'); + expect(targets[0]?.metadata.branch).toBe('main'); + expect(targets[0]?.metadata.label).toBe('main'); + expect(targets[0]?.metadata.name).toBe('repo-primary'); + expect(targets[0]?.metadata.headState).toBe('branch'); + }); + + test('sorts linked targets by effective compact label when branch is missing', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo', + discoveredWorktrees: [ + worktree({ path: '/repo-zed', branch: '', label: '', name: 'zed' }), + worktree({ path: '/repo-alpha', branch: '', label: '', name: 'alpha' }), + worktree({ path: '/repo-beta', branch: 'beta', label: 'beta', name: 'beta' }), + ], + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', projectDirectory: '/repo', branch: '', label: '', name: 'current' }), + }); + + expect(targets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-alpha', + '/repo-beta', + '/repo-current', + '/repo-zed', + ]); + }); + + test('uses the owning project root branch for a synthetic primary when git omits the queried checkout', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo', + discoveredWorktrees: [ + worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + ], + sourceDirectory: '/repo-feature', + currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + projectRootBranch: 'main', + }); + + expect(targets[0]?.isPrimary).toBe(true); + expect(targets[0]?.metadata.path).toBe('/repo'); + expect(targets[0]?.metadata.branch).toBe('main'); + expect(targets[0]?.metadata.label).toBe('main'); + expect(targets[0]?.metadata.headState).toBe('branch'); + }); +}); + +describe('commitDiscoveredRawWorktreesByProject', () => { + test('rejects an older aggregate commit after a newer targeted mutation and requests one bounded rediscovery', () => { + const rawRef = rawScope('runtime-1', [ + ['/repo', [worktree({ path: '/repo-old', projectDirectory: '/repo', branch: 'old', label: 'old' })]], + ]); + const reruns: string[] = []; + const published: Array = []; + const capturedRevision = rawRef.current.revision; + + markRawWorktreesByProjectMutation(rawRef, 'runtime-1'); + + const committed = commitDiscoveredRawWorktreesByProject({ + rawWorktreesByProjectRef: rawRef, + runtimeKey: 'runtime-1', + capturedRevision, + nextRawWorktreesByProject: new Map([ + ['/repo', [worktree({ path: '/repo-stale', projectDirectory: '/repo', branch: 'stale', label: 'stale' })]], + ]), + publishedWorktreesByProject: new Map(), + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + projects: [{ id: 'owner', path: '/repo' }], + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + requestRediscovery: () => { + reruns.push('rerun'); + }, + now: () => 123, + }); + + expect(committed).toBe(false); + expect(reruns).toEqual(['rerun']); + expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-old']); + expect(published).toEqual([]); + }); +}); + +describe('startSessionWorktreeMenuLoad', () => { + test('returns cached targets immediately, forces only the owning project refresh, and publishes refreshed topology', async () => { + const calls: Array<{ projectId: string; force: boolean }> = []; + const published: Array<{ availableWorktrees: WorktreeMetadata[]; availableWorktreesByProject: Map }> = []; + const rawRef = rawScope('runtime-1', [ + ['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]], + ['/repo-other', [worktree({ path: '/other-worktree', projectDirectory: '/repo-other', branch: 'other', label: 'other', name: 'other' })]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [ + { id: 'linked', path: '/repo-linked' }, + { id: 'other', path: '/repo-other' }, + ], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map(), + resolveProject: () => null, + listProjectWorktrees: async (project, options) => { + calls.push({ projectId: project.id, force: options.force }); + return [ + worktree({ path: '/repo-new', branch: 'aaa', label: 'aaa', name: 'aaa' }), + worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }), + ]; + }, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + + const freshTargets = await load.refreshTargets; + + expect(calls).toEqual([{ projectId: 'linked', force: true }]); + expect(freshTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-new', + '/repo-current', + ]); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([ + '/repo-new', + '/repo-current', + ]); + expect(published).toHaveLength(1); + expect(published[0]?.availableWorktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([ + '/repo-new', + '/repo-current', + ]); + }); + + test('rejects refresh failures without mutating topology and keeps cached targets available for the menu', async () => { + const published: Array = []; + const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' }); + const rawRef = rawScope('runtime-1', [ + ['/repo-linked', [existing]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [{ id: 'linked', path: '/repo-linked' }], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]), + resolveProject: () => null, + listProjectWorktrees: async () => { + throw new Error('git failed'); + }, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + const refreshError = await load.refreshTargets.catch((error) => error); + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('git failed'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]); + expect(published).toEqual([]); + }); + + test('seeds an empty raw scope from published topology so a failed first refresh preserves prior topology', async () => { + const publishedTopology = new Map([ + ['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]], + ]); + const rawRef = rawScope(null, []); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [{ id: 'linked', path: '/repo-linked' }], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => publishedTopology, + resolveProject: () => null, + listProjectWorktrees: async () => { + throw new Error('git failed'); + }, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => true, + recordWorktreesSeen: () => {}, + publishTopology: () => { + throw new Error('should not publish on failed refresh'); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + const refreshError = await load.refreshTargets.catch((error) => error); + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('git failed'); + expect(rawRef.current.runtimeKey).toBe('runtime-1'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']); + }); + + test('applies a non-owner shared-repository refresh to the owner raw and published topology', async () => { + const published: Array<{ availableWorktreesByProject: Map }> = []; + const ownerExisting = worktree({ path: '/repo-old', branch: 'old', label: 'old', name: 'old' }); + const rawRef = rawScope('runtime-1', [ + ['/repo', [ownerExisting]], + ['/repo-linked', [worktree({ path: '/repo-other-stale', branch: 'stale', label: 'stale', name: 'stale' })]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-linked', + currentWorktree: worktree({ path: '/repo-linked', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + }, + { + projects: [ + { id: 'owner', path: '/repo' }, + { id: 'linked', path: '/repo-linked' }, + ], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]), + resolveProject: () => null, + listProjectWorktrees: async () => [ + worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }), + ], + partitionWorktreesByRegisteredProject: (projects, worktreesByProject) => { + const ownerPath = projects[0]!.path; + return new Map([[ownerPath, worktreesByProject.get(ownerPath) ?? []]]); + }, + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push({ availableWorktreesByProject: next.availableWorktreesByProject }); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + await load.refreshTargets; + + expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']); + expect(published[0]?.availableWorktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']); + }); + + test('re-seeds raw topology on runtime change and ignores stale completions', async () => { + let runtimeKey = 'runtime-2'; + const refreshDeferred = createDeferred(); + const published: Array = []; + const rawRef = rawScope('runtime-1', [ + ['/old-runtime-repo', [worktree({ path: '/old-runtime-worktree', projectDirectory: '/old-runtime-repo' })]], + ]); + const publishedCurrentRuntime = new Map([ + ['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [{ id: 'linked', path: '/repo-linked' }], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => publishedCurrentRuntime, + resolveProject: () => null, + listProjectWorktrees: async () => refreshDeferred.promise, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + getRuntimeKey: () => runtimeKey, + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + expect(rawRef.current.runtimeKey).toBe('runtime-2'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']); + + runtimeKey = 'runtime-3'; + refreshDeferred.resolve([worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' })]); + + const refreshError = await load.refreshTargets.catch((error) => error); + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('Runtime changed during worktree refresh'); + expect(rawRef.current.runtimeKey).toBe('runtime-2'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']); + expect(published).toEqual([]); + }); + + test('falls back to resolving the owning configured project from the source directory when projectId is missing', async () => { + const calls: string[] = []; + const load = startSessionWorktreeMenuLoad( + { + projectId: null, + sourceDirectory: '/repo-feature', + currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + }, + { + projects: [{ id: 'owner', path: '/repo' }], + rawWorktreesByProjectRef: rawScope('runtime-1', []), + getPublishedWorktreesByProject: () => new Map(), + resolveProject: (directory) => { + calls.push(directory); + return { id: 'owner', path: '/repo' }; + }, + listProjectWorktrees: async (project) => [ + worktree({ path: '/repo-another', projectDirectory: project.path, branch: 'another', label: 'another', name: 'another' }), + ], + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: () => {}, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: 'main', + }, + ); + + expect(calls).toEqual(['/repo-feature']); + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual(['/repo', '/repo-feature']); + const refreshTargets = await load.refreshTargets; + expect(refreshTargets.map((target) => ({ + path: target.metadata.path, + branch: target.metadata.branch, + }))).toEqual([ + { path: '/repo', branch: 'main' }, + { path: '/repo-another', branch: 'another' }, + { path: '/repo-feature', branch: 'feature' }, + ]); + }); +}); + +describe('getSessionWorktreeMenuState', () => { + test('keeps the new worktree action available when refresh fails without cached targets', () => { + expect(getSessionWorktreeMenuState({ + targets: [], + isRefreshing: false, + loadFailed: true, + })).toEqual({ + refreshState: 'error', + showNewWorktreeAction: true, + }); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts new file mode 100644 index 00000000..081cd376 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts @@ -0,0 +1,402 @@ +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { normalizePath } from '@/lib/pathNormalization'; + +export type SessionWorktreeMenuTarget = { + metadata: WorktreeMetadata; + isPrimary: boolean; + isCurrent: boolean; +}; + +export type StartSessionWorktreeMenuLoadArgs = { + projectId: string | null; + sourceDirectory: string | null; + currentWorktree: WorktreeMetadata | null; +}; + +export type StartSessionWorktreeMenuLoadResult = { + cachedTargets: SessionWorktreeMenuTarget[]; + refreshTargets: Promise; +}; + +type SessionWorktreeMenuState = { + refreshState: 'loading' | 'error' | null; + showNewWorktreeAction: boolean; +}; + +type StartSessionWorktreeMenuLoadDependencies = { + projects: ReadonlyArray; + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; + getPublishedWorktreesByProject: () => Map; + resolveProject: (directory: string) => ProjectRef | null; + listProjectWorktrees: (project: ProjectRef, options: { force: true }) => Promise; + partitionWorktreesByRegisteredProject: ( + projects: ReadonlyArray>, + worktreesByProject: ReadonlyMap, + ) => Map; + worktreeMapsEqual: ( + a: Map, + b: Map, + ) => boolean; + recordWorktreesSeen: (paths: Iterable, seenAt: number) => void; + publishTopology: (next: { + availableWorktrees: WorktreeMetadata[]; + availableWorktreesByProject: Map; + }) => void; + getRuntimeKey: () => string; + now: () => number; + projectRootBranch: string | null; +}; + +type RequestRediscovery = () => void; + +export type RawWorktreesByProjectScope = { + runtimeKey: string | null; + revision: number; + worktreesByProject: Map; +}; + +export const markRawWorktreesByProjectMutation = ( + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }, + runtimeKey: string, +): number => { + if (rawWorktreesByProjectRef.current.runtimeKey !== runtimeKey) { + return rawWorktreesByProjectRef.current.revision; + } + rawWorktreesByProjectRef.current = { + ...rawWorktreesByProjectRef.current, + revision: rawWorktreesByProjectRef.current.revision + 1, + }; + return rawWorktreesByProjectRef.current.revision; +}; + +const cloneWorktreesByProject = ( + worktreesByProject: ReadonlyMap, +): Map => { + return new Map( + [...worktreesByProject.entries()].map(([projectPath, worktrees]) => [projectPath, worktrees.map((worktree) => cloneMetadata(worktree))]), + ); +}; + +export const ensureRawWorktreesByProjectScope = (args: { + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; + publishedWorktreesByProject: Map; + runtimeKey: string; +}): RawWorktreesByProjectScope => { + const shouldReseed = args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey + || (args.rawWorktreesByProjectRef.current.worktreesByProject.size === 0 && args.publishedWorktreesByProject.size > 0); + + if (shouldReseed) { + args.rawWorktreesByProjectRef.current = { + runtimeKey: args.runtimeKey, + revision: args.rawWorktreesByProjectRef.current.runtimeKey === args.runtimeKey + ? args.rawWorktreesByProjectRef.current.revision + : 0, + worktreesByProject: cloneWorktreesByProject(args.publishedWorktreesByProject), + }; + } + + return args.rawWorktreesByProjectRef.current; +}; + +const compareLinkedTargets = (a: SessionWorktreeMenuTarget, b: SessionWorktreeMenuTarget): number => { + const aLabel = a.metadata.branch || a.metadata.name || a.metadata.label || a.metadata.path; + const bLabel = b.metadata.branch || b.metadata.name || b.metadata.label || b.metadata.path; + const labelCompare = aLabel.localeCompare(bLabel, undefined, { sensitivity: 'base' }); + if (labelCompare !== 0) { + return labelCompare; + } + + return a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' }); +}; + +const buildFallbackLabel = (path: string): string => { + const parts = path.split('/').filter(Boolean); + return parts[parts.length - 1] ?? path; +}; + +const cloneMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => ({ + ...metadata, + path: normalizePath(metadata.path) ?? metadata.path, + projectDirectory: normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory, + worktreeRoot: normalizePath(metadata.worktreeRoot ?? metadata.path) ?? metadata.worktreeRoot, +}); + +const buildSyntheticWorktreeMetadata = (args: { + path: string; + projectDirectory: string; + currentWorktree: WorktreeMetadata | null; + projectRootBranch?: string | null; +}): WorktreeMetadata => { + const { currentWorktree, path, projectDirectory, projectRootBranch } = args; + const currentPath = normalizePath(currentWorktree?.path ?? null); + const isCurrentPath = currentPath === path; + const syntheticBranch = isCurrentPath ? (currentWorktree?.branch ?? '') : (projectRootBranch ?? ''); + + const syntheticMetadata: WorktreeMetadata = { + path, + projectDirectory, + branch: syntheticBranch, + label: isCurrentPath + ? (currentWorktree?.label || currentWorktree?.branch || currentWorktree?.name || buildFallbackLabel(path)) + : (projectRootBranch || buildFallbackLabel(path)), + name: isCurrentPath ? currentWorktree?.name : undefined, + worktreeRoot: isCurrentPath + ? (normalizePath(currentWorktree?.worktreeRoot ?? path) ?? path) + : path, + worktreeStatus: isCurrentPath + ? (currentWorktree?.worktreeStatus ?? 'ready') + : 'ready', + worktreeSource: isCurrentPath + ? (currentWorktree?.worktreeSource ?? 'existing') + : 'existing', + headState: isCurrentPath ? currentWorktree?.headState : (projectRootBranch ? 'branch' : undefined), + }; + + return isCurrentPath && currentWorktree + ? { ...currentWorktree, ...syntheticMetadata } + : syntheticMetadata; +}; + +export const buildSessionWorktreeMenuTargets = (args: { + projectPath: string | null; + discoveredWorktrees: ReadonlyArray; + sourceDirectory: string | null; + currentWorktree: WorktreeMetadata | null; + projectRootBranch?: string | null; +}): SessionWorktreeMenuTarget[] => { + const normalizedProjectPath = normalizePath(args.projectPath ?? null); + const normalizedSourceDirectory = normalizePath(args.sourceDirectory ?? null) + ?? normalizePath(args.currentWorktree?.path ?? null); + const discoveredPrimaryPath = normalizePath( + args.discoveredWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? null, + ); + const currentPrimaryPath = normalizePath(args.currentWorktree?.projectDirectory ?? null); + const primaryPath = discoveredPrimaryPath ?? currentPrimaryPath ?? normalizedProjectPath; + + const targetsByPath = new Map(); + const pushTarget = (target: SessionWorktreeMenuTarget): void => { + const normalizedPath = normalizePath(target.metadata.path ?? null); + if (!normalizedPath || targetsByPath.has(normalizedPath)) { + return; + } + targetsByPath.set(normalizedPath, { + ...target, + metadata: cloneMetadata({ + ...target.metadata, + path: normalizedPath, + }), + }); + }; + + for (const worktree of args.discoveredWorktrees) { + const normalizedPath = normalizePath(worktree.path ?? null); + if (!normalizedPath) { + continue; + } + pushTarget({ + metadata: cloneMetadata({ + ...worktree, + path: normalizedPath, + projectDirectory: normalizePath(worktree.projectDirectory ?? null) ?? primaryPath ?? normalizedProjectPath ?? normalizedPath, + }), + isPrimary: primaryPath === normalizedPath, + isCurrent: normalizedSourceDirectory === normalizedPath, + }); + } + + if (primaryPath && !targetsByPath.has(primaryPath)) { + pushTarget({ + metadata: buildSyntheticWorktreeMetadata({ + path: primaryPath, + projectDirectory: primaryPath, + currentWorktree: args.currentWorktree, + projectRootBranch: args.projectRootBranch, + }), + isPrimary: true, + isCurrent: normalizedSourceDirectory === primaryPath, + }); + } + + if (normalizedSourceDirectory && !targetsByPath.has(normalizedSourceDirectory)) { + pushTarget({ + metadata: buildSyntheticWorktreeMetadata({ + path: normalizedSourceDirectory, + projectDirectory: primaryPath ?? normalizedProjectPath ?? normalizedSourceDirectory, + currentWorktree: args.currentWorktree, + projectRootBranch: args.projectRootBranch, + }), + isPrimary: primaryPath === normalizedSourceDirectory, + isCurrent: true, + }); + } + + const primaryTargets: SessionWorktreeMenuTarget[] = []; + const linkedTargets: SessionWorktreeMenuTarget[] = []; + for (const target of targetsByPath.values()) { + if (target.isPrimary) { + primaryTargets.push(target); + continue; + } + linkedTargets.push(target); + } + + primaryTargets.sort((a, b) => a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' })); + linkedTargets.sort(compareLinkedTargets); + return [...primaryTargets, ...linkedTargets]; +}; + +export const startSessionWorktreeMenuLoad = ( + args: StartSessionWorktreeMenuLoadArgs, + deps: StartSessionWorktreeMenuLoadDependencies, +): StartSessionWorktreeMenuLoadResult => { + const runtimeKey = deps.getRuntimeKey(); + const publishedWorktreesByProject = deps.getPublishedWorktreesByProject(); + const rawScope = ensureRawWorktreesByProjectScope({ + rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef, + publishedWorktreesByProject, + runtimeKey, + }); + const projectById = args.projectId + ? deps.projects.find((candidate) => candidate.id === args.projectId) ?? null + : null; + const project = projectById ?? (args.sourceDirectory ? deps.resolveProject(args.sourceDirectory) : null); + const normalizedProjectPath = normalizePath(project?.path ?? null); + const cachedTargets = buildSessionWorktreeMenuTargets({ + projectPath: normalizedProjectPath, + discoveredWorktrees: normalizedProjectPath + ? (rawScope.worktreesByProject.get(normalizedProjectPath) ?? []) + : [], + sourceDirectory: args.sourceDirectory, + currentWorktree: args.currentWorktree, + projectRootBranch: deps.projectRootBranch, + }); + + return { + cachedTargets, + refreshTargets: (async () => { + if (!project || !normalizedProjectPath) { + throw new Error('Unable to resolve worktree project'); + } + + const refreshedWorktrees = await deps.listProjectWorktrees(project, { force: true }); + + if (deps.getRuntimeKey() !== runtimeKey) { + throw new Error('Runtime changed during worktree refresh'); + } + + const currentRawScope = ensureRawWorktreesByProjectScope({ + rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef, + publishedWorktreesByProject: deps.getPublishedWorktreesByProject(), + runtimeKey, + }); + const nextRawTopology = cloneWorktreesByProject(currentRawScope.worktreesByProject); + const nextProjectWorktrees = [...refreshedWorktrees] + .map((worktree) => cloneMetadata(worktree)) + .sort((a, b) => compareLinkedTargets( + { metadata: a, isPrimary: false, isCurrent: false }, + { metadata: b, isPrimary: false, isCurrent: false }, + )); + + const refreshedRepositoryRoot = normalizePath( + nextProjectWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory + ?? args.currentWorktree?.projectDirectory + ?? project.path, + ); + const matchingProjectPaths = new Set([normalizedProjectPath]); + for (const [projectPath, worktrees] of nextRawTopology.entries()) { + const repositoryRoot = normalizePath( + worktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? projectPath, + ); + if (repositoryRoot && repositoryRoot === refreshedRepositoryRoot) { + matchingProjectPaths.add(projectPath); + } + } + for (const projectPath of matchingProjectPaths) { + if (nextProjectWorktrees.length === 0) { + nextRawTopology.delete(projectPath); + continue; + } + nextRawTopology.set(projectPath, nextProjectWorktrees.map((worktree) => cloneMetadata(worktree))); + } + + markRawWorktreesByProjectMutation(deps.rawWorktreesByProjectRef, runtimeKey); + deps.rawWorktreesByProjectRef.current = { + runtimeKey, + revision: deps.rawWorktreesByProjectRef.current.revision, + worktreesByProject: nextRawTopology, + }; + + const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(deps.projects, nextRawTopology); + const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); + deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now()); + + const latestPublishedWorktreesByProject = deps.getPublishedWorktreesByProject(); + if (!deps.worktreeMapsEqual(partitionedWorktreesByProject, latestPublishedWorktreesByProject)) { + deps.publishTopology({ + availableWorktrees: allWorktrees, + availableWorktreesByProject: partitionedWorktreesByProject, + }); + } + + return buildSessionWorktreeMenuTargets({ + projectPath: normalizedProjectPath, + discoveredWorktrees: nextProjectWorktrees, + sourceDirectory: args.sourceDirectory, + currentWorktree: args.currentWorktree, + projectRootBranch: deps.projectRootBranch, + }); + })(), + }; +}; + +export const commitDiscoveredRawWorktreesByProject = (args: { + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; + runtimeKey: string; + capturedRevision: number; + nextRawWorktreesByProject: Map; + publishedWorktreesByProject: Map; + partitionWorktreesByRegisteredProject: StartSessionWorktreeMenuLoadDependencies['partitionWorktreesByRegisteredProject']; + projects: ReadonlyArray>; + worktreeMapsEqual: StartSessionWorktreeMenuLoadDependencies['worktreeMapsEqual']; + recordWorktreesSeen: StartSessionWorktreeMenuLoadDependencies['recordWorktreesSeen']; + publishTopology: StartSessionWorktreeMenuLoadDependencies['publishTopology']; + requestRediscovery: RequestRediscovery; + now: () => number; +}): boolean => { + if (args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey) { + return false; + } + if (args.rawWorktreesByProjectRef.current.revision !== args.capturedRevision) { + args.requestRediscovery(); + return false; + } + const partitionedWorktreesByProject = args.partitionWorktreesByRegisteredProject(args.projects, args.nextRawWorktreesByProject); + const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); + args.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), args.now()); + args.rawWorktreesByProjectRef.current = { + runtimeKey: args.runtimeKey, + revision: args.capturedRevision, + worktreesByProject: new Map(args.nextRawWorktreesByProject), + }; + if (!args.worktreeMapsEqual(partitionedWorktreesByProject, args.publishedWorktreesByProject)) { + args.publishTopology({ + availableWorktrees: allWorktrees, + availableWorktreesByProject: partitionedWorktreesByProject, + }); + } + return true; +}; + +export const getSessionWorktreeMenuState = (args: { + targets: ReadonlyArray; + isRefreshing: boolean; + loadFailed: boolean; +}): SessionWorktreeMenuState => { + return { + refreshState: args.isRefreshing + ? 'loading' + : (args.loadFailed && args.targets.length === 0 ? 'error' : null), + showNewWorktreeAction: true, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 3c9d40c5..9272f8b4 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -47,11 +47,21 @@ 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'; +import { + startSessionTreeExistingWorktreeMove, + startSessionTreeWorktreeMove, + useIsSessionWorktreeMovePending, +} from '@/lib/worktrees/sessionWorktreeMove'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useUIStore } from '@/stores/useUIStore'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { + getSessionWorktreeMenuState, + type SessionWorktreeMenuTarget, + type StartSessionWorktreeMenuLoadResult, +} from '../sessionWorktreeMenu'; type SecondaryMeta = { projectLabel?: string | null; @@ -88,6 +98,11 @@ export type SessionNodeItemProps = { createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void; handleRestoreSession: (session: Session) => void; + startSessionWorktreeMenuLoad: (args: { + projectId: string | null; + sourceDirectory: string | null; + currentWorktree: WorktreeMetadata | null; + }) => StartSessionWorktreeMenuLoadResult; mobileVariant: boolean; alwaysShowActions: boolean; secondaryMeta?: SecondaryMeta | null; @@ -271,6 +286,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode createFolderAndStartRename, handleDeleteSession, handleRestoreSession, + startSessionWorktreeMenuLoad, mobileVariant, alwaysShowActions, secondaryMeta, @@ -430,6 +446,12 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode // tick of the counter it only decides to mount. const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id); + const currentWorktreeMetadata = node.worktree ?? useSessionUIStore.getState().getWorktreeMetadata(session.id) ?? null; + const [worktreeTargets, setWorktreeTargets] = React.useState([]); + const [worktreeTargetsLoading, setWorktreeTargetsLoading] = React.useState(false); + const [worktreeTargetsLoadFailed, setWorktreeTargetsLoadFailed] = React.useState(false); + const worktreeSubmenuOpenRef = React.useRef(false); + const worktreeLoadSequenceRef = React.useRef(0); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false }); const sessionGoal = getSessionGoal(resolvedSession); const sessionGoalGlyph = sessionGoal ? ( @@ -879,6 +901,41 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode } }; + const handleWorktreeSubmenuOpenChange = React.useCallback((open: boolean) => { + worktreeSubmenuOpenRef.current = open; + worktreeLoadSequenceRef.current += 1; + const loadSequence = worktreeLoadSequenceRef.current; + if (!open) { + setWorktreeTargetsLoading(false); + setWorktreeTargetsLoadFailed(false); + return; + } + const load = startSessionWorktreeMenuLoad({ + projectId: projectId ?? null, + sourceDirectory: sessionDirectory, + currentWorktree: currentWorktreeMetadata, + }); + setWorktreeTargets(load.cachedTargets); + setWorktreeTargetsLoading(true); + setWorktreeTargetsLoadFailed(false); + void load.refreshTargets + .then((freshTargets) => { + if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) { + return; + } + setWorktreeTargets(freshTargets); + setWorktreeTargetsLoading(false); + setWorktreeTargetsLoadFailed(false); + }) + .catch(() => { + if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) { + return; + } + setWorktreeTargetsLoading(false); + setWorktreeTargetsLoadFailed(true); + }); + }, [currentWorktreeMetadata, projectId, sessionDirectory, startSessionWorktreeMenuLoad]); + const renderSessionMenuItems = ({ Item, Separator, @@ -935,38 +992,105 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {t('sessions.sidebar.session.menu.exportMarkdown')} - {!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? ( - - - - { - 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" - > - - {t('sessions.sidebar.session.menu.moveToWorktree')} - - - - - {isMovingToWorktree - ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') - : isStreaming - ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') - : t('sessions.sidebar.session.moveToWorktree.tooltip')} - - - ) : null} +{!isSubtaskSession && !archivedBucket && !isVSCode ? (() => { + const worktreeMenuState = getSessionWorktreeMenuState({ + targets: worktreeTargets, + isRefreshing: worktreeTargetsLoading, + loadFailed: worktreeTargetsLoadFailed, + }); + return ( + + + + + + + {t('sessions.sidebar.session.menu.moveToWorktreeTargets')} + + + {worktreeTargets.map((target) => { + const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path; + const itemLabel = target.isPrimary + ? t('sessions.sidebar.session.moveToWorktree.main') + : (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path); + const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready'; + + return ( + { + if (isDisabled || !sessionDirectory) { + return; + } + startSessionTreeExistingWorktreeMove({ + root: resolvedSession, + descendants: collectNodeDescendantSessions(node), + sourceDirectory: sessionDirectory, + destination: target.metadata, + successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'), + }); + }} + > + + {itemLabel} + {target.isCurrent ? {t('sessions.sidebar.session.moveToWorktree.current')} : null} + + {target.isCurrent ? + ); + })} + {worktreeMenuState.refreshState === 'loading' ? ( + + {t('sessions.sidebar.session.moveToWorktree.refreshing')} + + ) : null} + {worktreeMenuState.refreshState === 'error' ? ( + + {t('sessions.sidebar.session.moveToWorktree.loadFailed')} + + ) : null} + + {worktreeMenuState.showNewWorktreeAction ? ( + { + 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="[&>svg]:mr-1" + > + + {t('sessions.sidebar.session.menu.newWorktree')} + + ) : null} + + + + + + {isMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isStreaming + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltipTargets')} + + + ); + })() : null} {isMultiRunLikeSession ? ( setFusionDialogOpen(true)} className="[&>svg]:mr-1"> @@ -1628,6 +1752,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN && prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.handleDeleteSession === next.handleDeleteSession && prev.handleRestoreSession === next.handleRestoreSession + && prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad && prev.children === next.children; }; diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx index e524c50e..87f3a4c8 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx @@ -3,6 +3,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import type { Session } from '@opencode-ai/sdk/v2'; import type { SessionNodeItemProps } from './SessionNodeItem'; +import type { SessionTreeItemProps } from './SessionTreeItem'; import { installHookTestDom } from '../test-utils/testDom'; import { I18nProvider } from '@/lib/i18n'; @@ -39,6 +40,11 @@ mock.module('./hooks/useSessionActions', () => ({ const { SessionTreeItem } = await import('./SessionTreeItem'); +const noopStartSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad'] = () => ({ + cachedTargets: [], + refreshTargets: Promise.resolve([]), +}); + const session = (id: string): Session => ({ id, slug: id, @@ -91,6 +97,7 @@ describe('SessionTreeItem public behavior', () => { setDeleteSessionConfirm={noop} startFolderRename={noop} setCopiedSessionId={setCopiedSessionId} + startSessionWorktreeMenuLoad={noopStartSessionWorktreeMenuLoad} mobileVariant={false} alwaysShowActions={false} {...context} diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx index d3519e26..9bd265f0 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx @@ -39,6 +39,7 @@ export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick & { allowReselect: boolean; onSessionSelected?: (sessionId: string) => void; @@ -88,6 +89,7 @@ export function SessionTreeItem({ startFolderRename, copiedSessionId, setCopiedSessionId, + startSessionWorktreeMenuLoad, mobileVariant, alwaysShowActions, }: SessionTreeItemProps): React.ReactNode { @@ -160,11 +162,12 @@ export function SessionTreeItem({ openSidebarMenuKey={openSidebarMenuKey} setOpenSidebarMenuKey={setOpenSidebarMenuKey} createFolderAndStartRename={createFolderAndStartRename} - handleDeleteSession={sessionActions.handleDeleteSession} - handleRestoreSession={sessionActions.handleRestoreSession} - mobileVariant={mobileVariant} - alwaysShowActions={alwaysShowActions} - pinnedSessionIds={pinnedSessionIds} + handleDeleteSession={sessionActions.handleDeleteSession} + handleRestoreSession={sessionActions.handleRestoreSession} + startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad} + mobileVariant={mobileVariant} + alwaysShowActions={alwaysShowActions} + pinnedSessionIds={pinnedSessionIds} node={node} depth={depth} groupDirectory={groupDirectory} @@ -201,11 +204,12 @@ export function SessionTreeItem({ setIsSessionSearchOpen={setIsSessionSearchOpen} deleteSessionConfirm={deleteSessionConfirm} setDeleteSessionConfirm={setDeleteSessionConfirm} - startFolderRename={startFolderRename} - setCopiedSessionId={setCopiedSessionId} - mobileVariant={mobileVariant} - alwaysShowActions={alwaysShowActions} - depth={depth + 1} + startFolderRename={startFolderRename} + setCopiedSessionId={setCopiedSessionId} + startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad} + mobileVariant={mobileVariant} + alwaysShowActions={alwaysShowActions} + depth={depth + 1} {...childContext} renderExtras={childRenderExtrasFor?.(child)} /> diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 62c0cfaa..caab5042 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2988,8 +2988,17 @@ export const dict = { 'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert', 'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden', 'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'In Worktree verschieben', + 'sessions.sidebar.session.menu.newWorktree': 'Neuer Worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben', 'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden', + 'sessions.sidebar.session.moveToWorktree.main': 'Haupt-Worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktrees werden aktualisiert...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees konnten nicht geladen werden', + 'sessions.sidebar.session.moveToWorktree.current': 'Aktueller Worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e8e697aa..2d820570 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -536,8 +536,17 @@ export const dict = { 'sessions.sidebar.session.menu.unshare': 'Unshare', 'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown', 'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Move to worktree', + 'sessions.sidebar.session.menu.newWorktree': 'New worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Session moved to a new worktree', 'sessions.sidebar.session.moveToWorktree.failed': 'Failed to move session to a new worktree', + 'sessions.sidebar.session.moveToWorktree.main': 'Main worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Refreshing worktrees...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees could not be loaded', + 'sessions.sidebar.session.moveToWorktree.current': 'Current worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index ebc2a2ad..8b8b656b 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -537,8 +537,17 @@ export const dict: Record = { "sessions.sidebar.session.menu.unshare": "Dejar de compartir", "sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown", "sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo", + "sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover a worktree", + "sessions.sidebar.session.menu.newWorktree": "Nuevo worktree...", "sessions.sidebar.session.moveToWorktree.success": "Sesión movida a un worktree nuevo", "sessions.sidebar.session.moveToWorktree.failed": "No se pudo mover la sesión a un worktree nuevo", + "sessions.sidebar.session.moveToWorktree.main": "Worktree principal", + "sessions.sidebar.session.moveToWorktree.refreshing": "Actualizando worktrees...", + "sessions.sidebar.session.moveToWorktree.loadFailed": "No se pudieron cargar los worktrees", + "sessions.sidebar.session.moveToWorktree.current": "Worktree actual", + "sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree", + "sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree", + "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.", "sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 46d97256..76e05ad3 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -367,8 +367,17 @@ export const dict = { 'sessions.sidebar.session.menu.unshare': 'Annuler le partage', 'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown', 'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Déplacer vers un worktree', + 'sessions.sidebar.session.menu.newWorktree': 'Nouveau worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Session déplacée vers un nouveau worktree', 'sessions.sidebar.session.moveToWorktree.failed': 'Impossible de déplacer la session vers un nouveau worktree', + 'sessions.sidebar.session.moveToWorktree.main': 'Worktree principal', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Actualisation des worktrees...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Impossible de charger les worktrees', + 'sessions.sidebar.session.moveToWorktree.current': 'Worktree actuel', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et l’option d’en créer un nouveau pour cette session.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez l’activité en cours ou attendez sa fin.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 845bc896..cadc381f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -537,8 +537,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '共有解除', 'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート', 'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktreeへ移動', + 'sessions.sidebar.session.menu.newWorktree': '新しいworktree...', 'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました', 'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした', + 'sessions.sidebar.session.moveToWorktree.main': 'メインworktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'worktreeを更新しています...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktreeを読み込めませんでした', + 'sessions.sidebar.session.moveToWorktree.current': '現在のworktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。', 'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 18ebfa49..d8389704 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -537,8 +537,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '공유 해제', 'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기', 'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktree로 이동', + 'sessions.sidebar.session.menu.newWorktree': '새 worktree...', 'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다', 'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다', + 'sessions.sidebar.session.moveToWorktree.main': '메인 worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'worktree 새로 고침 중...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktree를 불러오지 못했습니다', + 'sessions.sidebar.session.moveToWorktree.current': '현재 worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다', + 'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.', 'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 9938ac81..c95038bd 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -330,8 +330,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie', 'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown', 'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Przenieś do worktree', + 'sessions.sidebar.session.menu.newWorktree': 'Nowy worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Sesja została przeniesiona do nowego worktree', 'sessions.sidebar.session.moveToWorktree.failed': 'Nie udało się przenieść sesji do nowego worktree', + 'sessions.sidebar.session.moveToWorktree.main': 'Główny worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Odświeżanie worktree...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Nie udało się wczytać worktree', + 'sessions.sidebar.session.moveToWorktree.current': 'Bieżący worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 73c923ec..8ab01425 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -537,8 +537,17 @@ export const dict: Record = { "sessions.sidebar.session.menu.unshare": "Parar de compartilhar", "sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown", "sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree", + "sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover para worktree", + "sessions.sidebar.session.menu.newWorktree": "Novo worktree...", "sessions.sidebar.session.moveToWorktree.success": "Sessão movida para um novo worktree", "sessions.sidebar.session.moveToWorktree.failed": "Não foi possível mover a sessão para um novo worktree", + "sessions.sidebar.session.moveToWorktree.main": "Worktree principal", + "sessions.sidebar.session.moveToWorktree.refreshing": "Atualizando worktrees...", + "sessions.sidebar.session.moveToWorktree.loadFailed": "Não foi possível carregar os worktrees", + "sessions.sidebar.session.moveToWorktree.current": "Worktree atual", + "sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree", + "sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree", + "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.", "sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 08a5949c..5c46ddb0 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -537,8 +537,17 @@ export const dict: Record = { "sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ", "sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown", "sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree", + "sessions.sidebar.session.menu.moveToWorktreeTargets": "Перенести в worktree", + "sessions.sidebar.session.menu.newWorktree": "Новий worktree...", "sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree", "sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree", + "sessions.sidebar.session.moveToWorktree.main": "Основний worktree", + "sessions.sidebar.session.moveToWorktree.refreshing": "Оновлення worktree...", + "sessions.sidebar.session.moveToWorktree.loadFailed": "Не вдалося завантажити worktree", + "sessions.sidebar.session.moveToWorktree.current": "Поточний worktree", + "sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree", + "sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree", + "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.", "sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 191110b9..34504816 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -537,8 +537,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '取消分享', 'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown', 'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作树', + 'sessions.sidebar.session.menu.newWorktree': '新建工作树...', 'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树', 'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树', + 'sessions.sidebar.session.moveToWorktree.main': '主工作树', + 'sessions.sidebar.session.moveToWorktree.refreshing': '正在刷新工作树...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': '无法加载工作树', + 'sessions.sidebar.session.moveToWorktree.current': '当前工作树', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树', + 'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。', 'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 7338818a..aefcb5e0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -550,8 +550,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '取消分享', 'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown', 'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作樹', + 'sessions.sidebar.session.menu.newWorktree': '新增工作樹...', 'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹', 'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹', + 'sessions.sidebar.session.moveToWorktree.main': '主要工作樹', + 'sessions.sidebar.session.moveToWorktree.refreshing': '正在重新整理工作樹...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': '無法載入工作樹', + 'sessions.sidebar.session.moveToWorktree.current': '目前的工作樹', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹', + 'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。', 'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。', diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts new file mode 100644 index 00000000..14abe32e --- /dev/null +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -0,0 +1,485 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Session, SessionStatus } from '@opencode-ai/sdk/v2'; +import type { State } from '@/sync/types'; +import type { WorktreeMetadata } from '@/types/worktree'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; + +const moveCalls: Array<{ + sessionId: string; + sourceDirectory: string; + destinationDirectory: string; + moveChanges: boolean; +}> = []; +const refreshCalls: string[][] = []; +type RemoveProjectWorktreeOptions = { deleteLocalBranch: boolean }; +type RemoveProjectWorktreeCall = { + project: ProjectRef; + worktree: WorktreeMetadata; + options: RemoveProjectWorktreeOptions; +}; +type MoveSessionImplementation = ( + session: Session, + sourceDirectory: string, + destinationDirectory: string, + moveChanges: boolean, +) => Promise; +type RefreshImplementation = (directories: string[]) => Promise; +type CreateQuickWorktreeOptions = { preferredName?: string; startRef?: string }; +type CreateQuickWorktreeImplementation = ( + project: ProjectRef, + options: CreateQuickWorktreeOptions, +) => Promise; +type ResolveProjectRefImplementation = (directory: string) => ProjectRef | null; +type WaitForWorktreeGitReadyImplementation = (directory: string) => Promise; +type DirectoryState = Pick; +type DeferredVoid = { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +}; + +const removeWorktreeCalls: RemoveProjectWorktreeCall[] = []; +const metadataWrites: Array<{ sessionId: string; metadata: WorktreeMetadata | null }> = []; +const latestMetadataInputs: WorktreeMetadata[] = []; +const toastSuccesses: string[] = []; +const toastErrors: Array<{ title: string; description?: string }> = []; +const directoryStates = new Map(); +const storedMetadata = new Map(); +const originalConsoleWarn = console.warn; + +let moveSessionImplementation: MoveSessionImplementation = async () => {}; +let refreshImplementation: RefreshImplementation = async () => {}; +let latestMetadataResult: WorktreeMetadata; +let createQuickWorktreeImplementation: CreateQuickWorktreeImplementation = async () => ({ + path: '/created-worktree', + projectDirectory: '/repo', + branch: 'feature', + label: 'Created worktree', + worktreeStatus: 'ready', + worktreeSource: 'created-for-session', +}); +let resolveProjectRefImplementation: ResolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' }); +let waitForWorktreeGitReadyImplementation: WaitForWorktreeGitReadyImplementation = async () => {}; + +mock.module('@/components/ui', () => ({ + toast: { + success: (message: string) => { + toastSuccesses.push(message); + }, + error: (title: string, options?: { description?: string }) => { + toastErrors.push({ title, description: options?.description }); + }, + }, +})); + +mock.module('@/lib/gitApi', () => ({ + getGitStatus: mock(() => Promise.resolve({ current: 'feature' })), +})); + +mock.module('@/lib/worktreeSessionCreator', () => ({ + createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => createQuickWorktreeImplementation(project, options)), + resolveProjectRef: mock((directory: string) => resolveProjectRefImplementation(directory)), +})); + +mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ + waitForWorktreeGitReady: mock((directory: string) => waitForWorktreeGitReadyImplementation(directory)), +})); + +mock.module('@/lib/worktrees/worktreeManager', () => ({ + getLatestWorktreeMetadata: (metadata: WorktreeMetadata) => { + latestMetadataInputs.push(metadata); + return latestMetadataResult; + }, + removeProjectWorktree: (project: ProjectRef, worktree: WorktreeMetadata, options: RemoveProjectWorktreeOptions) => { + removeWorktreeCalls.push({ project, worktree, options }); + return Promise.resolve(); + }, +})); + +mock.module('@/stores/useGlobalSessionsStore', () => ({ + refreshGlobalSessionsForDirectories: (directories: string[]) => { + refreshCalls.push(directories); + return refreshImplementation(directories); + }, +})); + +mock.module('@/sync/session-actions', () => ({ + moveSessionToDirectory: (session: Session, sourceDirectory: string, destinationDirectory: string, moveChanges = true) => { + moveCalls.push({ sessionId: session.id, sourceDirectory, destinationDirectory, moveChanges }); + return moveSessionImplementation(session, sourceDirectory, destinationDirectory, moveChanges); + }, +})); + +mock.module('@/sync/session-ui-store', () => ({ + useSessionUIStore: { + getState: () => ({ + availableWorktrees: [], + availableWorktreesByProject: new Map(), + getWorktreeMetadata: (sessionId: string) => storedMetadata.get(sessionId) ?? null, + setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { + storedMetadata.set(sessionId, metadata); + metadataWrites.push({ sessionId, metadata }); + }, + }), + }, +})); + +mock.module('@/sync/sync-refs', () => ({ + getDirectoryState: (directory: string) => directoryStates.get(directory), +})); + +const { + moveSessionTreeToExistingWorktree, + startSessionTreeWorktreeMove, +} = await import('./sessionWorktreeMove'); + +const makeSession = (id: string, directory = '/source'): Session => ({ + id, + slug: id, + projectID: 'project-1', + directory, + title: id, + version: '1', + time: { + created: 0, + updated: 0, + }, +}); + +const makeWorktreeMetadata = (overrides: Partial = {}): WorktreeMetadata => ({ + path: '/destination', + projectDirectory: '/repo', + branch: 'feature', + label: 'Destination', + worktreeStatus: 'ready', + worktreeSource: 'existing', + ...overrides, +}); + +const makeSessionStatus = (type: SessionStatus['type']): SessionStatus => { + switch (type) { + case 'busy': + return { type: 'busy' }; + case 'idle': + return { type: 'idle' }; + case 'retry': + return { type: 'retry', attempt: 1, message: 'retry', next: 0 }; + } +}; + +const setStatuses = (directory: string, statuses: Record): void => { + directoryStates.set(directory, { + session_status: Object.fromEntries( + Object.entries(statuses).map(([sessionId, type]) => [sessionId, makeSessionStatus(type)]), + ), + }); +}; + +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error('Timed out waiting for condition'); +}; + +const deferred = (): DeferredVoid => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe('moveSessionTreeToExistingWorktree', () => { + beforeEach(() => { + moveCalls.length = 0; + refreshCalls.length = 0; + removeWorktreeCalls.length = 0; + metadataWrites.length = 0; + latestMetadataInputs.length = 0; + toastSuccesses.length = 0; + toastErrors.length = 0; + directoryStates.clear(); + storedMetadata.clear(); + latestMetadataResult = makeWorktreeMetadata({ label: 'Latest destination' }); + moveSessionImplementation = async () => {}; + refreshImplementation = async () => {}; + createQuickWorktreeImplementation = async () => makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session' }); + resolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' }); + waitForWorktreeGitReadyImplementation = async () => {}; + console.warn = () => {}; + }); + + afterEach(() => { + console.warn = originalConsoleWarn; + }); + + test('moves the root before descendants, only transfers changes once, and refreshes both directories', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); + const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' }); + const destination = makeWorktreeMetadata(); + setStatuses('/source', { root: 'idle', child: 'idle' }); + storedMetadata.set(root.id, previousRootMetadata); + storedMetadata.set(child.id, previousChildMetadata); + + const result = await moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination, + }); + + expect(result).toBe('/destination'); + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + ]); + expect(metadataWrites).toEqual([ + { sessionId: 'root', metadata: latestMetadataResult }, + { sessionId: 'child', metadata: latestMetadataResult }, + ]); + expect(latestMetadataInputs).toEqual([destination, destination]); + expect(refreshCalls).toEqual([['/source', '/destination']]); + expect(removeWorktreeCalls).toEqual([]); + }); + + test('rejects a destination that normalizes to the source directory', async () => { + setStatuses('/source', { root: 'idle' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source/', + destination: makeWorktreeMetadata({ path: '/source' }), + })).rejects.toThrow('Source and destination are the same'); + + expect(moveCalls).toEqual([]); + expect(refreshCalls).toEqual([]); + }); + + test('rejects a destination worktree that is not ready', async () => { + setStatuses('/source', { root: 'idle' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata({ worktreeStatus: 'pending' }), + })).rejects.toThrow('Destination worktree is not ready'); + + expect(moveCalls).toEqual([]); + }); + + test('rejects when the root session is busy before setup', async () => { + setStatuses('/source', { root: 'busy' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([]); + }); + + test('rejects when any descendant is busy before setup', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'retry' }); + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([]); + }); + + test('rejects a duplicate move request while the root move is pending', async () => { + const root = makeSession('root'); + const rootMove = deferred(); + setStatuses('/source', { root: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + return rootMove.promise; + } + }; + + const firstMove = moveSessionTreeToExistingWorktree({ + root, + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }); + await waitFor(() => moveCalls.length === 1); + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('Session move already in progress'); + + rootMove.resolve(); + await firstMove; + expect(moveCalls).toHaveLength(1); + }); + + test('rolls back completed moves in reverse order, restores previous metadata, and never removes an existing destination', async () => { + const root = makeSession('root'); + const childA = makeSession('child-a'); + const childB = makeSession('child-b'); + const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); + const previousChildAMetadata = makeWorktreeMetadata({ path: '/old-child-a', label: 'Old child A' }); + const previousChildBMetadata = makeWorktreeMetadata({ path: '/old-child-b', label: 'Old child B' }); + setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' }); + storedMetadata.set(root.id, previousRootMetadata); + storedMetadata.set(childA.id, previousChildAMetadata); + storedMetadata.set(childB.id, previousChildBMetadata); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'child-b' && sourceDirectory === '/source') { + throw new Error('child-b failed'); + } + }; + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [childA, childB], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('child-b failed'); + + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-b', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false }, + { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, + ]); + expect(metadataWrites).toEqual([ + { sessionId: 'root', metadata: latestMetadataResult }, + { sessionId: 'child-a', metadata: latestMetadataResult }, + { sessionId: 'child-a', metadata: previousChildAMetadata }, + { sessionId: 'root', metadata: previousRootMetadata }, + ]); + expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); + expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata); + expect(storedMetadata.get(childB.id)).toBe(previousChildBMetadata); + expect(removeWorktreeCalls).toEqual([]); + expect(refreshCalls).toEqual([]); + }); + + test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'child' && sourceDirectory === '/source') { + throw new Error('child failed'); + } + if (session.id === 'root' && sourceDirectory === '/destination') { + throw new Error('rollback failed'); + } + }; + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('could not be fully rolled back'); + + expect(removeWorktreeCalls).toEqual([]); + }); + + test('keeps the move successful when the post-move refresh fails', async () => { + const root = makeSession('root'); + setStatuses('/source', { root: 'idle' }); + refreshImplementation = async () => { + throw new Error('refresh failed'); + }; + + const result = await moveSessionTreeToExistingWorktree({ + root, + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }); + + expect(result).toBe('/destination'); + expect(refreshCalls).toEqual([['/source', '/destination']]); + }); + + test('removes a newly created worktree when git-ready setup fails', async () => { + setStatuses('/source', { root: 'idle' }); + waitForWorktreeGitReadyImplementation = async () => { + throw new Error('git-ready failed'); + }; + + startSessionTreeWorktreeMove({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + successMessage: 'success', + failureMessage: 'failed', + }); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'failed', description: 'git-ready failed' }]); + expect(removeWorktreeCalls).toEqual([{ + project: { id: 'project-1', path: '/repo' }, + worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }), + options: { deleteLocalBranch: true }, + }]); + expect(moveCalls).toEqual([]); + }); + + test('removes a newly created worktree when a session becomes busy before the first move', async () => { + setStatuses('/source', { root: 'idle' }); + waitForWorktreeGitReadyImplementation = async () => { + setStatuses('/source', { root: 'busy' }); + }; + + startSessionTreeWorktreeMove({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + successMessage: 'success', + failureMessage: 'failed', + }); + + await waitFor(() => toastErrors.length === 1); + expect(removeWorktreeCalls).toEqual([{ + project: { id: 'project-1', path: '/repo' }, + worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }), + options: { deleteLocalBranch: true }, + }]); + expect(moveCalls).toEqual([]); + }); + + test('surfaces a pre-destination preparation failure without attempting removal', async () => { + setStatuses('/source', { root: 'idle' }); + resolveProjectRefImplementation = () => null; + + startSessionTreeWorktreeMove({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + successMessage: 'success', + failureMessage: 'failed', + }); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: '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 9ddb5e57..e0b679b1 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -88,31 +88,34 @@ const rollbackMovedSessions = async ( const removeFailedWorktree = async ( project: ProjectRef, worktree: WorktreeMetadata, - moveError: unknown, + moveError: Error, ): Promise => { try { await removeProjectWorktree(project, worktree, { deleteLocalBranch: true }); } catch { - const message = moveError instanceof Error ? moveError.message : String(moveError); - throw new Error(`Session move failed and the new worktree could not be removed: ${message}`); + throw new Error(`Session move failed and the new worktree could not be removed: ${moveError.message}`); } throw moveError; }; -const moveSessionTreeToQuickWorktree = async (input: { - root: Session; - descendants: Session[]; - sourceDirectory: string; -}): Promise => { +const moveSessionTreeTransaction = async ( + input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; + }, + prepareDestination: () => Promise<{ + directory: string; + metadata: WorktreeMetadata; + onMoveFailure?: (error: Error) => Promise; + }>, +): Promise => { if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) { throw new Error('Session move already in progress'); } setSessionMovePending(input.root.id, true); try { - const project = resolveProjectRef(input.sourceDirectory); - if (!project) throw new Error('Unable to find the project for this session'); - const sessions = [input.root, ...input.descendants]; const previousMetadata = new Map( sessions.map((session) => [ @@ -122,49 +125,112 @@ const moveSessionTreeToQuickWorktree = async (input: { ); assertSessionsIdle(sessions, input.sourceDirectory); - const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path); - const worktree = await createQuickWorktree(project, { startRef: sourceBranch }); - + let destination: Awaited> | null = null; const moved: Session[] = []; try { - await waitForWorktreeGitReady(worktree.path); - // Branch/status discovery and worktree creation can take long enough for a - // session to start running, so verify the whole tree again before moving. + destination = await prepareDestination(); + // Setup can take long enough for one of the sessions to start running, so + // verify the whole tree again immediately before the first move. assertSessionsIdle(sessions, input.sourceDirectory); for (const [index, session] of sessions.entries()) { // Transfer the checkout changes once with the root. Descendants only // need their execution location updated. - await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0); + await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, index === 0); moved.push(session); - useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree)); + useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata)); } } catch (error) { + const moveError = error instanceof Error ? error : new Error(String(error)); const rollbackFailures = await rollbackMovedSessions( moved, input.root.id, input.sourceDirectory, - worktree.path, + destination?.directory ?? input.sourceDirectory, previousMetadata, ); if (rollbackFailures.length > 0) { - throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Session move partially failed and could not be fully rolled back: ${moveError.message}`); } - return removeFailedWorktree(project, worktree, error); + if (destination?.onMoveFailure) { + return destination.onMoveFailure(moveError); + } + throw moveError; } try { - await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]); + await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]); } catch (error) { // Direct action updates already reconciled both stores. Keep the move // successful if this best-effort authoritative refresh is unavailable. console.warn('[session-worktree-move] Failed to refresh moved sessions', error); } - return worktree.path; + return destination.directory; } finally { setSessionMovePending(input.root.id, false); } }; +export const moveSessionTreeToExistingWorktree = async (input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; + destination: WorktreeMetadata; +}): Promise => { + const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory; + const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path; + if (normalizedSourceDirectory === normalizedDestinationDirectory) { + throw new Error('Source and destination are the same'); + } + if (input.destination.worktreeStatus !== 'ready') { + throw new Error('Destination worktree is not ready'); + } + + return moveSessionTreeTransaction(input, async () => ({ + directory: input.destination.path, + metadata: input.destination, + })); +}; + +const moveSessionTreeToQuickWorktree = async (input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; +}): Promise => { + return moveSessionTreeTransaction(input, async () => { + const project = resolveProjectRef(input.sourceDirectory); + if (!project) throw new Error('Unable to find the project for this session'); + + const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path); + const worktree = await createQuickWorktree(project, { startRef: sourceBranch }); + try { + await waitForWorktreeGitReady(worktree.path); + } catch (error) { + const setupError = error instanceof Error ? error : new Error(String(error)); + return removeFailedWorktree(project, worktree, setupError); + } + return { + directory: worktree.path, + metadata: worktree, + onMoveFailure: (error) => removeFailedWorktree(project, worktree, error), + }; + }); +}; + +export const startSessionTreeExistingWorktreeMove = (input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; + destination: WorktreeMetadata; + successMessage: string; + failureMessage: string; +}): void => { + void moveSessionTreeToExistingWorktree(input) + .then(() => toast.success(input.successMessage)) + .catch((error) => toast.error(input.failureMessage, { + description: error instanceof Error ? error.message : String(error), + })); +}; + export const startSessionTreeWorktreeMove = (input: { root: Session; descendants: Session[]; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 772db94b..eeed16c9 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -11,6 +11,7 @@ type WorktreeListEntry = { const listCalls: string[] = []; const listResolvers: Array<(value: WorktreeListEntry[]) => void> = []; +const listRejecters: Array<(reason: Error) => void> = []; const createPayloads: unknown[] = []; const validatePayloads: unknown[] = []; const createdWorktree = { @@ -78,8 +79,9 @@ mock.module('@/lib/gitApi', () => ({ worktree: { list: (directory: string) => { listCalls.push(directory); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { listResolvers.push(resolve); + listRejecters.push((reason: Error) => reject(reason)); }); }, create: mock((_directory: string, payload: unknown) => { @@ -118,6 +120,7 @@ describe('worktreeManager list invalidation', () => { beforeEach(() => { listCalls.length = 0; listResolvers.length = 0; + listRejecters.length = 0; createPayloads.length = 0; validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; @@ -152,6 +155,85 @@ describe('worktreeManager list invalidation', () => { expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']); }); + test('forced refresh bypasses a fresh cached result', async () => { + const project = { id: 'project-force-cache', path: '/repo-force-cache' }; + + const initialListing = listProjectWorktrees(project); + await waitForListCallCount(1); + listResolvers[0]([]); + const initialResult = await initialListing; + expect(initialResult).toEqual([]); + + const cachedResult = await listProjectWorktrees(project); + expect(cachedResult).toEqual([]); + expect(listCalls).toEqual(['/repo-force-cache']); + + const forcedListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + listResolvers[1]([createdWorktree]); + + const forcedResult = await forcedListing; + expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']); + const refreshedCachedResult = await listProjectWorktrees(project); + expect(refreshedCachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']); + }); + + test('forced refresh starts a new request instead of joining an older in-flight list', async () => { + const project = { id: 'project-force-inflight', path: '/repo-force-inflight' }; + + const initialListing = listProjectWorktrees(project); + await waitForListCallCount(1); + + const forcedListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + + listResolvers[1]([createdWorktree]); + const forcedResult = await forcedListing; + expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + + listResolvers[0]([]); + await waitForListCallCount(3); + listResolvers[2]([createdWorktree]); + const initialResult = await initialListing; + expect(initialResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toEqual([ + '/repo-force-inflight', + '/repo-force-inflight', + '/repo-force-inflight', + ]); + }); + + test('older completions do not replace a forced refresh result with stale topology', async () => { + const project = { id: 'project-force-stale', path: '/repo-force-stale' }; + + void listProjectWorktrees(project); + await waitForListCallCount(1); + + const forcedListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + listResolvers[1]([createdWorktree]); + const forcedResult = await forcedListing; + expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + + listResolvers[0]([{ path: '/repo-stale', branch: 'stale', name: 'stale' }]); + await waitForListCallCount(3); + + const cachedResult = await listProjectWorktrees(project); + expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + }); + + test('rejects when git worktree listing fails', async () => { + const project = { id: 'project-force-failure', path: '/repo-force-failure' }; + + const listing = listProjectWorktrees(project); + await waitForListCallCount(1); + listRejecters[0](new Error('git failed')); + + await expect(listing).rejects.toThrow('git failed'); + }); + test('marks fast-created worktrees pending until bootstrap settles', async () => { const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, { preferredName: 'feature', @@ -389,6 +471,7 @@ describe('worktreeManager fork remote payload wiring', () => { beforeEach(() => { listCalls.length = 0; listResolvers.length = 0; + listRejecters.length = 0; createPayloads.length = 0; validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 543c1d41..50016065 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -374,7 +374,7 @@ export const partitionWorktreesByRegisteredProject = ( // Cache worktree listings to avoid repeated git worktree list + rev-parse calls const _worktreeListCache = new Map(); -const _worktreeListInflight = new Map>(); +const _worktreeListInflight = new Map }>(); const _worktreeListGeneration = new Map(); const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds @@ -391,7 +391,7 @@ const readProjectWorktrees = async (projectDirectory: string): Promise projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); - const worktrees = await git.worktree.list(projectDirectory).catch(() => []); + const worktrees = await git.worktree.list(projectDirectory); const results: WorktreeMetadata[] = worktrees .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) .map((entry) => { @@ -424,38 +424,48 @@ const readProjectWorktrees = async (projectDirectory: string): Promise => { +const readStableProjectWorktrees = async ( + projectDirectory: string, + minimumGeneration = getWorktreeListGeneration(projectDirectory), +): Promise => { while (true) { const generation = getWorktreeListGeneration(projectDirectory); const worktrees = await readProjectWorktrees(projectDirectory); - if (generation === getWorktreeListGeneration(projectDirectory)) { + if (generation >= minimumGeneration && generation === getWorktreeListGeneration(projectDirectory)) { _worktreeListCache.set(projectDirectory, { value: worktrees, at: Date.now() }); return worktrees; } } }; -export async function listProjectWorktrees(project: ProjectRef): Promise { +export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise { const projectDirectory = normalizePath(project.path); + const force = options?.force === true; + + if (force) { + invalidateWorktreeList(projectDirectory); + } + + const generation = getWorktreeListGeneration(projectDirectory); // Return cached if fresh const cached = _worktreeListCache.get(projectDirectory); - if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { + if (!force && cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { return cached.value; } // Dedup in-flight requests const inflight = _worktreeListInflight.get(projectDirectory); - if (inflight) return inflight; + if (inflight && inflight.generation === generation) return inflight.promise; - const promise = readStableProjectWorktrees(projectDirectory).finally(() => { - if (_worktreeListInflight.get(projectDirectory) === promise) { + const promise = readStableProjectWorktrees(projectDirectory, generation).finally(() => { + if (_worktreeListInflight.get(projectDirectory)?.promise === promise) { _worktreeListInflight.delete(projectDirectory); } }); - _worktreeListInflight.set(projectDirectory, promise); + _worktreeListInflight.set(projectDirectory, { generation, promise }); return promise; } From 2088216bc2de3482f5200ba6659f8aa281d07315 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Tue, 18 Aug 2026 20:52:36 -0600 Subject: [PATCH 02/14] docs(changelog): add session worktree move --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3325cf..b393a397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ All notable changes to this project will be documented in this file. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). - Desktop/Windows: the close button now aligns correctly with the rest of the window chrome. - Session assist: recaps and suggested follow-ups now work when the Anthropic provider is configured to use a custom endpoint; they previously failed every time instead of using that configured connection. +- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). ## [1.19.0] - 2026-08-19 @@ -107,7 +108,6 @@ All notable changes to this project will be documented in this file. - Usage/Command Code: Command Code plan limits now appear in the Usage page and work status panel. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Git/Worktrees: creating a worktree from a pull request now falls back to GitHub's pull-request reference when the source fork was deleted or cannot be reached, instead of failing before creating the worktree (thanks to @makeittech). -- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech). - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). From 415f443a79d42d0267cef8364fc34816bad2c2b3 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Tue, 18 Aug 2026 20:55:21 -0600 Subject: [PATCH 03/14] fix(sessions): keep worktree menu hook order stable --- .../components/session/sidebar/sessions/SessionNodeItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 9272f8b4..9a3f0240 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -901,7 +901,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode } }; - const handleWorktreeSubmenuOpenChange = React.useCallback((open: boolean) => { + const handleWorktreeSubmenuOpenChange = (open: boolean) => { worktreeSubmenuOpenRef.current = open; worktreeLoadSequenceRef.current += 1; const loadSequence = worktreeLoadSequenceRef.current; @@ -934,7 +934,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode setWorktreeTargetsLoading(false); setWorktreeTargetsLoadFailed(true); }); - }, [currentWorktreeMetadata, projectId, sessionDirectory, startSessionWorktreeMenuLoad]); + }; const renderSessionMenuItems = ({ Item, From 52bea6d1f31a7b62af2e2b09c2a4d2247e813241 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 19 Aug 2026 09:11:40 -0600 Subject: [PATCH 04/14] fix(sessions): harden worktree move recovery --- .../src/components/session/SessionSidebar.tsx | 1 + .../sidebar/sessionWorktreeMenu.test.ts | 59 ++++++ .../session/sidebar/sessionWorktreeMenu.ts | 9 +- .../sidebar/sessions/SessionNodeItem.tsx | 184 ++++++++--------- .../sessions/sessionNodeItemUtils.test.ts | 36 +++- .../sidebar/sessions/sessionNodeItemUtils.ts | 10 + .../lib/worktrees/sessionWorktreeMove.test.ts | 185 +++++++++++++++--- .../src/lib/worktrees/sessionWorktreeMove.ts | 35 +++- .../src/lib/worktrees/worktreeManager.test.ts | 63 ++++++ .../ui/src/lib/worktrees/worktreeManager.ts | 29 ++- 10 files changed, 478 insertions(+), 133 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f3fc8c15..a2f6fe1d 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -564,6 +564,7 @@ const SessionSidebarComponent: React.FC = ({ : (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null); return startSessionWorktreeMenuLoad(args, { projects, + getCurrentProjects: () => useProjectsStore.getState().projects, rawWorktreesByProjectRef, getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject, resolveProject: (directory) => resolveProjectRef(directory), diff --git a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts index 4b87fbea..1821ee3c 100644 --- a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts +++ b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts @@ -197,6 +197,10 @@ describe('startSessionWorktreeMenuLoad', () => { { id: 'linked', path: '/repo-linked' }, { id: 'other', path: '/repo-other' }, ], + getCurrentProjects: () => [ + { id: 'linked', path: '/repo-linked' }, + { id: 'other', path: '/repo-other' }, + ], rawWorktreesByProjectRef: rawRef, getPublishedWorktreesByProject: () => new Map(), resolveProject: () => null, @@ -259,6 +263,7 @@ describe('startSessionWorktreeMenuLoad', () => { }, { projects: [{ id: 'linked', path: '/repo-linked' }], + getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }], rawWorktreesByProjectRef: rawRef, getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]), resolveProject: () => null, @@ -303,6 +308,7 @@ describe('startSessionWorktreeMenuLoad', () => { }, { projects: [{ id: 'linked', path: '/repo-linked' }], + getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }], rawWorktreesByProjectRef: rawRef, getPublishedWorktreesByProject: () => publishedTopology, resolveProject: () => null, @@ -352,6 +358,10 @@ describe('startSessionWorktreeMenuLoad', () => { { id: 'owner', path: '/repo' }, { id: 'linked', path: '/repo-linked' }, ], + getCurrentProjects: () => [ + { id: 'owner', path: '/repo' }, + { id: 'linked', path: '/repo-linked' }, + ], rawWorktreesByProjectRef: rawRef, getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]), resolveProject: () => null, @@ -398,6 +408,7 @@ describe('startSessionWorktreeMenuLoad', () => { }, { projects: [{ id: 'linked', path: '/repo-linked' }], + getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }], rawWorktreesByProjectRef: rawRef, getPublishedWorktreesByProject: () => publishedCurrentRuntime, resolveProject: () => null, @@ -433,6 +444,53 @@ describe('startSessionWorktreeMenuLoad', () => { expect(published).toEqual([]); }); + test('rejects a deferred refresh when the owning project is removed before commit', async () => { + const refreshDeferred = createDeferred(); + const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' }); + const published: Array<{ availableWorktreesByProject: Map }> = []; + const rawRef = rawScope('runtime-1', [ + ['/repo-linked', [existing]], + ]); + let currentProjects = [{ id: 'linked', path: '/repo-linked' }]; + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: currentProjects, + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]), + resolveProject: () => null, + listProjectWorktrees: async () => refreshDeferred.promise, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push({ availableWorktreesByProject: next.availableWorktreesByProject }); + }, + getCurrentProjects: () => currentProjects, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + currentProjects = []; + refreshDeferred.resolve([ + worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }), + ]); + + const refreshError = await load.refreshTargets.catch((error) => error); + + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('Project removed during worktree refresh'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]); + expect(published).toEqual([]); + }); + test('falls back to resolving the owning configured project from the source directory when projectId is missing', async () => { const calls: string[] = []; const load = startSessionWorktreeMenuLoad( @@ -443,6 +501,7 @@ describe('startSessionWorktreeMenuLoad', () => { }, { projects: [{ id: 'owner', path: '/repo' }], + getCurrentProjects: () => [{ id: 'owner', path: '/repo' }], rawWorktreesByProjectRef: rawScope('runtime-1', []), getPublishedWorktreesByProject: () => new Map(), resolveProject: (directory) => { diff --git a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts index 081cd376..60512ad8 100644 --- a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts +++ b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts @@ -26,6 +26,7 @@ type SessionWorktreeMenuState = { type StartSessionWorktreeMenuLoadDependencies = { projects: ReadonlyArray; + getCurrentProjects: () => ReadonlyArray; rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; getPublishedWorktreesByProject: () => Map; resolveProject: (directory: string) => ProjectRef | null; @@ -285,6 +286,12 @@ export const startSessionWorktreeMenuLoad = ( throw new Error('Runtime changed during worktree refresh'); } + const currentProjects = deps.getCurrentProjects(); + const currentProject = currentProjects.find((candidate) => candidate.id === project.id) ?? null; + if (!currentProject || normalizePath(currentProject.path ?? null) !== normalizedProjectPath) { + throw new Error('Project removed during worktree refresh'); + } + const currentRawScope = ensureRawWorktreesByProjectScope({ rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef, publishedWorktreesByProject: deps.getPublishedWorktreesByProject(), @@ -327,7 +334,7 @@ export const startSessionWorktreeMenuLoad = ( worktreesByProject: nextRawTopology, }; - const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(deps.projects, nextRawTopology); + const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(currentProjects, nextRawTopology); const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now()); diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 9a3f0240..5cb49006 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount import { useSessionMessageRecordsForExport } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from '../folders/sessionFolderDnd'; -import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; import type { SessionNode } from '../types'; import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -993,102 +993,106 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {t('sessions.sidebar.session.menu.exportMarkdown')} {!isSubtaskSession && !archivedBucket && !isVSCode ? (() => { + const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({ + sessionDirectory, + isStreaming, + isMovingToWorktree, + }); const worktreeMenuState = getSessionWorktreeMenuState({ targets: worktreeTargets, isRefreshing: worktreeTargetsLoading, loadFailed: worktreeTargetsLoadFailed, }); return ( - - - - - - - {t('sessions.sidebar.session.menu.moveToWorktreeTargets')} - - - {worktreeTargets.map((target) => { - const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path; - const itemLabel = target.isPrimary - ? t('sessions.sidebar.session.moveToWorktree.main') - : (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path); - const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready'; + + + + + + {t('sessions.sidebar.session.menu.moveToWorktreeTargets')} + + + + {isMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isStreaming + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltipTargets')} + + + + {worktreeTargets.map((target) => { + const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path; + const itemLabel = target.isPrimary + ? t('sessions.sidebar.session.moveToWorktree.main') + : (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path); + const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready'; - return ( - { - if (isDisabled || !sessionDirectory) { - return; - } - startSessionTreeExistingWorktreeMove({ - root: resolvedSession, - descendants: collectNodeDescendantSessions(node), - sourceDirectory: sessionDirectory, - destination: target.metadata, - successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), - failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'), - }); - }} - > - - {itemLabel} - {target.isCurrent ? {t('sessions.sidebar.session.moveToWorktree.current')} : null} - - {target.isCurrent ? - ); - })} - {worktreeMenuState.refreshState === 'loading' ? ( - - {t('sessions.sidebar.session.moveToWorktree.refreshing')} - - ) : null} - {worktreeMenuState.refreshState === 'error' ? ( - - {t('sessions.sidebar.session.moveToWorktree.loadFailed')} - - ) : null} - - {worktreeMenuState.showNewWorktreeAction ? ( - { - 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="[&>svg]:mr-1" - > - - {t('sessions.sidebar.session.menu.newWorktree')} - - ) : null} - - - - - - {isMovingToWorktree - ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') - : isStreaming - ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') - : t('sessions.sidebar.session.moveToWorktree.tooltipTargets')} - - + return ( + { + if (isDisabled || !sessionDirectory) { + return; + } + startSessionTreeExistingWorktreeMove({ + root: resolvedSession, + descendants: collectNodeDescendantSessions(node), + sourceDirectory: sessionDirectory, + destination: target.metadata, + successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'), + }); + }} + > + + {itemLabel} + {target.isCurrent ? {t('sessions.sidebar.session.moveToWorktree.current')} : null} + + {target.isCurrent ? + ); + })} + {worktreeMenuState.refreshState === 'loading' ? ( + + {t('sessions.sidebar.session.moveToWorktree.refreshing')} + + ) : null} + {worktreeMenuState.refreshState === 'error' ? ( + + {t('sessions.sidebar.session.moveToWorktree.loadFailed')} + + ) : null} + + {worktreeMenuState.showNewWorktreeAction ? ( + { + if (isWorktreeMenuDisabled || !sessionDirectory) return; + startSessionTreeWorktreeMove({ + root: resolvedSession, + descendants: collectNodeDescendantSessions(node), + sourceDirectory: sessionDirectory, + successMessage: t('sessions.sidebar.session.moveToWorktree.success'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), + }); + }} + className="[&>svg]:mr-1" + > + + {t('sessions.sidebar.session.menu.newWorktree')} + + ) : null} + + ); })() : null} {isMultiRunLikeSession ? ( diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts index 27ff998b..07c136b5 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; -import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { + computeNodeStructureKey, + getSessionWorktreeMenuDisabled, + nodeHasPinnedMembershipChange, + selectFolderRootNodes, + selectQuestionBadgeSessionScopes, +} from './sessionNodeItemUtils'; import type { SessionNode } from '../types'; const session = (id: string, title: string): Session => ({ @@ -158,3 +164,31 @@ describe('selectFolderRootNodes', () => { expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]); }); }); + +describe('getSessionWorktreeMenuDisabled', () => { + test('shares the parent trigger disabled contract with the new worktree action', () => { + expect(getSessionWorktreeMenuDisabled({ + sessionDirectory: '/repo-feature', + isStreaming: false, + isMovingToWorktree: false, + })).toBe(false); + + expect(getSessionWorktreeMenuDisabled({ + sessionDirectory: null, + isStreaming: false, + isMovingToWorktree: false, + })).toBe(true); + + expect(getSessionWorktreeMenuDisabled({ + sessionDirectory: '/repo-feature', + isStreaming: true, + isMovingToWorktree: false, + })).toBe(true); + + expect(getSessionWorktreeMenuDisabled({ + sessionDirectory: '/repo-feature', + isStreaming: false, + isMovingToWorktree: true, + })).toBe(true); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts index f2c40e5c..631faad3 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts @@ -78,6 +78,16 @@ export type QuestionBadgeSessionScope = { sessionIDs: string[]; }; +export const getSessionWorktreeMenuDisabled = ({ + sessionDirectory, + isStreaming, + isMovingToWorktree, +}: { + sessionDirectory: string | null; + isStreaming: boolean; + isMovingToWorktree: boolean; +}): boolean => !sessionDirectory || isStreaming || isMovingToWorktree; + /** * Choose which (directory, sessionIDs) scopes a sidebar row's pending-question * badge should count. An expanded row counts only its own session; a collapsed diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 14abe32e..a3f82e3e 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -11,11 +11,10 @@ const moveCalls: Array<{ moveChanges: boolean; }> = []; const refreshCalls: string[][] = []; -type RemoveProjectWorktreeOptions = { deleteLocalBranch: boolean }; type RemoveProjectWorktreeCall = { - project: ProjectRef; - worktree: WorktreeMetadata; - options: RemoveProjectWorktreeOptions; + projectDirectory: string; + directory: string; + deleteLocalBranch: boolean; }; type MoveSessionImplementation = ( session: Session, @@ -37,15 +36,38 @@ type DeferredVoid = { resolve: () => void; reject: (error: Error) => void; }; +type IncompleteRollbackCause = { + moveError: Error; + rollbackFailures: Array<{ sessionId: string; error: Error }>; +}; const removeWorktreeCalls: RemoveProjectWorktreeCall[] = []; const metadataWrites: Array<{ sessionId: string; metadata: WorktreeMetadata | null }> = []; -const latestMetadataInputs: WorktreeMetadata[] = []; const toastSuccesses: string[] = []; const toastErrors: Array<{ title: string; description?: string }> = []; const directoryStates = new Map(); const storedMetadata = new Map(); const originalConsoleWarn = console.warn; +type SessionUIState = { + availableWorktrees: WorktreeMetadata[]; + availableWorktreesByProject: Map; + worktreeMetadata: Map; + getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | null; + setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void; +}; + +type SessionUIStatePatch = Partial | ((state: SessionUIState) => Partial); + +const sessionUIState: SessionUIState = { + availableWorktrees: [], + availableWorktreesByProject: new Map(), + worktreeMetadata: new Map(), + getWorktreeMetadata: (sessionId: string) => storedMetadata.get(sessionId) ?? null, + setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { + storedMetadata.set(sessionId, metadata); + metadataWrites.push({ sessionId, metadata }); + }, +}; let moveSessionImplementation: MoveSessionImplementation = async () => {}; let refreshImplementation: RefreshImplementation = async () => {}; @@ -74,6 +96,26 @@ mock.module('@/components/ui', () => ({ mock.module('@/lib/gitApi', () => ({ getGitStatus: mock(() => Promise.resolve({ current: 'feature' })), + deleteRemoteBranch: mock(), + git: { + worktree: { + list: mock(() => Promise.resolve([])), + create: mock(() => Promise.resolve(null)), + validate: mock(() => Promise.resolve({ ok: true, errors: [] })), + remove: mock((projectDirectory: string, options: { directory: string; deleteLocalBranch?: boolean }) => { + removeWorktreeCalls.push({ + projectDirectory, + directory: options.directory, + deleteLocalBranch: options.deleteLocalBranch === true, + }); + return Promise.resolve({ success: true }); + }), + }, + }, +})); + +mock.module('@/lib/openchamberConfig', () => ({ + substituteCommandVariables: (command: string) => command, })); mock.module('@/lib/worktreeSessionCreator', () => ({ @@ -83,17 +125,15 @@ mock.module('@/lib/worktreeSessionCreator', () => ({ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ waitForWorktreeGitReady: mock((directory: string) => waitForWorktreeGitReadyImplementation(directory)), + clearWorktreeBootstrapState: mock(), + markWorktreeBootstrapPending: mock(), + setWorktreeBootstrapState: mock(), + startWorktreeBootstrapWatcher: mock(), })); -mock.module('@/lib/worktrees/worktreeManager', () => ({ - getLatestWorktreeMetadata: (metadata: WorktreeMetadata) => { - latestMetadataInputs.push(metadata); - return latestMetadataResult; - }, - removeProjectWorktree: (project: ProjectRef, worktree: WorktreeMetadata, options: RemoveProjectWorktreeOptions) => { - removeWorktreeCalls.push({ project, worktree, options }); - return Promise.resolve(); - }, +mock.module('@/lib/worktrees/worktreeStatus', () => ({ + invalidateResolvedProjectRootCache: mock(), + resolveProjectRoot: (directory: string) => Promise.resolve(directory), })); mock.module('@/stores/useGlobalSessionsStore', () => ({ @@ -112,15 +152,17 @@ mock.module('@/sync/session-actions', () => ({ mock.module('@/sync/session-ui-store', () => ({ useSessionUIStore: { - getState: () => ({ - availableWorktrees: [], - availableWorktreesByProject: new Map(), - getWorktreeMetadata: (sessionId: string) => storedMetadata.get(sessionId) ?? null, - setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { - storedMetadata.set(sessionId, metadata); - metadataWrites.push({ sessionId, metadata }); - }, - }), + getState: () => sessionUIState, + setState: (patch: SessionUIStatePatch) => { + const next = patch instanceof Function ? patch(sessionUIState) : patch; + Object.assign(sessionUIState, next); + }, + }, +})); + +mock.module('@/sync/session-worktree-store', () => ({ + useSessionWorktreeStore: { + setState: mock(), }, })); @@ -193,18 +235,54 @@ const deferred = (): DeferredVoid => { return { promise, resolve, reject }; }; +const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => { + const cause = error.cause; + if (!cause || !(cause instanceof Object)) { + throw new Error('Expected rollback error cause details'); + } + + const parsed = cause as Partial; + if (!(parsed.moveError instanceof Error)) { + throw new Error('Expected rollback moveError cause'); + } + if (!Array.isArray(parsed.rollbackFailures)) { + throw new Error('Expected rollback failures in cause'); + } + + const rollbackFailures = parsed.rollbackFailures.map((entry) => { + if (!entry || !(entry instanceof Object)) { + throw new Error('Expected rollback failure entry'); + } + const failure = entry as { sessionId?: unknown; error?: unknown }; + if (typeof failure.sessionId !== 'string') { + throw new Error('Expected rollback failure session ID'); + } + if (!(failure.error instanceof Error)) { + throw new Error('Expected rollback failure error'); + } + return { sessionId: failure.sessionId, error: failure.error }; + }); + + return { + moveError: parsed.moveError, + rollbackFailures, + }; +}; + describe('moveSessionTreeToExistingWorktree', () => { beforeEach(() => { moveCalls.length = 0; refreshCalls.length = 0; removeWorktreeCalls.length = 0; metadataWrites.length = 0; - latestMetadataInputs.length = 0; toastSuccesses.length = 0; toastErrors.length = 0; directoryStates.clear(); storedMetadata.clear(); + sessionUIState.worktreeMetadata = new Map(); + sessionUIState.availableWorktreesByProject = new Map(); latestMetadataResult = makeWorktreeMetadata({ label: 'Latest destination' }); + sessionUIState.availableWorktrees = [latestMetadataResult]; moveSessionImplementation = async () => {}; refreshImplementation = async () => {}; createQuickWorktreeImplementation = async () => makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session' }); @@ -243,7 +321,6 @@ describe('moveSessionTreeToExistingWorktree', () => { { sessionId: 'root', metadata: latestMetadataResult }, { sessionId: 'child', metadata: latestMetadataResult }, ]); - expect(latestMetadataInputs).toEqual([destination, destination]); expect(refreshCalls).toEqual([['/source', '/destination']]); expect(removeWorktreeCalls).toEqual([]); }); @@ -390,6 +467,40 @@ describe('moveSessionTreeToExistingWorktree', () => { } }; + const error = await moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }).catch((rejection) => rejection); + + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message.includes('could not be fully rolled back')).toBe(true); + const cause = getIncompleteRollbackCause(error); + expect(cause.moveError.message).toBe('child failed'); + expect(cause.rollbackFailures).toEqual([{ sessionId: 'root', error: new Error('rollback failed') }]); + + expect(removeWorktreeCalls).toEqual([]); + }); + + const expectBusyOrRetryRollbackBlock = async (status: Extract): Promise => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'idle' }); + setStatuses('/destination', {}); + moveSessionImplementation = async (session, sourceDirectory) => { + if (sourceDirectory === '/source' && session.id === 'root') { + setStatuses('/destination', { root: status }); + return; + } + if (sourceDirectory === '/source' && session.id === 'child') { + throw new Error('child failed'); + } + }; + await expect(moveSessionTreeToExistingWorktree({ root, descendants: [child], @@ -397,7 +508,19 @@ describe('moveSessionTreeToExistingWorktree', () => { destination: makeWorktreeMetadata(), })).rejects.toThrow('could not be fully rolled back'); + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + ]); expect(removeWorktreeCalls).toEqual([]); + }; + + test('does not attempt rollback for a moved root that becomes busy in the destination', async () => { + await expectBusyOrRetryRollbackBlock('busy'); + }); + + test('does not attempt rollback for a moved root that becomes retry in the destination', async () => { + await expectBusyOrRetryRollbackBlock('retry'); }); test('keeps the move successful when the post-move refresh fails', async () => { @@ -435,9 +558,9 @@ describe('moveSessionTreeToExistingWorktree', () => { await waitFor(() => toastErrors.length === 1); expect(toastErrors).toEqual([{ title: 'failed', description: 'git-ready failed' }]); expect(removeWorktreeCalls).toEqual([{ - project: { id: 'project-1', path: '/repo' }, - worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }), - options: { deleteLocalBranch: true }, + projectDirectory: '/repo', + directory: '/created-worktree', + deleteLocalBranch: true, }]); expect(moveCalls).toEqual([]); }); @@ -458,9 +581,9 @@ describe('moveSessionTreeToExistingWorktree', () => { await waitFor(() => toastErrors.length === 1); expect(removeWorktreeCalls).toEqual([{ - project: { id: 'project-1', path: '/repo' }, - worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }), - options: { deleteLocalBranch: true }, + projectDirectory: '/repo', + directory: '/created-worktree', + deleteLocalBranch: true, }]); expect(moveCalls).toEqual([]); }); diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index e0b679b1..0aa5499c 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -61,15 +61,39 @@ const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void if (hasActiveSession) throw new Error('Session is not idle'); }; +type RollbackFailure = { + sessionId: string; + error: Error; +}; + +const createIncompleteRollbackError = (moveError: Error, rollbackFailures: RollbackFailure[]): Error => { + const rollbackSummary = rollbackFailures + .map(({ sessionId, error }) => `${sessionId}: ${error.message}`) + .join(', '); + return new Error( + `Session move partially failed and could not be fully rolled back: ${moveError.message}. Rollback failures: ${rollbackSummary}`, + { cause: { moveError, rollbackFailures } }, + ); +}; + +const isSessionBusyOrRetrying = (session: Session, directory: string): boolean => { + const status = getDirectoryState(directory)?.session_status[session.id]?.type; + return status === 'busy' || status === 'retry'; +}; + const rollbackMovedSessions = async ( sessions: Session[], rootSessionId: string, sourceDirectory: string, worktreeDirectory: string, previousMetadata: ReadonlyMap, -): Promise => { - const failures: unknown[] = []; +): Promise => { + const failures: RollbackFailure[] = []; for (const session of [...sessions].reverse()) { + if (isSessionBusyOrRetrying(session, worktreeDirectory)) { + failures.push({ sessionId: session.id, error: new Error('Session is not idle') }); + continue; + } try { await moveSessionToDirectory( session, @@ -79,7 +103,10 @@ const rollbackMovedSessions = async ( ); useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null); } catch (error) { - failures.push(error); + failures.push({ + sessionId: session.id, + error: error instanceof Error ? error : new Error(String(error)), + }); } } return failures; @@ -149,7 +176,7 @@ const moveSessionTreeTransaction = async ( previousMetadata, ); if (rollbackFailures.length > 0) { - throw new Error(`Session move partially failed and could not be fully rolled back: ${moveError.message}`); + throw createIncompleteRollbackError(moveError, rollbackFailures); } if (destination?.onMoveFailure) { return destination.onMoveFailure(moveError); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index eeed16c9..6a8698cc 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -12,6 +12,7 @@ type WorktreeListEntry = { const listCalls: string[] = []; const listResolvers: Array<(value: WorktreeListEntry[]) => void> = []; const listRejecters: Array<(reason: Error) => void> = []; +let listImplementation: ((directory: string) => Promise) | undefined; const createPayloads: unknown[] = []; const validatePayloads: unknown[] = []; const createdWorktree = { @@ -79,6 +80,9 @@ mock.module('@/lib/gitApi', () => ({ worktree: { list: (directory: string) => { listCalls.push(directory); + if (listImplementation) { + return listImplementation(directory); + } return new Promise((resolve, reject) => { listResolvers.push(resolve); listRejecters.push((reason: Error) => reject(reason)); @@ -121,6 +125,7 @@ describe('worktreeManager list invalidation', () => { listCalls.length = 0; listResolvers.length = 0; listRejecters.length = 0; + listImplementation = undefined; createPayloads.length = 0; validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; @@ -234,6 +239,64 @@ describe('worktreeManager list invalidation', () => { await expect(listing).rejects.toThrow('git failed'); }); + test('rejects sustained invalidation explicitly, preserves the last cached result, and allows a later retry', async () => { + const project = { id: 'project-force-convergence', path: '/repo-force-convergence' }; + const oldWorktree = [{ path: '/repo-old', branch: 'old', name: 'old' } satisfies WorktreeListEntry]; + const scriptedResolvers = new Map void>(); + let recoveryReadsAllowed = false; + + listImplementation = () => { + const callNumber = listCalls.length; + if (callNumber === 8 && !recoveryReadsAllowed) { + return Promise.reject(new Error('unexpected extra read')); + } + return new Promise((resolve) => { + scriptedResolvers.set(callNumber, resolve); + }); + }; + + const seededListing = listProjectWorktrees(project); + await waitForListCallCount(1); + scriptedResolvers.get(1)?.(oldWorktree); + expect((await seededListing).map((entry) => entry.path)).toEqual(['/repo-old']); + + const unstableListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + + const forcedRefreshA = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(3); + scriptedResolvers.get(3)?.([createdWorktree]); + expect((await forcedRefreshA).map((entry) => entry.path)).toEqual(['/repo-feature']); + scriptedResolvers.get(2)?.([{ path: '/repo-stale-a', branch: 'stale-a', name: 'stale-a' }]); + await waitForListCallCount(4); + + const forcedRefreshB = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(5); + scriptedResolvers.get(5)?.([createdWorktree]); + expect((await forcedRefreshB).map((entry) => entry.path)).toEqual(['/repo-feature']); + scriptedResolvers.get(4)?.([{ path: '/repo-stale-b', branch: 'stale-b', name: 'stale-b' }]); + await waitForListCallCount(6); + + const forcedRefreshC = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(7); + scriptedResolvers.get(7)?.([createdWorktree]); + expect((await forcedRefreshC).map((entry) => entry.path)).toEqual(['/repo-feature']); + scriptedResolvers.get(6)?.([{ path: '/repo-stale-c', branch: 'stale-c', name: 'stale-c' }]); + + await expect(unstableListing).rejects.toThrow('Worktree list did not converge'); + expect(listCalls).toHaveLength(7); + + const cachedResult = await listProjectWorktrees(project); + expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toHaveLength(7); + + recoveryReadsAllowed = true; + const recoveredListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(8); + scriptedResolvers.get(8)?.([createdWorktree]); + expect((await recoveredListing).map((entry) => entry.path)).toEqual(['/repo-feature']); + }); + test('marks fast-created worktrees pending until bootstrap settles', async () => { const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, { preferredName: 'feature', diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 50016065..67cf5235 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -377,6 +377,7 @@ const _worktreeListCache = new Map }>(); const _worktreeListGeneration = new Map(); const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds +const WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS = 3; const getWorktreeListGeneration = (projectDirectory: string): number => { return _worktreeListGeneration.get(projectDirectory) ?? 0; @@ -428,7 +429,7 @@ const readStableProjectWorktrees = async ( projectDirectory: string, minimumGeneration = getWorktreeListGeneration(projectDirectory), ): Promise => { - while (true) { + for (let attempt = 0; attempt < WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS; attempt += 1) { const generation = getWorktreeListGeneration(projectDirectory); const worktrees = await readProjectWorktrees(projectDirectory); @@ -437,11 +438,16 @@ const readStableProjectWorktrees = async ( return worktrees; } } + + throw new Error( + `Worktree list did not converge after ${WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS} attempts` + ); }; export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise { const projectDirectory = normalizePath(project.path); const force = options?.force === true; + const previousCache = force ? _worktreeListCache.get(projectDirectory) : undefined; if (force) { invalidateWorktreeList(projectDirectory); @@ -459,11 +465,22 @@ export async function listProjectWorktrees(project: ProjectRef, options?: { forc const inflight = _worktreeListInflight.get(projectDirectory); if (inflight && inflight.generation === generation) return inflight.promise; - const promise = readStableProjectWorktrees(projectDirectory, generation).finally(() => { - if (_worktreeListInflight.get(projectDirectory)?.promise === promise) { - _worktreeListInflight.delete(projectDirectory); - } - }); + const promise = readStableProjectWorktrees(projectDirectory, generation) + .catch((error) => { + if ( + previousCache + && !_worktreeListCache.has(projectDirectory) + && getWorktreeListGeneration(projectDirectory) === generation + ) { + _worktreeListCache.set(projectDirectory, previousCache); + } + throw error; + }) + .finally(() => { + if (_worktreeListInflight.get(projectDirectory)?.promise === promise) { + _worktreeListInflight.delete(projectDirectory); + } + }); _worktreeListInflight.set(projectDirectory, { generation, promise }); return promise; From 285bb35224897dab5c913aacb85335ec35b963e2 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 19 Aug 2026 12:01:16 -0600 Subject: [PATCH 05/14] fix(sessions): recheck idle tree during worktree move --- .../lib/worktrees/sessionWorktreeMove.test.ts | 44 +++++++++++++++++++ .../src/lib/worktrees/sessionWorktreeMove.ts | 7 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index a3f82e3e..59084db1 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -454,6 +454,50 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(refreshCalls).toEqual([]); }); + test('rolls back the root and never moves a child that becomes busy after the root move starts', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + const rootMove = deferred(); + const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); + const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' }); + setStatuses('/source', { root: 'idle', child: 'idle' }); + setStatuses('/destination', {}); + storedMetadata.set(root.id, previousRootMetadata); + storedMetadata.set(child.id, previousChildMetadata); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + return rootMove.promise; + } + }; + + const movePromise = moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }); + + await waitFor(() => moveCalls.length === 1); + setStatuses('/source', { root: 'idle', child: 'busy' }); + setStatuses('/destination', { root: 'idle' }); + rootMove.resolve(); + + await expect(movePromise).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, + ]); + expect(metadataWrites).toEqual([ + { sessionId: 'root', metadata: latestMetadataResult }, + { sessionId: 'root', metadata: previousRootMetadata }, + ]); + expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); + expect(storedMetadata.get(child.id)).toBe(previousChildMetadata); + expect(removeWorktreeCalls).toEqual([]); + expect(refreshCalls).toEqual([]); + }); + test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => { const root = makeSession('root'); const child = makeSession('child'); diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 0aa5499c..35da12d9 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -156,10 +156,11 @@ const moveSessionTreeTransaction = async ( const moved: Session[] = []; try { destination = await prepareDestination(); - // Setup can take long enough for one of the sessions to start running, so - // verify the whole tree again immediately before the first move. - assertSessionsIdle(sessions, input.sourceDirectory); for (const [index, session] of sessions.entries()) { + // Setup and earlier moves can take long enough for a not-yet-moved + // descendant to start running, so re-check the remaining source tree + // immediately before each move. + assertSessionsIdle(sessions.slice(index), input.sourceDirectory); // Transfer the checkout changes once with the root. Descendants only // need their execution location updated. await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, index === 0); From c75c7809df7154ad7f269e7d1bfd120427af8c59 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 19 Aug 2026 12:19:04 -0600 Subject: [PATCH 06/14] test(sessions): complete global sessions store mock --- packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 59084db1..8a0beb3a 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -137,6 +137,10 @@ mock.module('@/lib/worktrees/worktreeStatus', () => ({ })); mock.module('@/stores/useGlobalSessionsStore', () => ({ + resolveGlobalSessionDirectory: (session: Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; + }) => session.directory ?? session.project?.worktree ?? null, refreshGlobalSessionsForDirectories: (directories: string[]) => { refreshCalls.push(directories); return refreshImplementation(directories); From 0d4b3f036a0b5aae55bd03e0a3658cb37d91765e Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 20 Aug 2026 12:55:25 -0600 Subject: [PATCH 07/14] fix(sessions): require explicit worktree change transfer --- .../lib/worktrees/sessionWorktreeMove.test.ts | 392 +++++++++++++++++- .../src/lib/worktrees/sessionWorktreeMove.ts | 227 ++++++++-- 2 files changed, 588 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 8a0beb3a..3e61b4ec 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -3,6 +3,7 @@ import type { Session, SessionStatus } from '@opencode-ai/sdk/v2'; import type { State } from '@/sync/types'; import type { WorktreeMetadata } from '@/types/worktree'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import type { SessionTreeMoveIntent, SessionTreeMoveMessages } from './sessionWorktreeMove'; const moveCalls: Array<{ sessionId: string; @@ -24,6 +25,11 @@ type MoveSessionImplementation = ( ) => Promise; type RefreshImplementation = (directories: string[]) => Promise; type CreateQuickWorktreeOptions = { preferredName?: string; startRef?: string }; +type GitStatusResult = { + current: string; + isClean: boolean; + files: Array<{ path: string; index: string; working_dir: string }>; +}; type CreateQuickWorktreeImplementation = ( project: ProjectRef, options: CreateQuickWorktreeOptions, @@ -42,6 +48,7 @@ type IncompleteRollbackCause = { }; const removeWorktreeCalls: RemoveProjectWorktreeCall[] = []; +const createQuickWorktreeCalls: Array<{ project: ProjectRef; options: CreateQuickWorktreeOptions }> = []; const metadataWrites: Array<{ sessionId: string; metadata: WorktreeMetadata | null }> = []; const toastSuccesses: string[] = []; const toastErrors: Array<{ title: string; description?: string }> = []; @@ -72,6 +79,18 @@ const sessionUIState: SessionUIState = { let moveSessionImplementation: MoveSessionImplementation = async () => {}; let refreshImplementation: RefreshImplementation = async () => {}; let latestMetadataResult: WorktreeMetadata; +let isGitRepositoryImplementation = async (directory: string): Promise => { + void directory; + return true; +}; +let getGitStatusImplementation = async (directory: string): Promise => { + void directory; + return { + current: 'feature', + isClean: true, + files: [], + }; +}; let createQuickWorktreeImplementation: CreateQuickWorktreeImplementation = async () => ({ path: '/created-worktree', projectDirectory: '/repo', @@ -95,7 +114,8 @@ mock.module('@/components/ui', () => ({ })); mock.module('@/lib/gitApi', () => ({ - getGitStatus: mock(() => Promise.resolve({ current: 'feature' })), + checkIsGitRepository: (directory: string) => isGitRepositoryImplementation(directory), + getGitStatus: (directory: string) => getGitStatusImplementation(directory), deleteRemoteBranch: mock(), git: { worktree: { @@ -119,7 +139,10 @@ mock.module('@/lib/openchamberConfig', () => ({ })); mock.module('@/lib/worktreeSessionCreator', () => ({ - createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => createQuickWorktreeImplementation(project, options)), + createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => { + createQuickWorktreeCalls.push({ project, options }); + return createQuickWorktreeImplementation(project, options); + }), resolveProjectRef: mock((directory: string) => resolveProjectRefImplementation(directory)), })); @@ -176,6 +199,11 @@ mock.module('@/sync/sync-refs', () => ({ const { moveSessionTreeToExistingWorktree, + requestSessionTreeMove, + confirmSessionTreeMove, + cancelSessionTreeMove, + useSessionTreeMoveConfirmation, + getSessionTreeMoveConfirmation, startSessionTreeWorktreeMove, } = await import('./sessionWorktreeMove'); @@ -202,6 +230,21 @@ const makeWorktreeMetadata = (overrides: Partial = {}): Worktr ...overrides, }); +const makeMoveMessages = (): SessionTreeMoveMessages => ({ + success: 'move succeeded', + failure: 'move failed', + sourceVerificationFailed: 'source verification failed', + applyChangesFailed: 'apply changes failed', +}); + +const makeQuickIntent = (): SessionTreeMoveIntent => ({ + kind: 'quick', + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + messages: makeMoveMessages(), +}); + const makeSessionStatus = (type: SessionStatus['type']): SessionStatus => { switch (type) { case 'busy': @@ -245,6 +288,8 @@ const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => { throw new Error('Expected rollback error cause details'); } + // SAFETY: createIncompleteRollbackError in the module under test attaches + // this exact cause shape when rollback reporting fails. const parsed = cause as Partial; if (!(parsed.moveError instanceof Error)) { throw new Error('Expected rollback moveError cause'); @@ -257,10 +302,9 @@ const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => { if (!entry || !(entry instanceof Object)) { throw new Error('Expected rollback failure entry'); } - const failure = entry as { sessionId?: unknown; error?: unknown }; - if (typeof failure.sessionId !== 'string') { - throw new Error('Expected rollback failure session ID'); - } + // SAFETY: the same helper populates every rollback entry with a string ID + // and Error instance before this test helper reads it back. + const failure = entry as { sessionId: string; error: Error }; if (!(failure.error instanceof Error)) { throw new Error('Expected rollback failure error'); } @@ -275,9 +319,11 @@ const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => { describe('moveSessionTreeToExistingWorktree', () => { beforeEach(() => { + cancelSessionTreeMove(); moveCalls.length = 0; refreshCalls.length = 0; removeWorktreeCalls.length = 0; + createQuickWorktreeCalls.length = 0; metadataWrites.length = 0; toastSuccesses.length = 0; toastErrors.length = 0; @@ -289,6 +335,12 @@ describe('moveSessionTreeToExistingWorktree', () => { sessionUIState.availableWorktrees = [latestMetadataResult]; moveSessionImplementation = async () => {}; refreshImplementation = async () => {}; + isGitRepositoryImplementation = async () => true; + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: true, + files: [], + }); createQuickWorktreeImplementation = async () => makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session' }); resolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' }); waitForWorktreeGitReadyImplementation = async () => {}; @@ -314,6 +366,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [child], sourceDirectory: '/source', destination, + moveChanges: true, }); expect(result).toBe('/destination'); @@ -337,6 +390,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [], sourceDirectory: '/source/', destination: makeWorktreeMetadata({ path: '/source' }), + moveChanges: true, })).rejects.toThrow('Source and destination are the same'); expect(moveCalls).toEqual([]); @@ -351,6 +405,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [], sourceDirectory: '/source', destination: makeWorktreeMetadata({ worktreeStatus: 'pending' }), + moveChanges: true, })).rejects.toThrow('Destination worktree is not ready'); expect(moveCalls).toEqual([]); @@ -364,6 +419,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, })).rejects.toThrow('Session is not idle'); expect(moveCalls).toEqual([]); @@ -379,6 +435,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [child], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, })).rejects.toThrow('Session is not idle'); expect(moveCalls).toEqual([]); @@ -399,6 +456,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, }); await waitFor(() => moveCalls.length === 1); @@ -407,6 +465,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, })).rejects.toThrow('Session move already in progress'); rootMove.resolve(); @@ -436,6 +495,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [childA, childB], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, })).rejects.toThrow('child-b failed'); expect(moveCalls).toEqual([ @@ -479,6 +539,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [child], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, }); await waitFor(() => moveCalls.length === 1); @@ -520,6 +581,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [child], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, }).catch((rejection) => rejection); expect(error).toBeInstanceOf(Error); @@ -554,6 +616,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [child], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, })).rejects.toThrow('could not be fully rolled back'); expect(moveCalls).toEqual([ @@ -583,6 +646,7 @@ describe('moveSessionTreeToExistingWorktree', () => { descendants: [], sourceDirectory: '/source', destination: makeWorktreeMetadata(), + moveChanges: true, }); expect(result).toBe('/destination'); @@ -636,6 +700,322 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(moveCalls).toEqual([]); }); + test('moves a clean existing-worktree request without transferring source changes', async () => { + setStatuses('/source', { root: 'idle' }); + expect(useSessionTreeMoveConfirmation).toBeDefined(); + + requestSessionTreeMove({ + kind: 'existing', + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + messages: makeMoveMessages(), + }); + + await waitFor(() => moveCalls.length === 1); + expect(moveCalls).toEqual([{ + sessionId: 'root', + sourceDirectory: '/source', + destinationDirectory: '/destination', + moveChanges: false, + }]); + expect(getSessionTreeMoveConfirmation()).toBeNull(); + }); + + test('waits for a dirty-source choice before preparing a quick worktree', async () => { + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [ + { path: 'staged.ts', index: 'M', working_dir: ' ' }, + { path: 'working.ts', index: ' ', working_dir: 'M' }, + ], + }); + + requestSessionTreeMove(makeQuickIntent()); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + + expect(getSessionTreeMoveConfirmation()).toEqual({ + intent: makeQuickIntent(), + dirtyFileCount: 2, + stagedFileCount: 1, + }); + expect(createQuickWorktreeCalls).toEqual([]); + expect(moveCalls).toEqual([]); + }); + + test('moves a non-Git source without checking status or transferring source changes', async () => { + setStatuses('/source', { root: 'idle' }); + isGitRepositoryImplementation = async () => false; + let statusCallCount = 0; + getGitStatusImplementation = async () => { + statusCallCount += 1; + return { + current: 'feature', + isClean: true, + files: [], + }; + }; + + requestSessionTreeMove(makeQuickIntent()); + + await waitFor(() => createQuickWorktreeCalls.length === 1); + await waitFor(() => moveCalls.length === 1); + + expect(statusCallCount).toBe(0); + expect(moveCalls).toEqual([{ + sessionId: 'root', + sourceDirectory: '/source', + destinationDirectory: '/created-worktree', + moveChanges: false, + }]); + }); + + test('uses the source verification failure message when the repository check fails', async () => { + isGitRepositoryImplementation = async () => { + throw new Error('repo check failed'); + }; + + requestSessionTreeMove(makeQuickIntent()); + + await waitFor(() => toastErrors.length === 1); + + expect(createQuickWorktreeCalls).toEqual([]); + expect(moveCalls).toEqual([]); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'source verification failed' }]); + }); + + test('uses the source verification failure message when the status check fails', async () => { + getGitStatusImplementation = async () => { + throw new Error('status failed'); + }; + + requestSessionTreeMove(makeQuickIntent()); + + await waitFor(() => toastErrors.length === 1); + + expect(createQuickWorktreeCalls).toEqual([]); + expect(moveCalls).toEqual([]); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'source verification failed' }]); + }); + + test('cancels a pending dirty-source request without starting setup or move', async () => { + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + + requestSessionTreeMove(makeQuickIntent()); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + + cancelSessionTreeMove(); + + expect(getSessionTreeMoveConfirmation()).toBeNull(); + expect(createQuickWorktreeCalls).toEqual([]); + expect(moveCalls).toEqual([]); + }); + + test('confirms session-only mode after a dirty-source request', async () => { + setStatuses('/source', { root: 'idle' }); + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + + requestSessionTreeMove(makeQuickIntent()); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + + confirmSessionTreeMove(false); + + await waitFor(() => moveCalls.length === 1); + + expect(getSessionTreeMoveConfirmation()).toBeNull(); + expect(moveCalls).toEqual([{ + sessionId: 'root', + sourceDirectory: '/source', + destinationDirectory: '/created-worktree', + moveChanges: false, + }]); + }); + + test('confirms all changes for the root but not descendants after a dirty-source request', async () => { + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + setStatuses('/source', { root: 'idle', child: 'idle' }); + + requestSessionTreeMove({ + kind: 'quick', + root: makeSession('root'), + descendants: [makeSession('child')], + sourceDirectory: '/source', + messages: makeMoveMessages(), + }); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + + confirmSessionTreeMove(true); + + await waitFor(() => moveCalls.length === 2); + + expect(getSessionTreeMoveConfirmation()).toBeNull(); + expect(moveCalls).toEqual([ + { + sessionId: 'root', + sourceDirectory: '/source', + destinationDirectory: '/created-worktree', + moveChanges: true, + }, + { + sessionId: 'child', + sourceDirectory: '/source', + destinationDirectory: '/created-worktree', + moveChanges: false, + }, + ]); + }); + + test('does not replace an existing pending dirty-source confirmation', async () => { + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + + requestSessionTreeMove(makeQuickIntent()); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + + const firstConfirmation = getSessionTreeMoveConfirmation(); + requestSessionTreeMove({ + kind: 'existing', + root: makeSession('other-root'), + descendants: [], + sourceDirectory: '/other-source', + destination: makeWorktreeMetadata({ path: '/other-destination' }), + messages: makeMoveMessages(), + }); + + expect(getSessionTreeMoveConfirmation()).toBe(firstConfirmation); + expect(createQuickWorktreeCalls).toEqual([]); + expect(moveCalls).toEqual([]); + }); + + test('uses session-only mode when rolling back a moved root', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'idle' }); + setStatuses('/destination', { root: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'child' && sourceDirectory === '/source') { + throw new Error('child failed'); + } + }; + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + moveChanges: false, + })).rejects.toThrow('child failed'); + + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false }, + ]); + }); + + test('uses all-changes mode when rolling back a moved root after a full transfer', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'idle' }); + setStatuses('/destination', { root: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'child' && sourceDirectory === '/source') { + throw new Error('child failed'); + } + }; + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + moveChanges: true, + })).rejects.toThrow('child failed'); + + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, + ]); + }); + + test('uses actionable apply guidance for explicit transfer failures', async () => { + setStatuses('/source', { root: 'idle' }); + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + const error = Object.assign(new Error('Unable to apply your changes in the destination directory: fix conflicts'), { status: 400 }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + throw error; + } + }; + + requestSessionTreeMove({ + kind: 'existing', + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + messages: makeMoveMessages(), + }); + + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'apply changes failed' }]); + }); + + test('retains other move errors when a 400 failure is not the apply-changes case', async () => { + setStatuses('/source', { root: 'idle' }); + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + const error = Object.assign(new Error('Destination directory belongs to another project'), { status: 400 }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + throw error; + } + }; + + requestSessionTreeMove({ + kind: 'existing', + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + messages: makeMoveMessages(), + }); + + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'Destination directory belongs to another project' }]); + }); + test('surfaces a pre-destination preparation failure without attempting removal', async () => { setStatuses('/source', { root: 'idle' }); resolveProjectRefImplementation = () => null; diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 35da12d9..57f773ca 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -1,6 +1,6 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; -import { getGitStatus } from '@/lib/gitApi'; +import { checkIsGitRepository, getGitStatus } from '@/lib/gitApi'; import { normalizePath } from '@/lib/pathNormalization'; import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator'; import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; @@ -12,12 +12,60 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap'; import { create } from 'zustand'; -const useSessionMoveState = create<{ pendingSessionIds: Set }>(() => ({ +export type SessionTreeMoveMessages = { + success: string; + failure: string; + sourceVerificationFailed: string; + applyChangesFailed: string; +}; + +export type SessionTreeMoveIntent = + | { + kind: 'existing'; + root: Session; + descendants: Session[]; + sourceDirectory: string; + destination: WorktreeMetadata; + messages: SessionTreeMoveMessages; + } + | { + kind: 'quick'; + root: Session; + descendants: Session[]; + sourceDirectory: string; + messages: SessionTreeMoveMessages; + }; + +export type SessionTreeMoveConfirmation = { + intent: SessionTreeMoveIntent; + dirtyFileCount: number; + stagedFileCount: number; +}; + +type SessionMoveState = { + pendingSessionIds: Set; + requestingSessionIds: Set; + confirmation: SessionTreeMoveConfirmation | null; +}; + +const useSessionMoveState = create(() => ({ pendingSessionIds: new Set(), + requestingSessionIds: new Set(), + confirmation: null, })); export const useIsSessionWorktreeMovePending = (sessionId: string): boolean => - useSessionMoveState((state) => state.pendingSessionIds.has(sessionId)); + useSessionMoveState((state) => state.pendingSessionIds.has(sessionId) || state.requestingSessionIds.has(sessionId)); + +export const useSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null => + useSessionMoveState((state) => state.confirmation); + +export const getSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null => + useSessionMoveState.getState().confirmation; + +const setSessionMoveConfirmation = (confirmation: SessionTreeMoveConfirmation | null): void => { + useSessionMoveState.setState((state) => (state.confirmation === confirmation ? state : { ...state, confirmation })); +}; const setSessionMovePending = (sessionId: string, pending: boolean): void => { useSessionMoveState.setState((state) => { @@ -25,10 +73,29 @@ const setSessionMovePending = (sessionId: string, pending: boolean): void => { const pendingSessionIds = new Set(state.pendingSessionIds); if (pending) pendingSessionIds.add(sessionId); else pendingSessionIds.delete(sessionId); - return { pendingSessionIds }; + return { ...state, pendingSessionIds }; }); }; +const setSessionMoveRequesting = (sessionId: string, requesting: boolean): void => { + useSessionMoveState.setState((state) => { + if (state.requestingSessionIds.has(sessionId) === requesting) return state; + const requestingSessionIds = new Set(state.requestingSessionIds); + if (requesting) requestingSessionIds.add(sessionId); + else requestingSessionIds.delete(sessionId); + return { ...state, requestingSessionIds }; + }); +}; + +const APPLY_CHANGES_MESSAGE = 'Unable to apply your changes in the destination directory'; + +const isApplyChangesError = (error: Error): boolean => { + // SAFETY: move failures originate from our own SDK/runtime layer, which may + // attach an optional numeric HTTP status to an Error instance. + const errorWithStatus = error as Error & { status?: number }; + return errorWithStatus.status === 400 && error.message.includes(APPLY_CHANGES_MESSAGE); +}; + const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise => { try { const status = await getGitStatus(directory, { mode: 'light' }); @@ -87,6 +154,7 @@ const rollbackMovedSessions = async ( sourceDirectory: string, worktreeDirectory: string, previousMetadata: ReadonlyMap, + moveChanges: boolean, ): Promise => { const failures: RollbackFailure[] = []; for (const session of [...sessions].reverse()) { @@ -95,12 +163,12 @@ const rollbackMovedSessions = async ( continue; } try { - await moveSessionToDirectory( - session, - worktreeDirectory, - sourceDirectory, - session.id === rootSessionId, - ); + await moveSessionToDirectory( + session, + worktreeDirectory, + sourceDirectory, + session.id === rootSessionId && moveChanges, + ); useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null); } catch (error) { failures.push({ @@ -130,6 +198,7 @@ const moveSessionTreeTransaction = async ( root: Session; descendants: Session[]; sourceDirectory: string; + moveChanges: boolean; }, prepareDestination: () => Promise<{ directory: string; @@ -161,9 +230,12 @@ const moveSessionTreeTransaction = async ( // descendant to start running, so re-check the remaining source tree // immediately before each move. assertSessionsIdle(sessions.slice(index), input.sourceDirectory); - // Transfer the checkout changes once with the root. Descendants only - // need their execution location updated. - await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, index === 0); + await moveSessionToDirectory( + session, + input.sourceDirectory, + destination.directory, + index === 0 && input.moveChanges, + ); moved.push(session); useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata)); } @@ -175,6 +247,7 @@ const moveSessionTreeTransaction = async ( input.sourceDirectory, destination?.directory ?? input.sourceDirectory, previousMetadata, + input.moveChanges, ); if (rollbackFailures.length > 0) { throw createIncompleteRollbackError(moveError, rollbackFailures); @@ -203,6 +276,7 @@ export const moveSessionTreeToExistingWorktree = async (input: { descendants: Session[]; sourceDirectory: string; destination: WorktreeMetadata; + moveChanges: boolean; }): Promise => { const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory; const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path; @@ -223,13 +297,16 @@ const moveSessionTreeToQuickWorktree = async (input: { root: Session; descendants: Session[]; sourceDirectory: string; + moveChanges: boolean; }): Promise => { return moveSessionTreeTransaction(input, async () => { const project = resolveProjectRef(input.sourceDirectory); if (!project) throw new Error('Unable to find the project for this session'); - const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path); - const worktree = await createQuickWorktree(project, { startRef: sourceBranch }); + const sourceBranch = await checkIsGitRepository(input.sourceDirectory) + ? await resolveSourceBranch(input.sourceDirectory, project.path) + : null; + const worktree = await createQuickWorktree(project, sourceBranch ? { startRef: sourceBranch } : {}); try { await waitForWorktreeGitReady(worktree.path); } catch (error) { @@ -244,6 +321,91 @@ const moveSessionTreeToQuickWorktree = async (input: { }); }; +const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: boolean): void => { + const movePromise = intent.kind === 'existing' + ? moveSessionTreeToExistingWorktree({ + root: intent.root, + descendants: intent.descendants, + sourceDirectory: intent.sourceDirectory, + destination: intent.destination, + moveChanges, + }) + : moveSessionTreeToQuickWorktree({ + root: intent.root, + descendants: intent.descendants, + sourceDirectory: intent.sourceDirectory, + moveChanges, + }); + + void movePromise + .then(() => toast.success(intent.messages.success)) + .catch((error) => { + const failure = error instanceof Error ? error : new Error(String(error)); + toast.error(intent.messages.failure, { + description: moveChanges && isApplyChangesError(failure) + ? intent.messages.applyChangesFailed + : failure.message, + }); + }); +}; + +export const cancelSessionTreeMove = (): void => { + const confirmation = getSessionTreeMoveConfirmation(); + if (!confirmation) return; + setSessionMoveRequesting(confirmation.intent.root.id, false); + setSessionMoveConfirmation(null); +}; + +export const confirmSessionTreeMove = (moveChanges: boolean): void => { + const confirmation = getSessionTreeMoveConfirmation(); + if (!confirmation) return; + const { intent } = confirmation; + setSessionMoveConfirmation(null); + setSessionMoveRequesting(intent.root.id, false); + executeSessionTreeMove(intent, moveChanges); +}; + +export const requestSessionTreeMove = (intent: SessionTreeMoveIntent): void => { + const state = useSessionMoveState.getState(); + if (state.confirmation) return; + if (state.pendingSessionIds.has(intent.root.id) || state.requestingSessionIds.has(intent.root.id)) return; + + setSessionMoveRequesting(intent.root.id, true); + + void (async () => { + try { + const isGitRepository = await checkIsGitRepository(intent.sourceDirectory); + if (!isGitRepository) { + setSessionMoveRequesting(intent.root.id, false); + executeSessionTreeMove(intent, false); + return; + } + + const status = await getGitStatus(intent.sourceDirectory); + if (status.isClean) { + setSessionMoveRequesting(intent.root.id, false); + executeSessionTreeMove(intent, false); + return; + } + + const stagedFileCount = status.files.filter((file) => { + const indexStatus = file.index.trim(); + return indexStatus !== '' && indexStatus !== '?'; + }).length; + setSessionMoveConfirmation({ + intent, + dirtyFileCount: status.files.length, + stagedFileCount, + }); + } catch { + toast.error(intent.messages.failure, { + description: intent.messages.sourceVerificationFailed, + }); + setSessionMoveRequesting(intent.root.id, false); + } + })(); +}; + export const startSessionTreeExistingWorktreeMove = (input: { root: Session; descendants: Session[]; @@ -252,11 +414,19 @@ export const startSessionTreeExistingWorktreeMove = (input: { successMessage: string; failureMessage: string; }): void => { - void moveSessionTreeToExistingWorktree(input) - .then(() => toast.success(input.successMessage)) - .catch((error) => toast.error(input.failureMessage, { - description: error instanceof Error ? error.message : String(error), - })); + 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: { @@ -266,9 +436,16 @@ export const startSessionTreeWorktreeMove = (input: { successMessage: string; failureMessage: string; }): void => { - void moveSessionTreeToQuickWorktree(input) - .then(() => toast.success(input.successMessage)) - .catch((error) => toast.error(input.failureMessage, { - description: error instanceof Error ? error.message : String(error), - })); + 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, + }, + }); }; From 8fc08853b3a1be163f9c549b4d9e9c0e82ae7fc0 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 20 Aug 2026 13:32:51 -0600 Subject: [PATCH 08/14] feat(sessions): confirm dirty-source worktree moves --- .../SessionWorktreeMoveConfirmDialog.test.tsx | 106 ++++++++++++++++++ .../SessionWorktreeMoveConfirmDialog.tsx | 81 +++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 14 ++- packages/ui/src/lib/i18n/messages/en.ts | 14 ++- packages/ui/src/lib/i18n/messages/es.ts | 14 ++- packages/ui/src/lib/i18n/messages/fr.ts | 14 ++- packages/ui/src/lib/i18n/messages/ja.ts | 14 ++- packages/ui/src/lib/i18n/messages/ko.ts | 14 ++- packages/ui/src/lib/i18n/messages/pl.ts | 14 ++- packages/ui/src/lib/i18n/messages/pt-BR.ts | 14 ++- packages/ui/src/lib/i18n/messages/uk.ts | 14 ++- packages/ui/src/lib/i18n/messages/zh-CN.ts | 14 ++- packages/ui/src/lib/i18n/messages/zh-TW.ts | 14 ++- 13 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx create mode 100644 packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.tsx diff --git a/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx b/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx new file mode 100644 index 00000000..3920ec4d --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { describe, expect, mock, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { I18nProvider } from '@/lib/i18n'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { + SessionTreeMoveIntent, + SessionTreeMoveMessages, +} from '@/lib/worktrees/sessionWorktreeMove'; + +type MockDialogProps = React.PropsWithChildren<{ + open?: boolean; + id?: string; + className?: string; +}>; + +mock.module('@/components/ui/dialog', () => ({ + Dialog: ({ children, open = true }: MockDialogProps) => (open ? <>{children} : null), + DialogContent: ({ children, id, className }: MockDialogProps) => ( +
{children}
+ ), + DialogDescription: ({ children }: MockDialogProps) =>

{children}

, + DialogFooter: ({ children, className }: MockDialogProps) =>
{children}
, + DialogHeader: ({ children }: MockDialogProps) =>
{children}
, + DialogTitle: ({ children }: MockDialogProps) =>

{children}

, +})); + +const { SessionWorktreeMoveConfirmDialog } = await import('./SessionWorktreeMoveConfirmDialog'); + +const makeMoveMessages = (): SessionTreeMoveMessages => ({ + success: 'move succeeded', + failure: 'move failed', + sourceVerificationFailed: 'source verification failed', + applyChangesFailed: 'apply changes failed', +}); + +const makeExistingIntent = (): SessionTreeMoveIntent => ({ + kind: 'existing', + root: { + id: 'root', + slug: 'root', + projectID: 'project-1', + directory: '/source', + title: 'Root session', + version: '1', + time: { created: 0, updated: 0 }, + } satisfies Session, + descendants: [], + sourceDirectory: '/source', + destination: { + path: '/destination', + projectDirectory: '/repo', + branch: 'feature', + label: 'Destination', + worktreeStatus: 'ready', + worktreeSource: 'existing', + }, + messages: makeMoveMessages(), +}); + +describe('SessionWorktreeMoveConfirmDialog', () => { + test('renders stable semantic hooks, dirty file count, and the staged warning', () => { + const markup = renderToStaticMarkup( + + {}} + onMoveAllChanges={() => {}} + onCancel={() => {}} + /> + , + ); + + expect(markup).toContain('id="session-worktree-move-confirm-dialog"'); + expect(markup).toContain('data-session-worktree-move-action="session-only"'); + expect(markup).toContain('data-session-worktree-move-action="all-changes"'); + expect(markup).toContain('data-session-worktree-move-action="cancel"'); + expect(markup).toContain('autofocus=""'); + expect(markup).toContain('2'); + expect(markup).toContain('data-session-worktree-move-staged-warning="true"'); + }); + + test('omits the staged warning when no staged files are present', () => { + const markup = renderToStaticMarkup( + + {}} + onMoveAllChanges={() => {}} + onCancel={() => {}} + /> + , + ); + + expect(markup).not.toContain('data-session-worktree-move-staged-warning="true"'); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.tsx b/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.tsx new file mode 100644 index 00000000..fe6ecca3 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import type { SessionTreeMoveConfirmation } from '@/lib/worktrees/sessionWorktreeMove'; + +export type SessionWorktreeMoveConfirmDialogProps = { + value: SessionTreeMoveConfirmation | null; + onMoveSessionOnly: () => void; + onMoveAllChanges: () => void; + onCancel: () => void; +}; + +export function SessionWorktreeMoveConfirmDialog(props: SessionWorktreeMoveConfirmDialogProps): React.ReactNode { + const { t } = useI18n(); + const { value, onMoveSessionOnly, onMoveAllChanges, onCancel } = props; + + return ( + { if (!open) onCancel(); }}> + + + {t('sessions.sidebar.session.moveToWorktree.confirm.title')} + + {t('sessions.sidebar.session.moveToWorktree.confirm.changedFiles', { + count: value?.dirtyFileCount ?? 0, + })}{' '} + {t('sessions.sidebar.session.moveToWorktree.confirm.ownership')} + + +
+

{t('sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp')}

+

{t('sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp')}

+ {value && value.stagedFileCount > 0 ? ( +

+ {t('sessions.sidebar.session.moveToWorktree.confirm.stagedWarning')} +

+ ) : null} +

{t('sessions.sidebar.session.moveToWorktree.confirm.baseWarning')}

+
+ + + + + +
+
+ ); +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index caab5042..3e57a00f 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2999,9 +2999,21 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben', 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.', - 'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.', + 'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch und verschiebt diese Sitzung samt Untersitzungen dorthin. Bei ungespeicherten Änderungen in der Quelle wählst du, ob sie mit verschoben werden.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.', + 'sessions.sidebar.session.moveToWorktree.confirm.title': 'Die Quelle hat ungespeicherte Änderungen', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Geänderte Dateien in diesem Worktree: {count}.', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode verfolgt diese Änderungen nach Verzeichnis, nicht nach Sitzung.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Verschiebt diese Sitzung und ihre Untersitzungen, ohne die Quelldateien zu verändern.', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Überträgt die Änderungen im Sitzungsverzeichnis. Nicht committete und unversionierte Dateien verlassen die Quelle nach Erfolg.', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Gemappte (staged) Änderungen bleiben in der Quelle und werden ans Ziel kopiert.', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Die Übertragung kann fehlschlagen, wenn das Ziel eine andere Git-Basis verwendet.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Nur Sitzung verschieben', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Alle Quelländerungen verschieben', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Abbrechen', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Die Änderungen in der Quelle konnten nicht geprüft werden. Es wurde kein Worktree und keine Sitzung geändert.', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Das Ziel konnte die Änderungen der Quelle nicht übernehmen. Sitzung und Änderungen wurden nicht verschoben. Versuche es erneut und wähle Nur Sitzung verschieben.', 'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden', 'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben', 'gitView.header.updateBranch': 'Branch aktualisieren', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 2d820570..080b4fb6 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -547,9 +547,21 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree', 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.', - 'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.', + 'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch and moves this session and its sub-sessions there. When the source has uncommitted changes, you choose whether to move them.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.', + 'sessions.sidebar.session.moveToWorktree.confirm.title': 'Source has uncommitted changes', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Changed files in this worktree: {count}.', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode tracks these changes by directory, not by session.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Move this session and its sub-sessions while leaving every source file unchanged.', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfer changes under the session directory. Unstaged and untracked files leave the source after success.', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Staged changes remain in the source and are copied to the destination.', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'The transfer can fail when the destination uses a different Git base.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Move session only', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Move all source changes', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Cancel', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Source changes could not be verified. No worktree or session was changed.', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'The destination could not accept the source changes. The session and source changes were not moved. Retry and choose Move session only.', 'sessions.sidebar.session.menu.runFusion': 'Run fusion', 'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel', 'sessions.sidebar.session.actions.openInEditor': 'Open in Editor', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 8b8b656b..e565a91b 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -548,9 +548,21 @@ export const dict: Record = { "sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree", "sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree", "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.", - "sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.", + "sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual y mueve allí esta sesión y sus subsesiones. Si la fuente tiene cambios sin confirmar, decides si se transfieren.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.", + "sessions.sidebar.session.moveToWorktree.confirm.title": "La fuente tiene cambios sin confirmar", + "sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Archivos modificados en este worktree: {count}.", + "sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode rastrea estos cambios por directorio, no por sesión.", + "sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Mueve esta sesión y sus subsesiones dejando intacto cada archivo de la fuente.", + "sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfiere los cambios del directorio de la sesión. Los archivos sin confirmar y sin rastrear salen de la fuente tras el éxito.", + "sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Los cambios en el índice permanecen en la fuente y se copian al destino.", + "sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "La transferencia puede fallar si el destino usa una base de Git distinta.", + "sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover solo la sesión", + "sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todos los cambios de la fuente", + "sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar", + "sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "No se pudieron verificar los cambios de la fuente. No se modificó ningún worktree ni sesión.", + "sessions.sidebar.session.moveToWorktree.applyChangesFailed": "El destino no pudo aceptar los cambios de la fuente. No se movieron la sesión ni los cambios. Reintenta y elige Mover solo la sesión.", "sessions.sidebar.session.menu.runFusion": "Ejecutar fusion", "sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral", "sessions.sidebar.session.actions.openInEditor": "Abrir en el editor", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 76e05ad3..b1718a0c 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -378,9 +378,21 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree', 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et l’option d’en créer un nouveau pour cette session.', - 'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.', + 'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle et y déplace cette session et ses sous-sessions. Si la source contient des modifications non validées, vous choisissez de les transférer ou non.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez l’activité en cours ou attendez sa fin.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.', + 'sessions.sidebar.session.moveToWorktree.confirm.title': 'La source contient des modifications non validées', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Fichiers modifiés dans ce worktree : {count}.', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode suit ces modifications par répertoire, pas par session.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Déplace cette session et ses sous-sessions en laissant chaque fichier source inchangé.', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfère les modifications du répertoire de la session. Les fichiers non indexés et non suivis quittent la source après succès.', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Les modifications indexées restent dans la source et sont copiées vers la destination.', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Le transfert peut échouer si la destination utilise une base Git différente.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Déplacer la session uniquement', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Déplacer toutes les modifications de la source', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Annuler', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Les modifications de la source n’ont pas pu être vérifiées. Aucun worktree ni session n’a été modifié.', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'La destination n’a pas pu accepter les modifications de la source. La session et les modifications n’ont pas été déplacées. Réessayez et choisissez Déplacer la session uniquement.', 'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion', 'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral', 'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index cadc381f..e7b40882 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -548,9 +548,21 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました', 'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。', - 'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。', + 'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、このセッションとサブセッションをそこへ移動します。ソースに未コミットの変更がある場合は、移動するかどうかを選択します。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。', + 'sessions.sidebar.session.moveToWorktree.confirm.title': 'ソースに未コミットの変更があります', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'このworktree内の変更されたファイル: {count}。', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCodeはこれらの変更をセッションではなくディレクトリ単位で追跡します。', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'ソースファイルを一切変更せずに、このセッションとサブセッションを移動します。', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'セッションディレクトリ配下の変更を転送します。ステージされていない・追跡されていないファイルは成功後にソースを離れます。', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'ステージ済みの変更はソースに残り、宛先へコピーされます。', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '宛先が異なるGitベースを使用している場合、転送に失敗することがあります。', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'セッションのみ移動', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'ソースの変更をすべて移動', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'キャンセル', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'ソースの変更を検証できませんでした。worktreeもセッションも変更されませんでした。', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '宛先がソースの変更を受け付けられませんでした。セッションもソースの変更も移動されていません。再試行して「セッションのみ移動」を選んでください。', 'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行', 'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く', 'sessions.sidebar.session.actions.openInEditor': 'エディターで開く', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d8389704..bedcb2b7 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -548,9 +548,21 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다', 'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.', - 'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.', + 'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들어 이 세션과 하위 세션을 그곳으로 이동합니다. 원본에 커밋되지 않은 변경 사항이 있으면 이동 여부를 선택합니다.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.', + 'sessions.sidebar.session.moveToWorktree.confirm.title': '원본에 커밋되지 않은 변경 사항이 있습니다', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '이 worktree에서 변경된 파일: {count}개.', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode는 이 변경 사항을 세션이 아닌 디렉터리 기준으로 추적합니다.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '원본 파일은 그대로 둔 채 이 세션과 하위 세션을 이동합니다.', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '세션 디렉터리 아래의 변경 사항을 전송합니다. 스테이지되지 않거나 추적되지 않은 파일은 성공 후 원본을 떠납니다.', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '스테이지된 변경 사항은 원본에 남고 목적지로 복사됩니다.', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '목적지가 다른 Git 베이스를 사용하면 전송이 실패할 수 있습니다.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '세션만 이동', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '원본 변경 사항 모두 이동', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': '취소', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '원본 변경 사항을 확인하지 못했습니다. worktree와 세션 모두 변경되지 않았습니다.', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '목적지가 원본 변경 사항을 받아들이지 못했습니다. 세션과 원본 변경 사항이 이동되지 않았습니다. 다시 시도해 세션만 이동을 선택하세요.', 'sessions.sidebar.session.menu.runFusion': 'fusion 실행', 'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기', 'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c95038bd..4fd38373 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -341,9 +341,21 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree', 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.', - 'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.', + 'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi i przenosi tam tę sesję wraz z podsesjami. Jeśli w źródle są niezacommitowane zmiany, decydujesz, czy je przenieść.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.', + 'sessions.sidebar.session.moveToWorktree.confirm.title': 'Źródło ma niezacommitowane zmiany', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Zmienione pliki w tym worktree: {count}.', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode śledzi te zmiany według katalogu, a nie sesji.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Przenosi tę sesję i jej podsesje, pozostawiając każdy plik źródłowy bez zmian.', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Przenosi zmiany w katalogu sesji. Pliki niezacommitowane i nieśledzone opuszczają źródło po sukcesie.', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Zmiany w indeksie pozostają w źródle i są kopiowane do miejsca docelowego.', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Przeniesienie może się nie udać, gdy cel używa innej bazy Git.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Przenieś tylko sesję', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Przenieś wszystkie zmiany ze źródła', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Anuluj', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Nie udało się zweryfikować zmian w źródle. Żaden worktree ani sesja nie został zmieniony.', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Cel nie mógł przyjąć zmian ze źródła. Sesja i zmiany w źródle nie zostały przeniesione. Spróbuj ponownie i wybierz Przenieś tylko sesję.', 'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion', 'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym', 'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8ab01425..4c73c4f3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -548,9 +548,21 @@ export const dict: Record = { "sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree", "sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree", "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.", - "sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.", + "sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual e move esta sessão e suas subsessões para lá. Quando a fonte tem alterações não commitadas, você escolhe se as transfere.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.", + "sessions.sidebar.session.moveToWorktree.confirm.title": "A fonte tem alterações não commitadas", + "sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Arquivos alterados neste worktree: {count}.", + "sessions.sidebar.session.moveToWorktree.confirm.ownership": "O OpenCode rastreia essas alterações por diretório, não por sessão.", + "sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Move esta sessão e suas subsessões deixando todos os arquivos da fonte inalterados.", + "sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfere as alterações no diretório da sessão. Arquivos não adicionados ao stage e não rastreados saem da fonte após o sucesso.", + "sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Alterações já no stage permanecem na fonte e são copiadas para o destino.", + "sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "A transferência pode falhar quando o destino usa uma base do Git diferente.", + "sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover apenas a sessão", + "sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todas as alterações da fonte", + "sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar", + "sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "As alterações da fonte não puderam ser verificadas. Nenhum worktree ou sessão foi alterado.", + "sessions.sidebar.session.moveToWorktree.applyChangesFailed": "O destino não pôde aceitar as alterações da fonte. A sessão e as alterações da fonte não foram movidas. Tente novamente e escolha Mover apenas a sessão.", "sessions.sidebar.session.menu.runFusion": "Executar fusion", "sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral", "sessions.sidebar.session.actions.openInEditor": "Abrir no editor", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 5c46ddb0..cf06c3a9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -548,9 +548,21 @@ export const dict: Record = { "sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree", "sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree", "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.", - "sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.", + "sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки та переміщує туди цю сесію і її підсесії. Якщо у джерелі є незакомічені зміни, ви обираєте, переносити їх чи ні.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.", + "sessions.sidebar.session.moveToWorktree.confirm.title": "У джерелі є незакомічені зміни", + "sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Змінені файли у цьому worktree: {count}.", + "sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode відстежує ці зміни за каталогом, а не за сесією.", + "sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Переносить цю сесію та її підсесії, не змінюючи жодного файла джерела.", + "sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Переносить зміни з каталогу сесії. Незакомічені та невідстежувані файли залишають джерело після успіху.", + "sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Закомічені в індекс зміни залишаються в джерелі та копіюються до призначення.", + "sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "Перенесення може не вдатися, якщо призначення використовує іншу базу Git.", + "sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Перенести лише сесію", + "sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Перенести всі зміни з джерела", + "sessions.sidebar.session.moveToWorktree.confirm.cancel": "Скасувати", + "sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "Не вдалося перевірити зміни в джерелі. Жоден worktree чи сесію не змінено.", + "sessions.sidebar.session.moveToWorktree.applyChangesFailed": "Призначення не змогло прийняти зміни з джерела. Сесію та зміни в джерелі не перенесено. Спробуйте знову й оберіть Перенести лише сесію.", "sessions.sidebar.session.menu.runFusion": "Запустити fusion", "sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі", "sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 34504816..a9ccaeff 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -548,9 +548,21 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树', 'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。', - 'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。', + 'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,并将此会话及其子会话移至其中。当源有未提交的更改时,由你选择是否一并转移。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。', + 'sessions.sidebar.session.moveToWorktree.confirm.title': '源有未提交的更改', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作树中已更改的文件:{count}。', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 按目录而非会话跟踪这些更改。', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移动此会话及其子会话,同时保持每个源文件不变。', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '转移会话目录下的更改。未暂存和未跟踪的文件在成功后离开源。', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暂存的更改保留在源中,并复制到目的地。', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '当目的地使用不同的 Git 基准时,转移可能失败。', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '仅移动会话', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移动全部源更改', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '无法验证源的更改。未更改任何工作树或会话。', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地无法接受源的更改。会话和源更改均未移动。请重试并选择“仅移动会话”。', 'sessions.sidebar.session.menu.runFusion': '运行融合', 'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开', 'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index aefcb5e0..47ddc4b1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -561,9 +561,21 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹', 'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹', 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。', - 'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。', + 'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,並將此工作階段及其子工作階段移至其中。當來源有未提交的變更時,由你選擇是否一併轉移。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。', + 'sessions.sidebar.session.moveToWorktree.confirm.title': '來源有未提交的變更', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作樹中已變更的檔案:{count}。', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 依目錄而非工作階段追蹤這些變更。', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移動此工作階段及其子工作階段,同時保持每個來源檔案不變。', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '轉移工作階段目錄下的變更。未暫存與未追蹤的檔案在成功後離開來源。', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暫存的變更保留在來源中,並複製到目的地。', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '當目的地使用不同的 Git 基礎時,轉移可能失敗。', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '僅移動工作階段', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移動全部來源變更', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '無法驗證來源的變更。未變更任何工作樹或工作階段。', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地無法接受來源的變更。工作階段與來源變更均未移動。請重試並選擇「僅移動工作階段」。', 'sessions.sidebar.session.menu.runFusion': '執行 fusion', 'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟', 'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟', From 26d6d7255184bb854711eae2a14774e022a417bc Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 20 Aug 2026 13:52:36 -0600 Subject: [PATCH 09/14] 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, - }, - }); -}; From 1a9ff65380f596eea0cc4ae2922106b9b77ddad9 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 20 Aug 2026 14:08:44 -0600 Subject: [PATCH 10/14] docs(sessions): document dirty-source move choice --- .../ui/src/components/session/sidebar/DOCUMENTATION.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 0cc1692e..874da7e0 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -12,10 +12,12 @@ kept at this root in `types.ts` and `utils.tsx`. - `recent/` owns Recent and managed Chats activity projections. - `folders/` owns folder DnD, bulk actions, archived folders, and folder UI. - Root session right-click and overflow menus expose `Move to worktree`: a submenu - listing existing primary and linked worktree destinations, the current target - greyed and disabled, plus a `New worktree...` action. Moving to an existing or - new destination transfers the full idle subtree; only the root session carries - uncommitted changes. + listing the canonical primary and linked worktree destinations, with the current + target disabled and a separate `New worktree...` action. Opening the submenu + refreshes the worktree topology. Moving transfers the full idle subtree. Clean + and non-Git sources move session-only; a dirty Git source prompts to move only + the session, move all source changes, or cancel. Only the root session carries + source changes during a subtree move. `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })` unconditionally. The hook publishes complete directory bootstrap demand, From a35ff1032c9b54380a3dc5412bff79b13a51c646 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Tue, 25 Aug 2026 09:57:23 -0600 Subject: [PATCH 11/14] fix(sessions): avoid staged-change rollback conflicts --- .../session/sidebar/DOCUMENTATION.md | 6 +- .../lib/worktrees/sessionWorktreeMove.test.ts | 197 +++++++++++------- .../src/lib/worktrees/sessionWorktreeMove.ts | 27 ++- 3 files changed, 136 insertions(+), 94 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 874da7e0..1a77301e 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -16,8 +16,10 @@ kept at this root in `types.ts` and `utils.tsx`. target disabled and a separate `New worktree...` action. Opening the submenu refreshes the worktree topology. Moving transfers the full idle subtree. Clean and non-Git sources move session-only; a dirty Git source prompts to move only - the session, move all source changes, or cancel. Only the root session carries - source changes during a subtree move. + the session, move all source changes, or cancel. Descendants move first without + changes and roll back session-only if a later descendant fails. The root moves + last and carries source changes once, which prevents rollback from replaying the + transferred patch into the source. `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })` unconditionally. The hook publishes complete directory bootstrap demand, diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 125a5c93..07485e59 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import type { Session, SessionStatus } from '@opencode-ai/sdk/v2'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import type { State } from '@/sync/types'; import type { WorktreeMetadata } from '@/types/worktree'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; @@ -54,6 +58,7 @@ const toastSuccesses: string[] = []; const toastErrors: Array<{ title: string; description?: string }> = []; const directoryStates = new Map(); const storedMetadata = new Map(); +const tempDirectories: string[] = []; const originalConsoleWarn = console.warn; type SessionUIState = { availableWorktrees: WorktreeMetadata[]; @@ -281,6 +286,33 @@ const deferred = (): DeferredVoid => { return { promise, resolve, reject }; }; +const runGit = (directory: string, args: string[], input?: string): string => + execFileSync('git', args, { + cwd: directory, + encoding: 'utf8', + input, + stdio: ['pipe', 'pipe', 'pipe'], + }); + +const createStagedChangeWorktrees = () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-staged-move-')); + tempDirectories.push(root); + const source = path.join(root, 'source'); + const destination = path.join(root, 'destination'); + fs.mkdirSync(source); + runGit(source, ['init', '-b', 'main']); + runGit(source, ['config', 'user.email', 'test@example.com']); + runGit(source, ['config', 'user.name', 'Test']); + runGit(source, ['config', 'core.autocrlf', 'false']); + fs.writeFileSync(path.join(source, 'file.txt'), 'base\n'); + runGit(source, ['add', 'file.txt']); + runGit(source, ['commit', '--no-gpg-sign', '-m', 'init']); + runGit(source, ['worktree', 'add', '--detach', destination, 'HEAD']); + fs.writeFileSync(path.join(source, 'file.txt'), 'staged\n'); + runGit(source, ['add', 'file.txt']); + return { source, destination }; +}; + const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => { const cause = error.cause; if (!cause || !(cause instanceof Object)) { @@ -348,9 +380,12 @@ describe('moveSessionTreeToExistingWorktree', () => { afterEach(() => { console.warn = originalConsoleWarn; + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } }); - test('moves the root before descendants, only transfers changes once, and refreshes both directories', async () => { + test('moves descendants before the root, only transfers changes once, and refreshes both directories', async () => { const root = makeSession('root'); const child = makeSession('child'); const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); @@ -370,12 +405,12 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(result).toBe('/destination'); expect(moveCalls).toEqual([ - { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, ]); expect(metadataWrites).toEqual([ - { sessionId: 'root', metadata: latestMetadataResult }, { sessionId: 'child', metadata: latestMetadataResult }, + { sessionId: 'root', metadata: latestMetadataResult }, ]); expect(refreshCalls).toEqual([['/source', '/destination']]); expect(removeWorktreeCalls).toEqual([]); @@ -498,17 +533,13 @@ describe('moveSessionTreeToExistingWorktree', () => { })).rejects.toThrow('child-b failed'); expect(moveCalls).toEqual([ - { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, { sessionId: 'child-b', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, { sessionId: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false }, - { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, ]); expect(metadataWrites).toEqual([ - { sessionId: 'root', metadata: latestMetadataResult }, { sessionId: 'child-a', metadata: latestMetadataResult }, { sessionId: 'child-a', metadata: previousChildAMetadata }, - { sessionId: 'root', metadata: previousRootMetadata }, ]); expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata); @@ -517,67 +548,104 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(refreshCalls).toEqual([]); }); - test('rolls back the root and never moves a child that becomes busy after the root move starts', async () => { + test('does not replay transferred staged changes when a descendant move fails', async () => { + const { source, destination } = createStagedChangeWorktrees(); + const root = makeSession('root', source); + const child = makeSession('child', source); + setStatuses(source, { root: 'idle', child: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory, destinationDirectory, moveChanges) => { + if (session.id === 'child' && sourceDirectory === source) { + throw new Error('child failed'); + } + if (!moveChanges) return; + + const patch = runGit(sourceDirectory, ['diff', '--binary', 'HEAD']); + runGit(destinationDirectory, ['apply', '-'], patch); + runGit(sourceDirectory, ['checkout', '--', 'file.txt']); + }; + + const error = await moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: source, + destination: makeWorktreeMetadata({ path: destination }), + moveChanges: true, + }).catch((rejection) => rejection); + + expect(error).toEqual(new Error('child failed')); + expect(moveCalls).toEqual([ + { sessionId: 'child', sourceDirectory: source, destinationDirectory: destination, moveChanges: false }, + ]); + expect(runGit(source, ['status', '--short'])).toBe('M file.txt\n'); + expect(fs.readFileSync(path.join(destination, 'file.txt'), 'utf8')).toBe('base\n'); + }); + + test('rolls back an earlier child and never moves a later descendant that becomes busy', async () => { const root = makeSession('root'); - const child = makeSession('child'); - const rootMove = deferred(); + const childA = makeSession('child-a'); + const childB = makeSession('child-b'); + const childAMove = deferred(); const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); - const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' }); - setStatuses('/source', { root: 'idle', child: 'idle' }); + const previousChildAMetadata = makeWorktreeMetadata({ path: '/old-child-a', label: 'Old child A' }); + const previousChildBMetadata = makeWorktreeMetadata({ path: '/old-child-b', label: 'Old child B' }); + setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' }); setStatuses('/destination', {}); storedMetadata.set(root.id, previousRootMetadata); - storedMetadata.set(child.id, previousChildMetadata); + storedMetadata.set(childA.id, previousChildAMetadata); + storedMetadata.set(childB.id, previousChildBMetadata); moveSessionImplementation = async (session, sourceDirectory) => { - if (session.id === 'root' && sourceDirectory === '/source') { - return rootMove.promise; + if (session.id === 'child-a' && sourceDirectory === '/source') { + return childAMove.promise; } }; const movePromise = moveSessionTreeToExistingWorktree({ root, - descendants: [child], + descendants: [childA, childB], sourceDirectory: '/source', destination: makeWorktreeMetadata(), moveChanges: true, }); await waitFor(() => moveCalls.length === 1); - setStatuses('/source', { root: 'idle', child: 'busy' }); - setStatuses('/destination', { root: 'idle' }); - rootMove.resolve(); + setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'busy' }); + setStatuses('/destination', { 'child-a': 'idle' }); + childAMove.resolve(); await expect(movePromise).rejects.toThrow('Session is not idle'); expect(moveCalls).toEqual([ - { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, - { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, + { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false }, ]); expect(metadataWrites).toEqual([ - { sessionId: 'root', metadata: latestMetadataResult }, - { sessionId: 'root', metadata: previousRootMetadata }, + { sessionId: 'child-a', metadata: latestMetadataResult }, + { sessionId: 'child-a', metadata: previousChildAMetadata }, ]); expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); - expect(storedMetadata.get(child.id)).toBe(previousChildMetadata); + expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata); + expect(storedMetadata.get(childB.id)).toBe(previousChildBMetadata); expect(removeWorktreeCalls).toEqual([]); expect(refreshCalls).toEqual([]); }); test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => { const root = makeSession('root'); - const child = makeSession('child'); - setStatuses('/source', { root: 'idle', child: 'idle' }); + const childA = makeSession('child-a'); + const childB = makeSession('child-b'); + setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' }); moveSessionImplementation = async (session, sourceDirectory) => { - if (session.id === 'child' && sourceDirectory === '/source') { - throw new Error('child failed'); + if (session.id === 'child-b' && sourceDirectory === '/source') { + throw new Error('child-b failed'); } - if (session.id === 'root' && sourceDirectory === '/destination') { + if (session.id === 'child-a' && sourceDirectory === '/destination') { throw new Error('rollback failed'); } }; const error = await moveSessionTreeToExistingWorktree({ root, - descendants: [child], + descendants: [childA, childB], sourceDirectory: '/source', destination: makeWorktreeMetadata(), moveChanges: true, @@ -589,47 +657,48 @@ describe('moveSessionTreeToExistingWorktree', () => { } expect(error.message.includes('could not be fully rolled back')).toBe(true); const cause = getIncompleteRollbackCause(error); - expect(cause.moveError.message).toBe('child failed'); - expect(cause.rollbackFailures).toEqual([{ sessionId: 'root', error: new Error('rollback failed') }]); + expect(cause.moveError.message).toBe('child-b failed'); + expect(cause.rollbackFailures).toEqual([{ sessionId: 'child-a', error: new Error('rollback failed') }]); expect(removeWorktreeCalls).toEqual([]); }); const expectBusyOrRetryRollbackBlock = async (status: Extract): Promise => { const root = makeSession('root'); - const child = makeSession('child'); - setStatuses('/source', { root: 'idle', child: 'idle' }); + const childA = makeSession('child-a'); + const childB = makeSession('child-b'); + setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' }); setStatuses('/destination', {}); moveSessionImplementation = async (session, sourceDirectory) => { - if (sourceDirectory === '/source' && session.id === 'root') { - setStatuses('/destination', { root: status }); + if (sourceDirectory === '/source' && session.id === 'child-a') { + setStatuses('/destination', { 'child-a': status }); return; } - if (sourceDirectory === '/source' && session.id === 'child') { - throw new Error('child failed'); + if (sourceDirectory === '/source' && session.id === 'child-b') { + throw new Error('child-b failed'); } }; await expect(moveSessionTreeToExistingWorktree({ root, - descendants: [child], + descendants: [childA, childB], sourceDirectory: '/source', destination: makeWorktreeMetadata(), moveChanges: true, })).rejects.toThrow('could not be fully rolled back'); expect(moveCalls).toEqual([ - { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, - { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-b', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, ]); expect(removeWorktreeCalls).toEqual([]); }; - test('does not attempt rollback for a moved root that becomes busy in the destination', async () => { + test('does not attempt rollback for a moved child that becomes busy in the destination', async () => { await expectBusyOrRetryRollbackBlock('busy'); }); - test('does not attempt rollback for a moved root that becomes retry in the destination', async () => { + test('does not attempt rollback for a moved child that becomes retry in the destination', async () => { await expectBusyOrRetryRollbackBlock('retry'); }); @@ -851,18 +920,18 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(getSessionTreeMoveConfirmation()).toBeNull(); expect(moveCalls).toEqual([ - { - sessionId: 'root', - sourceDirectory: '/source', - destinationDirectory: '/created-worktree', - moveChanges: true, - }, { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/created-worktree', moveChanges: false, }, + { + sessionId: 'root', + sourceDirectory: '/source', + destinationDirectory: '/created-worktree', + moveChanges: true, + }, ]); }); @@ -891,7 +960,7 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(moveCalls).toEqual([]); }); - test('uses session-only mode when rolling back a moved root', async () => { + test('does not move the root when a descendant fails in session-only mode', async () => { const root = makeSession('root'); const child = makeSession('child'); setStatuses('/source', { root: 'idle', child: 'idle' }); @@ -911,35 +980,7 @@ describe('moveSessionTreeToExistingWorktree', () => { })).rejects.toThrow('child failed'); expect(moveCalls).toEqual([ - { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, - { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false }, - ]); - }); - - test('uses all-changes mode when rolling back a moved root after a full transfer', async () => { - const root = makeSession('root'); - const child = makeSession('child'); - setStatuses('/source', { root: 'idle', child: 'idle' }); - setStatuses('/destination', { root: 'idle' }); - moveSessionImplementation = async (session, sourceDirectory) => { - if (session.id === 'child' && sourceDirectory === '/source') { - throw new Error('child failed'); - } - }; - - await expect(moveSessionTreeToExistingWorktree({ - root, - descendants: [child], - sourceDirectory: '/source', - destination: makeWorktreeMetadata(), - moveChanges: true, - })).rejects.toThrow('child failed'); - - expect(moveCalls).toEqual([ - { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, - { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, - { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, ]); }); diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 5ea8b8ed..4f3b842e 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -150,11 +150,9 @@ const isSessionBusyOrRetrying = (session: Session, directory: string): boolean = const rollbackMovedSessions = async ( sessions: Session[], - rootSessionId: string, sourceDirectory: string, worktreeDirectory: string, previousMetadata: ReadonlyMap, - moveChanges: boolean, ): Promise => { const failures: RollbackFailure[] = []; for (const session of [...sessions].reverse()) { @@ -163,12 +161,12 @@ const rollbackMovedSessions = async ( continue; } try { - await moveSessionToDirectory( - session, - worktreeDirectory, - sourceDirectory, - session.id === rootSessionId && moveChanges, - ); + await moveSessionToDirectory( + session, + worktreeDirectory, + sourceDirectory, + false, + ); useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null); } catch (error) { failures.push({ @@ -212,7 +210,7 @@ const moveSessionTreeTransaction = async ( setSessionMovePending(input.root.id, true); try { - const sessions = [input.root, ...input.descendants]; + const sessions = [...input.descendants, input.root]; const previousMetadata = new Map( sessions.map((session) => [ session.id, @@ -227,27 +225,27 @@ const moveSessionTreeTransaction = async ( destination = await prepareDestination(); for (const [index, session] of sessions.entries()) { // Setup and earlier moves can take long enough for a not-yet-moved - // descendant to start running, so re-check the remaining source tree - // immediately before each move. + // session to start running, so re-check the remaining source tree + // immediately before each move. The root moves last so no later + // descendant failure can require replaying a transferred patch. assertSessionsIdle(sessions.slice(index), input.sourceDirectory); await moveSessionToDirectory( session, input.sourceDirectory, destination.directory, - index === 0 && input.moveChanges, + session.id === input.root.id && input.moveChanges, ); moved.push(session); + if (session.id === input.root.id) continue; useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata)); } } catch (error) { const moveError = error instanceof Error ? error : new Error(String(error)); const rollbackFailures = await rollbackMovedSessions( moved, - input.root.id, input.sourceDirectory, destination?.directory ?? input.sourceDirectory, previousMetadata, - input.moveChanges, ); if (rollbackFailures.length > 0) { throw createIncompleteRollbackError(moveError, rollbackFailures); @@ -257,6 +255,7 @@ const moveSessionTreeTransaction = async ( } throw moveError; } + useSessionUIStore.getState().setWorktreeMetadata(input.root.id, getLatestWorktreeMetadata(destination.metadata)); try { await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]); From 13ef639479b707e619eb70b45ac96bf46b47ae5f Mon Sep 17 00:00:00 2001 From: mattv8 Date: Tue, 25 Aug 2026 10:03:29 -0600 Subject: [PATCH 12/14] fix(sessions): keep chat worktree actions hidden --- .../sidebar/sessions/SessionNodeItem.tsx | 6 +++--- .../sessions/sessionNodeItemUtils.test.ts | 19 +++++++++++++++++++ .../sidebar/sessions/sessionNodeItemUtils.ts | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 08b548dc..79f69a20 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount import { useSessionMessageRecordsForExport } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from '../folders/sessionFolderDnd'; -import { getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; import type { SessionNode } from '../types'; import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -42,7 +42,7 @@ import { getSessionGoal } from '@/lib/sessionGoalMetadata'; import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation'; import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; -import { getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { getChatsRootFromDirectory } from '@/lib/chatDirectories'; import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { FusionIcon } from '@/components/icons/FusionIcon'; @@ -991,7 +991,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {t('sessions.sidebar.session.menu.exportMarkdown')} -{!isSubtaskSession && !archivedBucket && !isVSCode ? (() => { + {canShowSessionWorktreeMenu({ isSubtaskSession, archivedBucket: Boolean(archivedBucket), isVSCode, sessionDirectory }) ? (() => { const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({ sessionDirectory, isStreaming, diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts index 07c136b5..93128ec0 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts @@ -4,6 +4,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; import { computeNodeStructureKey, + canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeHasPinnedMembershipChange, selectFolderRootNodes, @@ -192,3 +193,21 @@ describe('getSessionWorktreeMenuDisabled', () => { })).toBe(true); }); }); + +describe('canShowSessionWorktreeMenu', () => { + test('hides worktree moves for managed Chat directories', () => { + expect(canShowSessionWorktreeMenu({ + isSubtaskSession: false, + archivedBucket: false, + isVSCode: false, + sessionDirectory: '/home/test/.config/openchamber/chats/2026-08-25/session-1', + })).toBe(false); + + expect(canShowSessionWorktreeMenu({ + isSubtaskSession: false, + archivedBucket: false, + isVSCode: false, + sessionDirectory: '/repo', + })).toBe(true); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts index 631faad3..171c36cb 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts @@ -1,6 +1,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import { normalizePath } from '@/lib/pathNormalization'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; import type { SessionNode } from '../types'; @@ -78,6 +79,21 @@ export type QuestionBadgeSessionScope = { sessionIDs: string[]; }; +export const canShowSessionWorktreeMenu = ({ + isSubtaskSession, + archivedBucket, + isVSCode, + sessionDirectory, +}: { + isSubtaskSession: boolean; + archivedBucket: boolean; + isVSCode: boolean; + sessionDirectory: string | null; +}): boolean => !isSubtaskSession + && !archivedBucket + && !isVSCode + && !isChatDirectoryPath(sessionDirectory); + export const getSessionWorktreeMenuDisabled = ({ sessionDirectory, isStreaming, From bcaa70216f0db523155780cce06fafcd3a06cd03 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 27 Aug 2026 14:59:48 -0600 Subject: [PATCH 13/14] fix(sessions): restore worktree menu type wiring --- .../session/sidebar/list/SessionProjectCollection.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index 4bd79c52..1e37bfc0 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { SessionTreeItemProps } from '../sessions/SessionTreeItem'; import { useArchivedAutoFolders } from '../folders/useArchivedAutoFolders'; import { ProjectSessionSelectionEffect } from '../projects/useProjectSessionSelection'; import type { WorktreeMetadata } from '@/types/worktree'; @@ -467,6 +468,7 @@ const VisibleSessionProjects: React.FC = ({ topol showRecentSection={showRecentSection && !singleProjectMode} /> : null ), [ + actions.startSessionWorktreeMenuLoad, alwaysShowActions, collection.childrenMap, collection.pinnedSessionIds, From 6d1510a148ca6e89610048dc8efe572499be915e Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 27 Aug 2026 19:15:06 -0600 Subject: [PATCH 14/14] docs(changelog): keep worktree move unreleased --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b393a397..8363b726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Work status: the session cost now counts what its subagents spent, with a line under the context meter splitting the session's own cost from the subagents' share, and each subagent's cost shown next to it in the Subagents list. Previously a session that delegated most of its work looked far cheaper than it was. +- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). - Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). @@ -94,7 +95,6 @@ All notable changes to this project will be documented in this file. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). - Desktop/Windows: the close button now aligns correctly with the rest of the window chrome. - Session assist: recaps and suggested follow-ups now work when the Anthropic provider is configured to use a custom endpoint; they previously failed every time instead of using that configured connection. -- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). ## [1.19.0] - 2026-08-19