fix(worktrees): unblock existing-worktree selection on large repositories (#740)

Batch branch and HEAD annotations from one Git worktree snapshot while retaining native inventory authority, physical local-repository identity and configured effective-root validation. Preserve fresh family transaction scans, mutation fencing and rollback without spawning three Git processes per checkout.\n\nKeep pending worktree selections in the store by instance and family so the target and busy state survive navigation, simultaneous moves and selector remounts. Independent gatekeeper review found two P2 issues; both were fixed and round two reported zero findings on f971419a.\n\nValidation includes 40 focused server tests, 13 real-component browser tests, UI/server typechecks, UI build, and isolated official OpenCode 2.0.11 native worktree/family integration. CI platform/native/runtime checks pass. Admin merge is user-authorized despite two system-message-search browser failures reproduced identically on the clean dev base; evidence and the review loop are published on the PR.
This commit is contained in:
Pascal André 2026-09-21 17:33:11 +02:00 • committed by GitHub
parent 0e4a0d3d72
commit 548ffd2f4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 423 additions and 24 deletions

View file

@ -43,6 +43,7 @@
- Worktree discovery/create/remove use `workspaces/native-worktrees.ts` and the native OpenCode worktree API. CodeNomad supplies the `.codenomad/worktrees` default, named-branch policy and verified family transactions. Git common-directory identity scopes the native inventory to the opened local repository; opaque worktree identifiers are separate from mutable branch labels. Validate through `scripts/test-opencode-location-native.mjs` with an isolated CLI and `tests/browser/worktrees.test.ts` for selector gestures.
- Worktree inventory snapshots live in `workspaces/worktree-inventory.ts`: display reads serve cached data and lazily revalidate, directory authorization uses validated reads, and family transactions force fresh reads. Invalidation retains display data and fences pending scans; `workspace.worktreesChanged` refreshes existing UI consumers after a changed snapshot is published. Keep selector opening independent of refresh completion and suppress duplicate selection events during inventory reconciliation.
- Worktree branch/HEAD annotations use one NUL-delimited Git worktree snapshot, intersected with native inventory and verified Git identities; effective checkout roots must respect Git configuration as well as administrative backlinks. Pending move targets belong to the store's instance/family request identity and survive selector navigation/remounts until native reconciliation settles.
- Session pruning is a narrow V2 plugin/RPC exception under `packages/server/src/opencode/session-pruning/`; see `dev-docs/SESSION_PRUNING_RPC.md`. Bundle it with the shared server for both desktop hosts and provision through normal native plugin discovery. RPC registrations follow backend presence; clean shutdown removes that backend's lease and crashes expire. Loading never deletes content. Deletion occurs only on an explicit pruning request, without an extra enable-write switch or beta-number gate. Keep generic RPC proxy access closed. Writes validate actual storage, a fresh daemon-storage identity challenge and the native durable execution claim inside a synchronous SQLite transaction. Run isolated native concurrency/payload and client-cache regressions; tests must never target the shared daemon or a user's database.
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.

View file

@ -172,6 +172,34 @@ authorized workspace spelling before changing UI state.
## Evidence and regression coverage
### Existing-worktree selection latency (2026-09-21)
Reproduced in the running Windows Tauri app with disposable sessions: choosing an
existing checkout immediately displayed Workspace again, and the move endpoint
did not respond within 90 seconds. Native location/list/refresh requests completed;
the CodeNomad catalogue alone took 76.1 seconds for 174 entries. Repeated fresh
transaction scans multiplied the per-checkout Git process cost.
HEAD/branch annotations now come from one NUL-delimited `git worktree list`
snapshot. Native OpenCode still supplies the inventory; physical common-directory
and linked-checkout backlink checks retain local ownership and root validation.
The same catalogue completed in 5.2 seconds during the first patched measurement.
This is a local observation, not a performance bound. Authoritative transaction
rescans, mutation fences and rollback remain in place.
The selector displays the requested worktree and a busy spinner while moving,
disables additional selections, and reconciles to the native location on success
or failure. Browser regressions cover both delayed outcomes without optimistic
mutation of the session location.
The independent gatekeeper review added two regression cases. Administrative
backlinks alone do not establish Git's effective root when `core.worktree` or
worktree-specific configuration redirects it. The catalogue now reads effective
configuration once and asks Git to validate each root when configuration can
change it; ordinary scans retain a constant process count. Pending selector
targets now live with the store's per-instance/family request and survive A/B
session navigation and remounts, including either completion order and failure.
### Final native/UI integration (2026-09-17)
Follow-up: search/filter mode presents independent flat rows. The "Show subsessions"

View file

@ -0,0 +1,136 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, it } from "node:test"
import { fixtureCatalogue } from "./native-worktree-fixture"
const git = (directory: string, ...args: string[]) => execFileSync("git", ["-C", directory, ...args], {
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
}).trim()
const gitPath = (directory: string) => directory.replaceAll("\\", "/")
describe("configured Git checkout roots", () => {
let temp: string
let repo: string
let linked: string
let redirected: string
let environment: Record<string, string | undefined>
beforeEach(() => {
environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith("GIT_")))
for (const key of Object.keys(environment)) delete process.env[key]
temp = mkdtempSync(path.join(tmpdir(), "codenomad-checkout-config-"))
process.env.GIT_CONFIG_NOSYSTEM = "1"
process.env.GIT_CONFIG_GLOBAL = path.join(temp, "empty-global-config")
writeFileSync(process.env.GIT_CONFIG_GLOBAL, "")
repo = path.join(temp, "main repo")
linked = path.join(temp, "linked checkout")
redirected = path.join(temp, "effective root")
git(temp, "init", "--initial-branch=main", repo)
git(repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "initial")
git(repo, "worktree", "add", "-b", "feature", linked)
mkdirSync(redirected)
})
afterEach(() => {
for (const key of Object.keys(process.env)) if (key.startsWith("GIT_")) delete process.env[key]
Object.assign(process.env, environment)
rmSync(temp, { recursive: true, force: true })
})
for (const mode of ["absolute", "relative"] as const) {
it(`rejects an administrative backlink redirected by ${mode} configuration`, async () => {
mkdirSync(path.join(repo, "nested"))
mkdirSync(path.join(linked, "nested"))
const workspace = path.join(repo, "nested")
const before = await fixtureCatalogue(workspace)
assert.equal(before.worktrees.length, 2)
assert.equal(before.worktrees.find(entry => entry.kind === "worktree")?.directory, path.join(realpathSync(linked), "nested"))
const admin = git(linked, "rev-parse", "--absolute-git-dir")
const backlink = readFileSync(path.join(admin, "gitdir"), "utf8")
git(repo, "config", "extensions.worktreeConfig", "true")
git(linked, "config", "--worktree", "core.worktree",
mode === "absolute" ? redirected : gitPath(path.relative(admin, redirected)))
assert.equal(realpathSync(git(linked, "rev-parse", "--show-toplevel")), realpathSync(redirected))
assert.equal(readFileSync(path.join(admin, "gitdir"), "utf8"), backlink)
await assert.rejects(fixtureCatalogue(workspace), /not a checkout root/)
})
}
it("rejects a linked checkout made bare through worktree config", async () => {
git(repo, "config", "extensions.worktreeConfig", "true")
git(linked, "config", "--worktree", "core.bare", "true")
assert.equal(git(linked, "rev-parse", "--is-bare-repository"), "true")
await assert.rejects(fixtureCatalogue(repo), /must be run in a work tree/)
})
it("checks a redirected main checkout when opened from an ordinary linked checkout", async () => {
git(repo, "config", "core.worktree", redirected)
assert.equal(realpathSync(git(repo, "rev-parse", "--show-toplevel")), realpathSync(redirected))
assert.equal(realpathSync(git(linked, "rev-parse", "--show-toplevel")), realpathSync(linked))
await assert.rejects(fixtureCatalogue(linked), /not a checkout root/)
})
for (const mode of ["worktree", "conditional shared", "conditional global"] as const) {
it(`uses Git's effective root semantics for ${mode} includes`, async () => {
const admin = git(linked, "rev-parse", "--absolute-git-dir")
const included = gitPath(path.join(temp, "root-config"))
writeFileSync(included, `[core]\n\tworktree = "${gitPath(redirected)}"\n`)
if (mode === "worktree") {
git(repo, "config", "extensions.worktreeConfig", "true")
git(linked, "config", "--worktree", "include.path", included)
} else {
git(repo, "config", mode === "conditional global" ? "--global" : "--local",
`includeIf.gitdir:${gitPath(admin)}.path`, included)
// The main checkout does not activate this condition.
assert.throws(() => git(repo, "config", "--get", "core.worktree"))
}
assert.equal(git(linked, "config", "--includes", "--get", "core.worktree"), gitPath(redirected))
// Git's early repository setup can ignore included core.worktree even
// though `config --get` reports it. Do not emulate that setup in JS.
const effectiveRoot = realpathSync(git(linked, "rev-parse", "--show-toplevel"))
if (effectiveRoot === realpathSync(linked)) {
assert.equal((await fixtureCatalogue(repo)).worktrees.length, 2)
} else {
await assert.rejects(fixtureCatalogue(repo), /not a checkout root/)
}
})
}
it("accepts unredirected worktree configuration and still rejects substituted backlinks", async () => {
git(repo, "config", "extensions.worktreeConfig", "true")
git(linked, "config", "--worktree", "core.worktree", linked)
assert.equal((await fixtureCatalogue(repo)).worktrees.length, 2)
const other = path.join(temp, "other checkout")
git(repo, "worktree", "add", "--detach", other)
rmSync(path.join(linked, ".git"))
writeFileSync(path.join(linked, ".git"), readFileSync(path.join(other, ".git")))
await assert.rejects(fixtureCatalogue(repo), /not a checkout root/)
})
it("keeps the ordinary scan process count constant with benign includes and more checkouts", async () => {
const included = path.join(temp, "benign-config")
writeFileSync(included, "[core]\n\tbare = false\n[user]\n\tname = Test\n")
git(repo, "config", "include.path", gitPath(included))
const other = path.join(temp, "detached checkout")
git(repo, "worktree", "add", "--detach", other)
const trace = path.join(temp, "git-trace.json")
process.env.GIT_TRACE2_EVENT = gitPath(trace)
const scan = async (directories: string[]) => {
writeFileSync(trace, "")
const result = await fixtureCatalogue(repo, directories)
const starts = readFileSync(trace, "utf8").trim().split("\n").map(line => JSON.parse(line))
.filter(event => event.event === "start" && event.argv.some((arg: string) => arg.startsWith(temp)))
assert.equal(starts.filter(event => event.argv.includes("--porcelain")).length, 1)
assert.equal(starts.filter(event => event.argv.includes("--includes")).length, 1)
return { result, commands: starts.map(event => event.argv.slice(3)) }
}
const one = await scan([repo])
const many = await scan([repo, linked, other])
assert.equal(many.result.worktrees.length, 3)
assert.deepEqual(many.commands.sort(), one.commands.sort())
assert.equal(many.result.worktrees.find(entry => entry.branch === "feature")?.removable, true)
})
})

