feat: move sessions to new worktrees
Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API. Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely. Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks. Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators. Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
This commit is contained in:
@@ -100,8 +100,9 @@ VS Code does not run the server permission-auto-accept runtime. The extension ho
|
||||
- title update
|
||||
- share
|
||||
- unshare
|
||||
- archive
|
||||
- delete
|
||||
- archive
|
||||
- delete
|
||||
- move to another worktree directory
|
||||
- retention cleanup batch archive/delete
|
||||
|
||||
This keeps cold/global lists responsive without requiring a refetch after every change.
|
||||
@@ -125,6 +126,7 @@ Examples of global-store updates performed in `session-actions.ts`:
|
||||
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
|
||||
- `archiveSession()` -> `archiveSessions([id], archivedAt)`
|
||||
- `deleteSession()` -> `removeSessions([id])`
|
||||
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
|
||||
|
||||
## The golden rule
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?:
|
||||
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
||||
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
|
||||
const globalUpsertedSessions: unknown[] = []
|
||||
const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = []
|
||||
|
||||
const mockScopedClient = {
|
||||
permission: {
|
||||
@@ -40,6 +41,14 @@ const mockScopedClient = {
|
||||
}
|
||||
|
||||
const mockSdk = {
|
||||
experimental: {
|
||||
controlPlane: {
|
||||
moveSession: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "controlPlane.moveSession", params })
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
messages: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.messages", params })
|
||||
@@ -102,6 +111,7 @@ mock.module("@/lib/opencode/client", () => ({
|
||||
return mockScopedClient
|
||||
},
|
||||
getDirectory: () => "/test/project",
|
||||
getSdkClient: () => mockSdk,
|
||||
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
|
||||
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
|
||||
return Promise.resolve(true)
|
||||
@@ -147,6 +157,9 @@ mock.module("./session-ui-store", () => ({
|
||||
if (sessionId === "session-b") return "/other/project"
|
||||
return null
|
||||
},
|
||||
setSessionDirectory: (sessionID: string, directory: string) => {
|
||||
movedSessionDirectories.push({ sessionID, directory })
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@@ -234,6 +247,87 @@ function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
|
||||
} as unknown as import("./child-store").ChildStoreManager
|
||||
}
|
||||
|
||||
describe("moveSessionToDirectory", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
registeredSessionDirectories.length = 0
|
||||
movedSessionDirectories.length = 0
|
||||
globalUpsertedSessions.length = 0
|
||||
})
|
||||
|
||||
test("moves through the control plane and reconciles directory stores", async () => {
|
||||
const message = {
|
||||
id: "message-a",
|
||||
sessionID: "session-a",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
} as Message
|
||||
const part = {
|
||||
id: "part-a",
|
||||
messageID: "message-a",
|
||||
type: "text",
|
||||
text: "hello",
|
||||
} as Part
|
||||
const source = createStore({ "session-a": [{ id: "permission-a" }] as never }, {
|
||||
session: [{ id: "session-a", title: "Move me", directory: "/source" } as Session],
|
||||
sessionTotal: 1,
|
||||
session_status: { "session-a": { type: "idle" } },
|
||||
session_diff: { "session-a": [{ file: "changed.ts", additions: 1, deletions: 0 }] },
|
||||
todo: { "session-a": [{ id: "todo-a", content: "Check move", status: "pending", priority: "medium" }] as never },
|
||||
question: { "session-a": [{ id: "question-a" }] as never },
|
||||
message: { "session-a": [message] },
|
||||
part: { "message-a": [part] },
|
||||
})
|
||||
const destination = createStore({})
|
||||
const childStores = createChildStores([["/source", source], ["/destination", destination]])
|
||||
const { moveSessionToDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/source")
|
||||
|
||||
await moveSessionToDirectory(source.getState().session[0], "/source", "/destination", true)
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([{
|
||||
method: "controlPlane.moveSession",
|
||||
params: {
|
||||
sessionID: "session-a",
|
||||
destination: { directory: "/destination" },
|
||||
moveChanges: true,
|
||||
},
|
||||
}])
|
||||
expect(source.getState().session).toHaveLength(0)
|
||||
expect(source.getState().sessionTotal).toBe(0)
|
||||
expect(source.getState().session_status["session-a"]).toBe(undefined)
|
||||
expect(source.getState().session_diff["session-a"]).toBe(undefined)
|
||||
expect(source.getState().todo["session-a"]).toBe(undefined)
|
||||
expect(source.getState().permission["session-a"]).toBe(undefined)
|
||||
expect(source.getState().question["session-a"]).toBe(undefined)
|
||||
expect(source.getState().message["session-a"]).toBe(undefined)
|
||||
expect(source.getState().part["message-a"]).toBe(undefined)
|
||||
expect(destination.getState().session[0]?.id).toBe("session-a")
|
||||
expect(destination.getState().sessionTotal).toBe(1)
|
||||
expect((destination.getState().session[0] as SessionWithDirectory)?.directory).toBe("/destination")
|
||||
expect(destination.getState().session_status["session-a"]?.type).toBe("idle")
|
||||
expect(destination.getState().session_diff["session-a"]?.[0]?.file).toBe("changed.ts")
|
||||
expect(destination.getState().todo["session-a"]?.[0]?.content).toBe("Check move")
|
||||
expect(destination.getState().permission["session-a"]?.[0]?.id).toBe("permission-a")
|
||||
expect(destination.getState().question["session-a"]?.[0]?.id).toBe("question-a")
|
||||
expect(destination.getState().message["session-a"]?.[0]?.id).toBe("message-a")
|
||||
expect(destination.getState().part["message-a"]?.[0]?.id).toBe("part-a")
|
||||
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }])
|
||||
expect(movedSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }])
|
||||
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe("/destination")
|
||||
|
||||
await moveSessionToDirectory(destination.getState().session[0], "/destination", "/source", true)
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")[1]?.params.moveChanges).toBe(true)
|
||||
expect(source.getState().session[0]?.id).toBe("session-a")
|
||||
expect(source.getState().message["session-a"]?.[0]?.id).toBe("message-a")
|
||||
expect(source.getState().part["message-a"]?.[0]?.id).toBe("part-a")
|
||||
expect(destination.getState().session).toHaveLength(0)
|
||||
expect(destination.getState().message["session-a"]).toBe(undefined)
|
||||
expect(destination.getState().part["message-a"]).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchMessagesForSession startup race", () => {
|
||||
test("does not reject before sync action refs are initialized", async () => {
|
||||
const { fetchMessagesForSession } = await import("./session-actions")
|
||||
|
||||
@@ -192,6 +192,115 @@ export function mirrorSessionIntoLiveStores(session: Session, directory?: string
|
||||
updateLiveSession(session)
|
||||
}
|
||||
|
||||
function moveRecordEntries<T>(
|
||||
source: Record<string, T>,
|
||||
destination: Record<string, T>,
|
||||
keys: Iterable<string>,
|
||||
): { source: Record<string, T>; destination: Record<string, T> } {
|
||||
let nextSource = source
|
||||
let nextDestination = destination
|
||||
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue
|
||||
if (nextSource === source) nextSource = { ...source }
|
||||
if (nextDestination === destination) nextDestination = { ...destination }
|
||||
nextDestination[key] = source[key]
|
||||
delete nextSource[key]
|
||||
}
|
||||
|
||||
return { source: nextSource, destination: nextDestination }
|
||||
}
|
||||
|
||||
function reconcileSessionMove(
|
||||
session: Session,
|
||||
sourceDirectory: string,
|
||||
destinationDirectory: string,
|
||||
): Session {
|
||||
const stores = _childStores
|
||||
const sourceStore = stores?.getChild(sourceDirectory)
|
||||
const destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false })
|
||||
const sourceState = sourceStore?.getState()
|
||||
const destinationState = destinationStore?.getState()
|
||||
const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session
|
||||
const movedSession = { ...liveSession, directory: destinationDirectory } as Session
|
||||
|
||||
if (!destinationStore || !destinationState || sourceStore === destinationStore) {
|
||||
return movedSession
|
||||
}
|
||||
|
||||
const destinationSessionIndex = destinationState.session.findIndex((candidate) => candidate.id === session.id)
|
||||
const destinationSessions = [...destinationState.session]
|
||||
if (destinationSessionIndex === -1) destinationSessions.push(movedSession)
|
||||
else destinationSessions[destinationSessionIndex] = movedSession
|
||||
|
||||
if (!sourceStore || !sourceState) {
|
||||
destinationStore.setState({
|
||||
session: destinationSessions,
|
||||
sessionTotal: destinationSessionIndex === -1
|
||||
? destinationState.sessionTotal + 1
|
||||
: destinationState.sessionTotal,
|
||||
})
|
||||
return movedSession
|
||||
}
|
||||
|
||||
const sourceContainsSession = sourceState.session.some((candidate) => candidate.id === session.id)
|
||||
const status = moveRecordEntries(sourceState.session_status, destinationState.session_status, [session.id])
|
||||
const diffs = moveRecordEntries(sourceState.session_diff, destinationState.session_diff, [session.id])
|
||||
const todos = moveRecordEntries(sourceState.todo, destinationState.todo, [session.id])
|
||||
const permissions = moveRecordEntries(sourceState.permission, destinationState.permission, [session.id])
|
||||
const questions = moveRecordEntries(sourceState.question, destinationState.question, [session.id])
|
||||
const messages = moveRecordEntries(sourceState.message, destinationState.message, [session.id])
|
||||
const messageIds = sourceState.message[session.id]?.map((message) => message.id) ?? []
|
||||
const parts = moveRecordEntries(sourceState.part, destinationState.part, messageIds)
|
||||
|
||||
sourceStore.setState({
|
||||
session: sourceState.session.filter((candidate) => candidate.id !== session.id),
|
||||
sessionTotal: sourceContainsSession ? Math.max(0, sourceState.sessionTotal - 1) : sourceState.sessionTotal,
|
||||
session_status: status.source,
|
||||
session_diff: diffs.source,
|
||||
todo: todos.source,
|
||||
permission: permissions.source,
|
||||
question: questions.source,
|
||||
message: messages.source,
|
||||
part: parts.source,
|
||||
})
|
||||
destinationStore.setState({
|
||||
session: destinationSessions,
|
||||
sessionTotal: destinationSessionIndex === -1
|
||||
? destinationState.sessionTotal + 1
|
||||
: destinationState.sessionTotal,
|
||||
session_status: status.destination,
|
||||
session_diff: diffs.destination,
|
||||
todo: todos.destination,
|
||||
permission: permissions.destination,
|
||||
question: questions.destination,
|
||||
message: messages.destination,
|
||||
part: parts.destination,
|
||||
})
|
||||
|
||||
return movedSession
|
||||
}
|
||||
|
||||
export async function moveSessionToDirectory(
|
||||
session: Session,
|
||||
sourceDirectory: string,
|
||||
destinationDirectory: string,
|
||||
moveChanges = true,
|
||||
): Promise<void> {
|
||||
const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({
|
||||
sessionID: session.id,
|
||||
destination: { directory: destinationDirectory },
|
||||
moveChanges,
|
||||
})
|
||||
assertSdkSuccess(result, "Move session")
|
||||
|
||||
const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory)
|
||||
|
||||
registerSessionDirectory(session.id, destinationDirectory)
|
||||
useGlobalSessionsStore.getState().upsertSession(moved)
|
||||
useSessionUIStore.getState().setSessionDirectory(session.id, destinationDirectory)
|
||||
}
|
||||
|
||||
function dir() {
|
||||
return _getDirectory() || undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user