fix(sessions): avoid staged-change rollback conflicts

This commit is contained in:
mattv8
2026-08-27 18:43:37 -06:00
parent 1a9ff65380
commit a35ff1032c
3 changed files with 136 additions and 94 deletions
@@ -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 target disabled and a separate `New worktree...` action. Opening the submenu
refreshes the worktree topology. Moving transfers the full idle subtree. Clean 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 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 the session, move all source changes, or cancel. Descendants move first without
source changes during a subtree move. 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 })` `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
unconditionally. The hook publishes complete directory bootstrap demand, unconditionally. The hook publishes complete directory bootstrap demand,
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Session, SessionStatus } from '@opencode-ai/sdk/v2'; 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 { State } from '@/sync/types';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
@@ -54,6 +58,7 @@ const toastSuccesses: string[] = [];
const toastErrors: Array<{ title: string; description?: string }> = []; const toastErrors: Array<{ title: string; description?: string }> = [];
const directoryStates = new Map<string, DirectoryState>(); const directoryStates = new Map<string, DirectoryState>();
const storedMetadata = new Map<string, WorktreeMetadata | null>(); const storedMetadata = new Map<string, WorktreeMetadata | null>();
const tempDirectories: string[] = [];
const originalConsoleWarn = console.warn; const originalConsoleWarn = console.warn;
type SessionUIState = { type SessionUIState = {
availableWorktrees: WorktreeMetadata[]; availableWorktrees: WorktreeMetadata[];
@@ -281,6 +286,33 @@ const deferred = (): DeferredVoid => {
return { promise, resolve, reject }; 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 getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => {
const cause = error.cause; const cause = error.cause;
if (!cause || !(cause instanceof Object)) { if (!cause || !(cause instanceof Object)) {
@@ -348,9 +380,12 @@ describe('moveSessionTreeToExistingWorktree', () => {
afterEach(() => { afterEach(() => {
console.warn = originalConsoleWarn; 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 root = makeSession('root');
const child = makeSession('child'); const child = makeSession('child');
const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' });
@@ -370,12 +405,12 @@ describe('moveSessionTreeToExistingWorktree', () => {
expect(result).toBe('/destination'); expect(result).toBe('/destination');
expect(moveCalls).toEqual([ expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true },
{ sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true },
]); ]);
expect(metadataWrites).toEqual([ expect(metadataWrites).toEqual([
{ sessionId: 'root', metadata: latestMetadataResult },
{ sessionId: 'child', metadata: latestMetadataResult }, { sessionId: 'child', metadata: latestMetadataResult },
{ sessionId: 'root', metadata: latestMetadataResult },
]); ]);
expect(refreshCalls).toEqual([['/source', '/destination']]); expect(refreshCalls).toEqual([['/source', '/destination']]);
expect(removeWorktreeCalls).toEqual([]); expect(removeWorktreeCalls).toEqual([]);
@@ -498,17 +533,13 @@ describe('moveSessionTreeToExistingWorktree', () => {
})).rejects.toThrow('child-b failed'); })).rejects.toThrow('child-b failed');
expect(moveCalls).toEqual([ expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true },
{ sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'child-b', 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: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false },
{ sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true },
]); ]);
expect(metadataWrites).toEqual([ expect(metadataWrites).toEqual([
{ sessionId: 'root', metadata: latestMetadataResult },
{ sessionId: 'child-a', metadata: latestMetadataResult }, { sessionId: 'child-a', metadata: latestMetadataResult },
{ sessionId: 'child-a', metadata: previousChildAMetadata }, { sessionId: 'child-a', metadata: previousChildAMetadata },
{ sessionId: 'root', metadata: previousRootMetadata },
]); ]);
expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); expect(storedMetadata.get(root.id)).toBe(previousRootMetadata);
expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata); expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata);
@@ -517,67 +548,104 @@ describe('moveSessionTreeToExistingWorktree', () => {
expect(refreshCalls).toEqual([]); 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 root = makeSession('root');
const child = makeSession('child'); const childA = makeSession('child-a');
const rootMove = deferred(); const childB = makeSession('child-b');
const childAMove = deferred();
const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' });
const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' }); const previousChildAMetadata = makeWorktreeMetadata({ path: '/old-child-a', label: 'Old child A' });
setStatuses('/source', { root: 'idle', child: 'idle' }); const previousChildBMetadata = makeWorktreeMetadata({ path: '/old-child-b', label: 'Old child B' });
setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' });
setStatuses('/destination', {}); setStatuses('/destination', {});
storedMetadata.set(root.id, previousRootMetadata); storedMetadata.set(root.id, previousRootMetadata);
storedMetadata.set(child.id, previousChildMetadata); storedMetadata.set(childA.id, previousChildAMetadata);
storedMetadata.set(childB.id, previousChildBMetadata);
moveSessionImplementation = async (session, sourceDirectory) => { moveSessionImplementation = async (session, sourceDirectory) => {
if (session.id === 'root' && sourceDirectory === '/source') { if (session.id === 'child-a' && sourceDirectory === '/source') {
return rootMove.promise; return childAMove.promise;
} }
}; };
const movePromise = moveSessionTreeToExistingWorktree({ const movePromise = moveSessionTreeToExistingWorktree({
root, root,
descendants: [child], descendants: [childA, childB],
sourceDirectory: '/source', sourceDirectory: '/source',
destination: makeWorktreeMetadata(), destination: makeWorktreeMetadata(),
moveChanges: true, moveChanges: true,
}); });
await waitFor(() => moveCalls.length === 1); await waitFor(() => moveCalls.length === 1);
setStatuses('/source', { root: 'idle', child: 'busy' }); setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'busy' });
setStatuses('/destination', { root: 'idle' }); setStatuses('/destination', { 'child-a': 'idle' });
rootMove.resolve(); childAMove.resolve();
await expect(movePromise).rejects.toThrow('Session is not idle'); await expect(movePromise).rejects.toThrow('Session is not idle');
expect(moveCalls).toEqual([ expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, { sessionId: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false },
]); ]);
expect(metadataWrites).toEqual([ expect(metadataWrites).toEqual([
{ sessionId: 'root', metadata: latestMetadataResult }, { sessionId: 'child-a', metadata: latestMetadataResult },
{ sessionId: 'root', metadata: previousRootMetadata }, { sessionId: 'child-a', metadata: previousChildAMetadata },
]); ]);
expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); 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(removeWorktreeCalls).toEqual([]);
expect(refreshCalls).toEqual([]); expect(refreshCalls).toEqual([]);
}); });
test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => { test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => {
const root = makeSession('root'); const root = makeSession('root');
const child = makeSession('child'); const childA = makeSession('child-a');
setStatuses('/source', { root: 'idle', child: 'idle' }); const childB = makeSession('child-b');
setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' });
moveSessionImplementation = async (session, sourceDirectory) => { moveSessionImplementation = async (session, sourceDirectory) => {
if (session.id === 'child' && sourceDirectory === '/source') { if (session.id === 'child-b' && sourceDirectory === '/source') {
throw new Error('child failed'); throw new Error('child-b failed');
} }
if (session.id === 'root' && sourceDirectory === '/destination') { if (session.id === 'child-a' && sourceDirectory === '/destination') {
throw new Error('rollback failed'); throw new Error('rollback failed');
} }
}; };
const error = await moveSessionTreeToExistingWorktree({ const error = await moveSessionTreeToExistingWorktree({
root, root,
descendants: [child], descendants: [childA, childB],
sourceDirectory: '/source', sourceDirectory: '/source',
destination: makeWorktreeMetadata(), destination: makeWorktreeMetadata(),
moveChanges: true, moveChanges: true,
@@ -589,47 +657,48 @@ describe('moveSessionTreeToExistingWorktree', () => {
} }
expect(error.message.includes('could not be fully rolled back')).toBe(true); expect(error.message.includes('could not be fully rolled back')).toBe(true);
const cause = getIncompleteRollbackCause(error); const cause = getIncompleteRollbackCause(error);
expect(cause.moveError.message).toBe('child failed'); expect(cause.moveError.message).toBe('child-b failed');
expect(cause.rollbackFailures).toEqual([{ sessionId: 'root', error: new Error('rollback failed') }]); expect(cause.rollbackFailures).toEqual([{ sessionId: 'child-a', error: new Error('rollback failed') }]);
expect(removeWorktreeCalls).toEqual([]); expect(removeWorktreeCalls).toEqual([]);
}); });
const expectBusyOrRetryRollbackBlock = async (status: Extract<SessionStatus['type'], 'busy' | 'retry'>): Promise<void> => { const expectBusyOrRetryRollbackBlock = async (status: Extract<SessionStatus['type'], 'busy' | 'retry'>): Promise<void> => {
const root = makeSession('root'); const root = makeSession('root');
const child = makeSession('child'); const childA = makeSession('child-a');
setStatuses('/source', { root: 'idle', child: 'idle' }); const childB = makeSession('child-b');
setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' });
setStatuses('/destination', {}); setStatuses('/destination', {});
moveSessionImplementation = async (session, sourceDirectory) => { moveSessionImplementation = async (session, sourceDirectory) => {
if (sourceDirectory === '/source' && session.id === 'root') { if (sourceDirectory === '/source' && session.id === 'child-a') {
setStatuses('/destination', { root: status }); setStatuses('/destination', { 'child-a': status });
return; return;
} }
if (sourceDirectory === '/source' && session.id === 'child') { if (sourceDirectory === '/source' && session.id === 'child-b') {
throw new Error('child failed'); throw new Error('child-b failed');
} }
}; };
await expect(moveSessionTreeToExistingWorktree({ await expect(moveSessionTreeToExistingWorktree({
root, root,
descendants: [child], descendants: [childA, childB],
sourceDirectory: '/source', sourceDirectory: '/source',
destination: makeWorktreeMetadata(), destination: makeWorktreeMetadata(),
moveChanges: true, moveChanges: true,
})).rejects.toThrow('could not be fully rolled back'); })).rejects.toThrow('could not be fully rolled back');
expect(moveCalls).toEqual([ expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, { sessionId: 'child-b', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
]); ]);
expect(removeWorktreeCalls).toEqual([]); 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'); 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'); await expectBusyOrRetryRollbackBlock('retry');
}); });
@@ -851,18 +920,18 @@ describe('moveSessionTreeToExistingWorktree', () => {
expect(getSessionTreeMoveConfirmation()).toBeNull(); expect(getSessionTreeMoveConfirmation()).toBeNull();
expect(moveCalls).toEqual([ expect(moveCalls).toEqual([
{
sessionId: 'root',
sourceDirectory: '/source',
destinationDirectory: '/created-worktree',
moveChanges: true,
},
{ {
sessionId: 'child', sessionId: 'child',
sourceDirectory: '/source', sourceDirectory: '/source',
destinationDirectory: '/created-worktree', destinationDirectory: '/created-worktree',
moveChanges: false, moveChanges: false,
}, },
{
sessionId: 'root',
sourceDirectory: '/source',
destinationDirectory: '/created-worktree',
moveChanges: true,
},
]); ]);
}); });
@@ -891,7 +960,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
expect(moveCalls).toEqual([]); 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 root = makeSession('root');
const child = makeSession('child'); const child = makeSession('child');
setStatuses('/source', { root: 'idle', child: 'idle' }); setStatuses('/source', { root: 'idle', child: 'idle' });
@@ -911,35 +980,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
})).rejects.toThrow('child failed'); })).rejects.toThrow('child failed');
expect(moveCalls).toEqual([ expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'child', 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 },
]); ]);
}); });
@@ -150,11 +150,9 @@ const isSessionBusyOrRetrying = (session: Session, directory: string): boolean =
const rollbackMovedSessions = async ( const rollbackMovedSessions = async (
sessions: Session[], sessions: Session[],
rootSessionId: string,
sourceDirectory: string, sourceDirectory: string,
worktreeDirectory: string, worktreeDirectory: string,
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>, previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
moveChanges: boolean,
): Promise<RollbackFailure[]> => { ): Promise<RollbackFailure[]> => {
const failures: RollbackFailure[] = []; const failures: RollbackFailure[] = [];
for (const session of [...sessions].reverse()) { for (const session of [...sessions].reverse()) {
@@ -163,12 +161,12 @@ const rollbackMovedSessions = async (
continue; continue;
} }
try { try {
await moveSessionToDirectory( await moveSessionToDirectory(
session, session,
worktreeDirectory, worktreeDirectory,
sourceDirectory, sourceDirectory,
session.id === rootSessionId && moveChanges, false,
); );
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null); useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
} catch (error) { } catch (error) {
failures.push({ failures.push({
@@ -212,7 +210,7 @@ const moveSessionTreeTransaction = async (
setSessionMovePending(input.root.id, true); setSessionMovePending(input.root.id, true);
try { try {
const sessions = [input.root, ...input.descendants]; const sessions = [...input.descendants, input.root];
const previousMetadata = new Map( const previousMetadata = new Map(
sessions.map((session) => [ sessions.map((session) => [
session.id, session.id,
@@ -227,27 +225,27 @@ const moveSessionTreeTransaction = async (
destination = await prepareDestination(); destination = await prepareDestination();
for (const [index, session] of sessions.entries()) { for (const [index, session] of sessions.entries()) {
// Setup and earlier moves can take long enough for a not-yet-moved // Setup and earlier moves can take long enough for a not-yet-moved
// descendant to start running, so re-check the remaining source tree // session to start running, so re-check the remaining source tree
// immediately before each move. // 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); assertSessionsIdle(sessions.slice(index), input.sourceDirectory);
await moveSessionToDirectory( await moveSessionToDirectory(
session, session,
input.sourceDirectory, input.sourceDirectory,
destination.directory, destination.directory,
index === 0 && input.moveChanges, session.id === input.root.id && input.moveChanges,
); );
moved.push(session); moved.push(session);
if (session.id === input.root.id) continue;
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata)); useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata));
} }
} catch (error) { } catch (error) {
const moveError = error instanceof Error ? error : new Error(String(error)); const moveError = error instanceof Error ? error : new Error(String(error));
const rollbackFailures = await rollbackMovedSessions( const rollbackFailures = await rollbackMovedSessions(
moved, moved,
input.root.id,
input.sourceDirectory, input.sourceDirectory,
destination?.directory ?? input.sourceDirectory, destination?.directory ?? input.sourceDirectory,
previousMetadata, previousMetadata,
input.moveChanges,
); );
if (rollbackFailures.length > 0) { if (rollbackFailures.length > 0) {
throw createIncompleteRollbackError(moveError, rollbackFailures); throw createIncompleteRollbackError(moveError, rollbackFailures);
@@ -257,6 +255,7 @@ const moveSessionTreeTransaction = async (
} }
throw moveError; throw moveError;
} }
useSessionUIStore.getState().setWorktreeMetadata(input.root.id, getLatestWorktreeMetadata(destination.metadata));
try { try {
await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]); await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]);