View file

@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
@ -30,4 +30,50 @@ describe("listWorktrees", () => {
rmSync(temp, { recursive: true, force: true })
}
})
it("batches named and detached annotations while retaining native membership and physical repository ownership", async () => {
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-annotations-"))
const repo = path.join(temp, "main repo")
const named = path.join(temp, "named checkout")
const detached = path.join(temp, "detached checkout")
const gitOnly = path.join(temp, "not in native inventory")
const foreign = path.join(temp, "independent clone")
const git = (...args: string[]) => execFileSync("git", args, { encoding: "utf8" }).trim()
try {
git("init", "--initial-branch=main", repo)
git("-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "initial")
const head = git("-C", repo, "rev-parse", "HEAD")
git("-C", repo, "worktree", "add", "-b", "feature/named", named)
git("-C", repo, "worktree", "add", "--detach", detached)
git("-C", repo, "worktree", "add", "--detach", gitOnly)
git("clone", "--local", repo, foreign)
const directories = [repo, named, detached, foreign]
const catalogue = await fixtureCatalogue(repo, directories)
assert.equal(catalogue.worktrees.length, 3)
const entry = catalogue.worktrees.find(item => item.directory === realpathSync(named))!
assert.equal(entry.branch, "feature/named")
assert.equal(entry.head, head)
assert.equal(entry.removable, true)
const detachedEntry = catalogue.worktrees.find(item => item.directory === realpathSync(detached))!
assert.equal(detachedEntry.branch, undefined)
assert.equal(detachedEntry.head, head)
assert.match(detachedEntry.label!, /detached checkout @ /)
assert.equal(catalogue.worktrees[0].removable, false)
const pointer = readFileSync(path.join(named, ".git"), "utf8")
try {
// A registered path can be replaced. Its current Git identity must still
// belong to this repository; a stale registration alone is insufficient.
rmSync(path.join(named, ".git"))
writeFileSync(path.join(named, ".git"), `gitdir: ${path.join(foreign, ".git")}\n`)
const replaced = await fixtureCatalogue(repo, directories)
assert.equal(replaced.worktrees.some(item => item.directory === realpathSync(named)), false)
writeFileSync(path.join(named, ".git"), readFileSync(path.join(detached, ".git")))
await assert.rejects(fixtureCatalogue(repo, directories), /not a checkout root/)
} finally {
rmSync(path.join(named, ".git"), { force: true })
writeFileSync(path.join(named, ".git"), pointer)
}
} finally { rmSync(temp, { recursive: true, force: true }) }
})
})

View file

@ -5,7 +5,7 @@ import { listNativeWorktrees } from "../native-worktrees"
// Only the native discovery transport is replaced. Filesystem identity, Git
// annotations, nested projection and the production catalogue remain real.
export function fixtureCatalogue(workspacePath: string) {
export function fixtureCatalogue(workspacePath: string, nativeDirectories?: string[]) {
const git = (directory: string, ...args: string[]) => execFileSync("git", ["-C", directory, ...args], { encoding: "utf8" }).trim()
const client = {
location: { get: async ({ location }: { location: { directory: string } }) => {
@ -15,7 +15,7 @@ export function fixtureCatalogue(workspacePath: string) {
} },
worktree: {
refresh: async () => {},
list: async () => git(workspacePath, "worktree", "list", "--porcelain", "-z").split("\0")
list: async () => nativeDirectories?.map(directory => ({ directory })) ?? git(workspacePath, "worktree", "list", "--porcelain", "-z").split("\0")
.filter(field => field.startsWith("worktree ")).map(field => ({ directory: field.slice(9) })),
},
} as unknown as OpenCodeClient

View file

@ -1,7 +1,7 @@
import path from "node:path"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { realpath } from "node:fs/promises"
import { readFile, realpath, stat } from "node:fs/promises"
export interface LogLike {
debug?: (obj: any, msg?: string) => void
@ -51,6 +51,65 @@ export async function readCheckout(directory: string) {
return { root: await realpath(root), common: await realpath(common), gitDirectory: await realpath(gitDirectory), head, branch }
}
// One Git snapshot supplies HEAD/branch annotations for every local checkout.
// Native OpenCode remains the inventory authority; callers intersect its entries
// with these registrations and verify each checkout's physical common directory.
export async function readWorktreeAnnotations(directory: string) {
const output = await git(directory, ["worktree", "list", "--porcelain", "-z"])
return output.split("\0\0").filter(Boolean).map(record => {
const fields = record.split("\0")
const root = fields.find(field => field.startsWith("worktree "))?.slice(9)
if (!root) throw new Error("Git returned a worktree without a directory")
const head = fields.find(field => field.startsWith("HEAD "))?.slice(5)
const branch = fields.find(field => field.startsWith("branch refs/heads/"))?.slice(18)
return { root, head, branch }
})
}
export async function readCheckoutIdentity(directory: string) {
const entry = path.join(directory, ".git")
const info = await stat(entry)
let gitDirectory = entry
if (!info.isDirectory()) {
const pointer = (await readFile(entry, "utf8")).replace(/\r?\n$/, "")
if (!pointer.startsWith("gitdir: ")) throw new Error("Invalid worktree Git directory")
gitDirectory = path.resolve(directory, pointer.slice(8))
}
const common = await readFile(path.join(gitDirectory, "commondir"), "utf8").catch(error => {
if (error.code === "ENOENT") return "."
throw error
})
const resolvedGit = await realpath(gitDirectory)
const resolvedCommon = await realpath(path.resolve(gitDirectory, common.replace(/\r?\n$/, "")))
const backlink = resolvedGit === resolvedCommon ? undefined : (await readFile(path.join(resolvedGit, "gitdir"), "utf8")).replace(/\r?\n$/, "")
return {
gitDirectory: resolvedGit,
common: resolvedCommon,
root: backlink ? await realpath(path.dirname(path.resolve(resolvedGit, backlink))) : undefined,
}
}
export async function createCheckoutRootVerifier(directory: string) {
// Read shared/system/global configuration once, letting Git expand includes.
// Conditional includes and config.worktree can differ for each checkout, so
// their presence requires Git's own resolution even if this checkout is plain.
const config = await git(directory, ["config", "--null", "--list", "--includes"])
const configuredRoot = config.split("\0").some(entry => {
const [key, value] = entry.split("\n", 2)
return key === "core.worktree" || key === "extensions.worktreeconfig" || key.startsWith("includeif.")
|| (key === "core.bare" && value !== "false")
}) || ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE"].some(key => process.env[key] !== undefined)
return async (checkout: string) => {
if (!configuredRoot) return
// Administrative gitdir backlinks describe registration, not necessarily
// the effective worktree. Keep annotations batched; only exceptional config
// needs this single extra process (which also rejects bare checkouts).
const root = await git(checkout, ["rev-parse", "--show-toplevel"])
if (await realpath(root) !== await realpath(checkout)) throw new Error("Native worktree entry is not a checkout root")
}
}
export function isValidWorktreeSlug(slug: string): boolean {
return Boolean(slug.trim() && slug.length <= 200 && !/[\x00-\x1F\x7F]/.test(slug))
}

View file

@ -4,7 +4,7 @@ import path from "node:path"
import type { LocationRef, OpenCodeClient } from "@opencode/client"
import type { WorktreeDescriptor, WorktreeListResponse } from "../api-types"
import { locationRequestOptions } from "../opencode/compatibility/location"
import { readCheckout, resolveRepoRoot, prepareWorktreeBranch, attachWorktreeBranch } from "./git-worktrees"
import { readCheckout, readCheckoutIdentity, readWorktreeAnnotations, createCheckoutRootVerifier, resolveRepoRoot, prepareWorktreeBranch, attachWorktreeBranch } from "./git-worktrees"
import { ensureCodenomadGitExclude } from "./worktree-map"
export interface NativeWorktreeContext {
@ -37,13 +37,22 @@ export async function listNativeWorktrees(context: NativeWorktreeContext): Promi
const options = locationRequestOptions(location, { includeDirectory: true })
await client.worktree.refresh({ projectID: current.project.id }, options)
const native = await client.worktree.list({ projectID: current.project.id }, options)
const verifyCheckoutRoot = await createCheckoutRootVerifier(workspacePath)
const annotations = new Map<string, Awaited<ReturnType<typeof readWorktreeAnnotations>>[number]>()
for (const annotation of await readWorktreeAnnotations(workspacePath)) {
const registered = await realpath(annotation.root).catch(error => {
if (error.code === "ENOENT") return undefined
throw error
})
if (registered) annotations.set(registered, annotation)
}
const paths = servicePaths(location.directory)
const relative = paths.relative(current.project.directory, current.directory)
if (paths.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${paths.sep}`)) throw new Error("Workspace is outside its checkout")
const worktrees: WorktreeDescriptor[] = []
const seen = new Set<string>()
// Large local repositories can contain hundreds of checkouts. Bound process
// concurrency, but do not serialize three Git processes per inventory entry.
// Validate native entries with bounded filesystem I/O. Branch/HEAD annotations
// come from one Git snapshot rather than three processes per checkout.
const pending = native.values()
const inspect = async (entry: (typeof native)[number]) => {
const host = await toHost(entry.directory)
@ -56,24 +65,27 @@ export async function listNativeWorktrees(context: NativeWorktreeContext): Promi
}
// Independent clones can share a native project ID. Git's physical common
// directory decides local membership; branch names and remote URLs do not.
const checkout = await readCheckout(host)
if (!await sameDirectory(local.common, checkout.common)) return
const registeredDirectory = await realpath(host)
if (!await sameDirectory(checkout.root, registeredDirectory)) throw new Error("Native worktree entry is not a checkout root")
const annotation = annotations.get(registeredDirectory)
if (!annotation) return
const checkout = await readCheckoutIdentity(host)
if (!await sameDirectory(local.common, checkout.common)) return
if (!await sameDirectory(checkout.root ?? mainHost, registeredDirectory)) throw new Error("Native worktree entry is not a checkout root")
await verifyCheckoutRoot(host)
if (seen.has(registeredDirectory)) return
seen.add(registeredDirectory)
const root = await sameDirectory(local.root, registeredDirectory)
worktrees.push({
slug: root ? "root" : identifier(registeredDirectory),
label: checkout.branch ?? `${path.basename(registeredDirectory)} @ ${checkout.head?.slice(0, 7) ?? "HEAD"}`,
label: annotation.branch ?? `${path.basename(registeredDirectory)} @ ${annotation.head?.slice(0, 7) ?? "HEAD"}`,
directory: root ? workspacePath : path.join(registeredDirectory, ...relative.split(/[\\/]/).filter(Boolean)),
serviceDirectory: root ? current.directory : paths.join(entry.directory, relative),
registeredDirectory,
serviceRoot: entry.directory,
kind: root ? "root" : "worktree",
removable: !root && !await sameDirectory(checkout.gitDirectory, checkout.common),
branch: checkout.branch,
head: checkout.head,
branch: annotation.branch,
head: annotation.head,
})
}
await Promise.all(Array.from({ length: Math.min(8, native.length) }, async () => {

View file

@ -1,7 +1,7 @@
import { Select } from "@kobalte/core/select"
import { Dialog } from "@kobalte/core/dialog"
import { Show, createMemo, createSignal, createUniqueId, untrack } from "solid-js"
import { ChevronDown, Copy, FolderOpen, Trash2 } from "lucide-solid"
import { ChevronDown, Copy, FolderOpen, Loader2, Trash2 } from "lucide-solid"
import type { WorktreeDescriptor } from "../../../server/src/api-types"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
@ -10,6 +10,7 @@ import {
createWorktree,
deleteWorktree,
getParentSessionId,
getPendingWorktreeSlug,
getGitRepoStatus,
getWorktreeSlugForParentSession,
getWorktrees,
@ -144,10 +145,11 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
const isChildSession = createMemo(() => Boolean(session()?.parentId))
const parentId = createMemo(() => getParentSessionId(props.instanceId, props.sessionId))
const currentSlug = createMemo(() => getWorktreeSlugForParentSession(props.instanceId, parentId()))
const movingSlug = createMemo(() => getPendingWorktreeSlug(props.instanceId, parentId()))
const gitRepoStatus = createMemo(() => getGitRepoStatus(props.instanceId))
const worktreesUnavailable = createMemo(() => gitRepoStatus() === false)
const dropdownDisabled = createMemo(() => isChildSession() || worktreesUnavailable())
const dropdownDisabled = createMemo(() => isChildSession() || worktreesUnavailable() || Boolean(movingSlug()))
let listbox: HTMLUListElement | undefined
const worktreeOptions = createMemo<WorktreeOption[]>(() => {
@ -183,7 +185,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
})
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
const slug = currentSlug()
const slug = movingSlug() ?? currentSlug()
const match = worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === slug)
if (match) return match
// Fallback to root if mapped slug is missing.
@ -327,6 +329,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
setCreateOpen(true)
return
}
if (movingSlug() || value.slug === currentSlug()) return
setIsOpen(false)
await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug)
}
@ -459,7 +463,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
)
}}
>
<Select.Trigger class="selector-trigger">
<Select.Trigger class="selector-trigger" aria-busy={Boolean(movingSlug())}>
<div class="flex-1 min-w-0">
<Select.Value<WorktreeOption>>
{(state) => {
@ -494,7 +498,9 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
<Show when={movingSlug()} fallback={<ChevronDown class="w-3 h-3" />}>
<Loader2 class="w-3 h-3 animate-spin" aria-hidden="true" />
</Show>
</Select.Icon>
</Select.Trigger>

View file

@ -19,6 +19,7 @@ const worktreeRequests = new Map<string, Promise<void>>()
const pendingWorktreeRefreshes = new Set<string>()
const worktreeReadyRefreshes = new Map<string, Promise<void>>()
const familyMoveRequests = new Map<string, Promise<void>>()
const [pendingFamilyMoveSlugs, setPendingFamilyMoveSlugs] = createSignal(new Map<string, string>())
const defaultDirectories = new Map<string, string>()
type WorktreeReadyRefresh = (instanceId: string) => Promise<void>
@ -228,6 +229,10 @@ export function getDefaultWorktreeDirectory(instanceId: string): string | undefi
return defaultDirectories.get(instanceId)
}
export function getPendingWorktreeSlug(instanceId: string, sessionId: string): string | undefined {
return pendingFamilyMoveSlugs().get(`${instanceId}:${getParentSessionId(instanceId, sessionId)}`)
}
async function setWorktreeSlugForParentSession(
instanceId: string,
parentSessionId: string,
@ -281,8 +286,15 @@ async function setWorktreeSlugForParentSession(
})
familyMoveRequests.set(key, task)
setPendingFamilyMoveSlugs(previous => new Map(previous).set(key, normalizedSlug))
await task.finally(() => {
if (familyMoveRequests.get(key) === task) familyMoveRequests.delete(key)
if (familyMoveRequests.get(key) !== task) return
familyMoveRequests.delete(key)
setPendingFamilyMoveSlugs(previous => {
const next = new Map(previous)
next.delete(key)
return next
})
})
}

View file

@ -1,4 +1,5 @@
import { render } from "solid-js/web"
import { createSignal, Show } from "solid-js"
import WorktreeSelector from "../../../src/components/worktree-selector"
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
@ -18,7 +19,11 @@ const entries = [
]
const session: any = { id: "session", instanceId: id, parentId: null, title: "Fixture", location: { directory: "/repo" },
projectID: "fixture", cost: 0, tokens: {}, time: { created: 1, updated: 1 }, agent: "build", status: "idle", model: { providerId: "fixture", modelId: "fixture" } }
const client: any = { session: { list: async () => ({ data: [session], cursor: {} }), active: async () => ({}) } }
const secondSession = { ...session, id: "session-b", location: { directory: "/repo" } }
const fixtureSessions = [session, secondSession]
const [selectedSession, setSelectedSession] = createSignal(session.id)
const [mounted, setMounted] = createSignal(true)
const client: any = { session: { list: async () => ({ data: fixtureSessions, cursor: {} }), active: async () => ({}) } }
;(sdkManager as any).clients.set(`${id}:/workspaces/${id}/instance`, client)
const uiConfig = { settings: { locale: "en" } }
serverApi.fetchConfigOwner = async () => uiConfig as any
@ -33,21 +38,48 @@ serverApi.createWorktree = async (_id, input) => {
}
serverApi.moveSessionFamily = async (_id, _session, input) => {
calls.push({ move: input.worktreeSlug })
session.location = { directory: entries.find(entry => entry.slug === input.worktreeSlug)!.directory }
return { rootSessionId: session.id, sessionIds: [session.id], worktreeSlug: input.worktreeSlug }
const target = fixtureSessions.find(candidate => candidate.id === _session)!
target.location = { directory: entries.find(entry => entry.slug === input.worktreeSlug)!.directory }
return { rootSessionId: target.id, sessionIds: [target.id], worktreeSlug: input.worktreeSlug }
}
serverApi.deleteWorktree = async (_id, slug) => { calls.push({ delete: slug }); entries.splice(entries.findIndex(entry => entry.slug === slug), 1) }
addInstance({ id, folder: "/repo", port: 0, pid: 0, proxyPath: `/workspaces/${id}/instance`, status: "ready", client })
setSessions(previous => new Map(previous).set(id, new Map([[session.id, session]])))
setSessions(previous => new Map(previous).set(id, new Map(fixtureSessions.map(session => [session.id, session]))))
await ensureWorktreesLoaded(id)
render(() => <ConfigProvider><I18nProvider><div style={{ width: "380px", margin: "40px" }}>
<WorktreeSelector instanceId={id} sessionId={session.id} />
<Show when={mounted()}><WorktreeSelector instanceId={id} sessionId={selectedSession()} /></Show>
</div></I18nProvider></ConfigProvider>, document.getElementById("root")!)
await updatePreferences({ locale: "en" })
;(window as any).fixture = {
calls,
selectSession: setSelectedSession,
setMounted,
location: () => session.location.directory,
worktrees: () => getWorktrees(id),
holdFamilyMoves: () => {
const move = serverApi.moveSessionFamily
const releases = new Map<string, (fail: boolean) => void>()
const requests: string[] = []
serverApi.moveSessionFamily = async (...args) => {
requests.push(args[1])
const fail = await new Promise<boolean>(resolve => { releases.set(args[1], resolve) })
if (fail) throw new Error("Fixture move rejected")
return move(...args)
}
;(window as any).fixture.moveRequests = requests
;(window as any).fixture.releaseFamilyMove = (sessionId: string, fail = false) => releases.get(sessionId)!(fail)
},
holdMove: (fail = false) => {
const move = serverApi.moveSessionFamily
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
serverApi.moveSessionFamily = async (...args) => {
await gate
if (fail) throw new Error("Fixture move rejected")
return move(...args)
}
;(window as any).fixture.releaseMove = release
},
refreshBurst: async () => {
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })

View file

@ -155,3 +155,70 @@ test("creation uses the selected source and the returned stable worktree ID", as
])
} finally { await page.close() }
})
for (const first of ["session", "session-b"]) {
for (const fail of [false, true]) {
test(`pending moves survive session switches and remounts: ${first} settles first, failure=${fail}`, async () => {
const page = await browser.newPage()
try {
await prepare(page)
await page.evaluate(() => (window as any).fixture.holdFamilyMoves())
const trigger = page.locator(".selector-trigger")
const select = (id: string) => page.evaluate(id => (window as any).fixture.selectSession(id), id)
const assertPending = async () => {
assert.match(await trigger.innerText(), /feature/)
assert.equal(await trigger.getAttribute("aria-busy"), "true")
assert.equal(await trigger.isDisabled(), true)
}
for (const id of ["session", "session-b"]) {
await select(id)
await trigger.click()
await page.getByRole("option", { name: /feature/ }).locator(".selector-option-label").click()
await page.getByRole("listbox").waitFor({ state: "hidden" })
await assertPending()
}
await select("session")
await assertPending()
await page.evaluate(() => (window as any).fixture.setMounted(false))
await trigger.waitFor({ state: "detached" })
await page.evaluate(() => (window as any).fixture.setMounted(true))
await assertPending()
await select(first)
await page.evaluate(({ first, fail }) => (window as any).fixture.releaseFamilyMove(first, fail), { first, fail })
await page.waitForFunction(() => document.querySelector(".selector-trigger")?.getAttribute("aria-busy") === "false")
assert.equal(await trigger.isDisabled(), false)
assert.match(await trigger.innerText(), fail ? /Workspace/ : /feature/)
const last = first === "session" ? "session-b" : "session"
await select(last)
await assertPending()
await page.evaluate(last => (window as any).fixture.releaseFamilyMove(last), last)
await page.waitForFunction(() => document.querySelector(".selector-trigger")?.getAttribute("aria-busy") === "false")
assert.match(await trigger.innerText(), /feature/)
assert.deepEqual(await page.evaluate(() => (window as any).fixture.moveRequests), ["session", "session-b"])
} finally { await page.close() }
})
}
}
for (const fail of [false, true]) {
test(`retains the requested worktree while moving and ${fail ? "restores the native location on failure" : "confirms it on success"}`, async () => {
const page = await browser.newPage()
try {
await prepare(page)
await page.evaluate(fail => (window as any).fixture.holdMove(fail), fail)
const trigger = page.locator(".selector-trigger")
await trigger.click()
await page.getByRole("option", { name: /feature/ }).locator(".selector-option-label").click()
await page.getByRole("listbox").waitFor({ state: "hidden" })
assert.match(await trigger.innerText(), /feature/)
assert.equal(await trigger.isDisabled(), true)
assert.equal(await trigger.getAttribute("aria-busy"), "true")
assert.equal(await page.evaluate(() => (window as any).fixture.location()), "/repo", "pending UI does not mutate native placement")
await page.evaluate(() => (window as any).fixture.releaseMove())
await page.waitForFunction(() => document.querySelector(".selector-trigger")?.getAttribute("aria-busy") === "false")
assert.equal(await trigger.isDisabled(), false)
assert.match(await trigger.innerText(), fail ? /Workspace/ : /feature/)
assert.deepEqual(await page.evaluate(() => (window as any).fixture.calls), fail ? [] : [{ move: "stable-feature-id" }])
} finally { await page.close() }
})
}