From 21a117aa348c40e21e930b2887b2691c529b9458 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:05:05 -0700 Subject: [PATCH] fix(worktree): close retirement consistency gaps --- mobile/src/components/NewWorktreeModal.tsx | 6 +- .../worktree/use-retired-worktree-names.ts | 18 ++- src/main/ipc/worktrees.ts | 38 ++--- ...rsistence-worktree-name-retirement.test.ts | 14 ++ src/main/persistence.ts | 13 +- src/main/runtime/orca-runtime.ts | 36 +++-- src/main/worktree-name-retirement.test.ts | 75 ++++++++- src/main/worktree-name-retirement.ts | 150 +++++++++++++++++- src/renderer/src/hooks/useComposerState.ts | 20 ++- .../src/hooks/useRetiredWorktreeNames.ts | 28 +++- 10 files changed, 331 insertions(+), 67 deletions(-) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 8881185d58c..644a6f27184 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -189,7 +189,8 @@ function NewWorktreeModalContent({ const [selectedRepo, setSelectedRepo] = useState(null) // Why: a deleted workspace's directory can still hold agent conversation state keyed by cwd, so // its name must never be suggested again. Fetched per selected repo while the sheet is open. - const retiredWorktreeNames = useRetiredWorktreeNames(client, selectedRepo?.id) + const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } = + useRetiredWorktreeNames(client, selectedRepo?.id) const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } = useNewWorktreeDrawerNavigation(visible) const createInFlightRef = useRef(false) @@ -564,7 +565,7 @@ function NewWorktreeModalContent({ } async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo || createInFlightRef.current) { + if (!client || !selectedRepo || retiredWorktreeNamesLoading || createInFlightRef.current) { return } createInFlightRef.current = true @@ -684,6 +685,7 @@ function NewWorktreeModalContent({ const canCreate = selectedRepo != null && !creating && + !retiredWorktreeNamesLoading && !sshGate.requiresConnection && (!needsSetupChoice || setupDecisionChoice != null) const visibleAgentOptions = diff --git a/mobile/src/worktree/use-retired-worktree-names.ts b/mobile/src/worktree/use-retired-worktree-names.ts index 6aad4726756..b3cc666f8a5 100644 --- a/mobile/src/worktree/use-retired-worktree-names.ts +++ b/mobile/src/worktree/use-retired-worktree-names.ts @@ -12,12 +12,15 @@ import type { RpcClient } from '../transport/rpc-client' export function useRetiredWorktreeNames( client: RpcClient | null | undefined, repoId: string | null | undefined -): readonly string[] { - const [retiredNames, setRetiredNames] = useState([]) +): { names: readonly string[]; loading: boolean } { + const [loaded, setLoaded] = useState<{ + repoId: string + names: readonly string[] + } | null>(null) useEffect(() => { if (!client || !repoId) { - setRetiredNames([]) + setLoaded(null) return } let cancelled = false @@ -32,11 +35,11 @@ export function useRetiredWorktreeNames( | { retiredNamesByRepo?: Record } | undefined const names = result?.retiredNamesByRepo?.[repoId] - setRetiredNames(Array.isArray(names) ? names : []) + setLoaded({ repoId, names: Array.isArray(names) ? names : [] }) }) .catch(() => { if (!cancelled) { - setRetiredNames([]) + setLoaded({ repoId, names: [] }) } }) return () => { @@ -44,5 +47,8 @@ export function useRetiredWorktreeNames( } }, [client, repoId]) - return retiredNames + return { + names: loaded && loaded.repoId === repoId ? loaded.names : [], + loading: Boolean(client && repoId) && loaded?.repoId !== repoId + } } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 51f37b88f27..4c4b8aecba5 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -108,10 +108,12 @@ import { areWorktreePathsEqual, formatWorktreeRemovalError, isOrphanCompatiblePreflightError, - isOrphanedWorktreeError, - computeWorktreePath + isOrphanedWorktreeError } from './worktree-logic' -import { discoverRetiredWorktreeNames, retirableLeafName } from '../worktree-name-retirement' +import { + ensureRetiredWorktreeNamesBackfilled, + getRetiredWorktreeNamesForRepo +} from '../worktree-name-retirement' import { dedupeWorktreesByPath } from './worktree-path-comparison' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { @@ -604,9 +606,6 @@ function getPreservedBranchCleanupTarget( } const loggedUnavailableSshGitProviders = new Set() -/** Repos whose retirement registry has been seeded this run; the scan is idempotent, so a - * per-run guard is enough to keep repeated suggestion reads from re-scanning the filesystem. */ -const backfilledRetirementRepoIds = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 @@ -1917,28 +1916,13 @@ export function registerWorktreeHandlers( if (!repo) { return [] } - // Why: seed once per app run rather than at startup — the scan is two directory reads, and - // repeating it per run also picks up workspaces deleted outside Orca since the last launch. - if (!backfilledRetirementRepoIds.has(args.repoId)) { - backfilledRetirementRepoIds.add(args.repoId) - try { - const probePath = computeWorktreePath( - 'orca-retirement-probe', - repo.path, - store.getSettings() - ) - const worktreeParent = probePath.slice( - 0, - probePath.length - retirableLeafName(probePath).length - 1 - ) - const discovered = await discoverRetiredWorktreeNames({ workspaceRoots: [worktreeParent] }) - store.mergeRetiredWorktreeNames(args.repoId, discovered) - } catch (err) { - // Best effort: without the seed we only under-retire, which is the pre-existing behavior. - console.warn(`[worktrees] retirement backfill failed for repo ${args.repoId}:`, err) - } + try { + await ensureRetiredWorktreeNamesBackfilled(store, repo, store.getSettings()) + } catch (err) { + // Best effort: without the seed we only under-retire, which is the pre-existing behavior. + console.warn(`[worktrees] retirement backfill failed for repo ${args.repoId}:`, err) } - return store.getRetiredWorktreeNames(args.repoId) + return getRetiredWorktreeNamesForRepo(store, repo, store.getRepos(), store.getSettings()) }) ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index d4aef646a5f..79da3b51a74 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -64,6 +64,13 @@ describe('worktree name retirement registry', () => { expect(store.getRetiredWorktreeNames(REPO)).toEqual([]) }) + it('does not persist arbitrary user and issue-title names', async () => { + const store = await createStore() + store.addRetiredWorktreeName(REPO, 'fix-login-redirect') + store.addRetiredWorktreeName(REPO, 'STA-4189-duplicate-name') + expect(store.getRetiredWorktreeNames(REPO)).toEqual([]) + }) + it('returns a copy so callers cannot mutate the registry in place', async () => { const store = await createStore() store.addRetiredWorktreeName(REPO, 'nautilus') @@ -103,4 +110,11 @@ describe('worktree name retirement registry', () => { }) expect(store.getRetiredWorktreeNames(REPO).sort()).toEqual(['nautilus', 'seahorse']) }) + + it('drops legacy arbitrary names while loading the persisted map', async () => { + const store = await createStore({ + retiredWorktreeNamesByRepo: { [REPO]: ['fix-login', 'Nautilus'] } + }) + expect(store.getRetiredWorktreeNames(REPO)).toEqual(['nautilus']) + }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index a2f8d603159..c446afdd801 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -170,6 +170,7 @@ import { pruneAutomationRuns } from '../shared/automation-run-retention' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' +import { normalizeRetirableGeneratedName } from './worktree-name-retirement' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId, @@ -2350,8 +2351,8 @@ function normalizeRetiredWorktreeNamesByRepo(value: unknown): Record 0 && normalized.length <= 256) { + const normalized = normalizeRetirableGeneratedName(entry) + if (normalized) { seen.add(normalized) } } @@ -7221,8 +7222,8 @@ export class Store { /** Records a generated workspace name as spent for this repo. Called with the name main actually * used, not the one the renderer proposed — the create path can advance past it on collision. */ addRetiredWorktreeName(repoId: string, name: string): void { - const normalized = name.trim().toLowerCase() - if (!repoId || normalized.length === 0) { + const normalized = normalizeRetirableGeneratedName(name) + if (!repoId || !normalized) { return } this.state.retiredWorktreeNamesByRepo ??= {} @@ -7244,8 +7245,8 @@ export class Store { } const incoming = new Set() for (const name of names) { - const normalized = name.trim().toLowerCase() - if (normalized.length > 0) { + const normalized = normalizeRetirableGeneratedName(name) + if (normalized) { incoming.add(normalized) } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3a8ee8ebd74..f927be3d650 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -933,7 +933,11 @@ import { getWorktreeCreateCandidate, WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS } from '../worktree-create-candidates' -import { retirableLeafName } from '../worktree-name-retirement' +import { + ensureRetiredWorktreeNamesBackfilled, + getRetiredWorktreeNamesByRepo, + retirableLeafName +} from '../worktree-name-retirement' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' import type { PtyBindingSourceExpectation, Store } from '../persistence' import type { StatsCollector } from '../stats/collector' @@ -1114,6 +1118,7 @@ type RuntimeStore = { * retirement when absent, which only means a name stays issuable. */ addRetiredWorktreeName?: Store['addRetiredWorktreeName'] getRetiredWorktreeNames?: Store['getRetiredWorktreeNames'] + mergeRetiredWorktreeNames?: Store['mergeRetiredWorktreeNames'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] getProjects?: Store['getProjects'] @@ -21041,8 +21046,24 @@ export class OrcaRuntimeService { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } + const selectedRepo = repoSelector ? await this.resolveRepoSelector(repoSelector) : null + if ( + selectedRepo && + this.store?.getRetiredWorktreeNames && + this.store.mergeRetiredWorktreeNames + ) { + try { + await ensureRetiredWorktreeNamesBackfilled( + this.store, + selectedRepo, + this.store.getSettings() + ) + } catch (error) { + console.warn(`[runtime] retirement backfill failed for repo ${selectedRepo.id}:`, error) + } + } const resolved = await this.listResolvedWorktrees() - const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null + const repoId = selectedRepo?.id ?? null const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved) const worktrees = resolved.filter((worktree) => { @@ -21077,14 +21098,9 @@ export class OrcaRuntimeService { if (!store?.getRetiredWorktreeNames) { return {} } - const byRepo: Record = {} - for (const repoId of repoIds) { - const names = store.getRetiredWorktreeNames(repoId) - if (names.length > 0) { - byRepo[repoId] = names - } - } - return byRepo + const repos = store.getRepos() + const settings = store.getSettings() + return getRetiredWorktreeNamesByRepo(store, repoIds, repos, settings) } async listDetectedManagedWorktrees( diff --git a/src/main/worktree-name-retirement.test.ts b/src/main/worktree-name-retirement.test.ts index 965740a77e5..726adc6f70d 100644 --- a/src/main/worktree-name-retirement.test.ts +++ b/src/main/worktree-name-retirement.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { MARINE_CREATURES } from '../shared/marine-creatures' -import { collectRetiredNamesFromPaths, extractCandidateLeafNames } from './worktree-name-retirement' +import type { GlobalSettings, Repo } from '../shared/types' +import { + collectRetiredNamesFromPaths, + ensureRetiredWorktreeNamesBackfilled, + extractCandidateLeafNames, + getRetiredWorktreeNamesForRepo, + normalizeRetirableGeneratedName +} from './worktree-name-retirement' const FIRST = MARINE_CREATURES[0].toLowerCase() const SECOND = MARINE_CREATURES[1].toLowerCase() @@ -66,3 +76,66 @@ describe('collectRetiredNamesFromPaths', () => { expect(collectRetiredNamesFromPaths(paths)).toEqual(new Set([FIRST])) }) }) + +describe('normalizeRetirableGeneratedName', () => { + it('accepts only generated pool names and their numbered variants', () => { + expect(normalizeRetirableGeneratedName(` ${FIRST} `)).toBe(FIRST) + expect(normalizeRetirableGeneratedName(`${FIRST}-2`)).toBe(`${FIRST}-2`) + expect(normalizeRetirableGeneratedName('fix-login')).toBeNull() + expect(normalizeRetirableGeneratedName(`${FIRST}-1`)).toBeNull() + }) +}) + +describe('getRetiredWorktreeNamesForRepo', () => { + const makeRepo = (id: string, path: string): Repo => + ({ id, path, displayName: id, badgeColor: '', addedAt: 0 }) as Repo + const store = { + getRetiredWorktreeNames: (repoId: string) => (repoId === 'repo-a' ? [FIRST] : [SECOND]) + } + + it('shares retirements when non-nested repos create into the same cwd namespace', () => { + const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')] + const settings = { workspaceDir: '/workspaces', nestWorkspaces: false } as GlobalSettings + expect(getRetiredWorktreeNamesForRepo(store, repos[1], repos, settings).sort()).toEqual( + [FIRST, SECOND].sort() + ) + }) + + it('keeps independent nested repo paths in separate retirement domains', () => { + const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')] + const settings = { workspaceDir: '/workspaces', nestWorkspaces: true } as GlobalSettings + expect(getRetiredWorktreeNamesForRepo(store, repos[1], repos, settings)).toEqual([SECOND]) + }) +}) + +describe('ensureRetiredWorktreeNamesBackfilled', () => { + it('awaits the historical workspace scan before returning names to a client', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-retirement-backfill-')) + const workspaceRoot = join(root, 'workspaces') + await mkdir(join(workspaceRoot, FIRST), { recursive: true }) + const merged: string[] = [] + const store = { + mergeRetiredWorktreeNames: (_repoId: string, names: Iterable) => { + merged.push(...names) + return true + } + } + const repo = { + id: 'repo-a', + path: join(root, 'repos', 'a'), + displayName: 'repo-a', + badgeColor: '', + addedAt: 0 + } as Repo + + try { + await ensureRetiredWorktreeNamesBackfilled(store, repo, { + workspaceDir: workspaceRoot, + nestWorkspaces: false + }) + expect(merged).toContain(FIRST) + } finally { + await rm(root, { force: true, recursive: true }) + } + }) +}) diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts index 68dca2b0b88..3e022e58482 100644 --- a/src/main/worktree-name-retirement.ts +++ b/src/main/worktree-name-retirement.ts @@ -2,6 +2,16 @@ import { readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { MARINE_CREATURES } from '../shared/marine-creatures' +import { getRepoExecutionHostId } from '../shared/execution-host' +import { isFolderRepo } from '../shared/repo-kind' +import type { GlobalSettings, Repo } from '../shared/types' +import { + computeRemoteWorktreePath, + computeWorktreePath, + getWorktreePathSettings, + hasRepoWorktreeBasePath +} from './ipc/worktree-logic' +import { worktreePathComparisonKey } from './ipc/worktree-path-comparison' /** Agent CLIs bucket their per-conversation state under a directory derived from the workspace * cwd. A bucket surviving its workspace is exactly the evidence we need: the name was used, the @@ -9,6 +19,23 @@ import { MARINE_CREATURES } from '../shared/marine-creatures' const AGENT_TRANSCRIPT_ROOTS = ['.claude/projects', '.codex/sessions'] as const const POOL_NAMES: ReadonlySet = new Set(MARINE_CREATURES.map((name) => name.toLowerCase())) +const RETIREMENT_PROBE_NAME = 'orca-retirement-probe' +const backfillsByStore = new WeakMap>>() + +type RetirementStore = { + getRetiredWorktreeNames?(repoId: string): string[] + mergeRetiredWorktreeNames?(repoId: string, names: Iterable): boolean +} +type RetirementPathSettings = Pick + +export function normalizeRetirableGeneratedName(name: string): string | null { + const normalized = name.trim().toLowerCase() + if (normalized.length > 256) { + return null + } + const match = normalized.match(/^(.+?)(?:-([2-9]\d*))?$/) + return match && POOL_NAMES.has(match[1]) ? normalized : null +} /** Why: worktree paths reaching retirement can be native, WSL, or SSH-host paths, so the leaf is * taken separator-agnostically rather than through a platform-bound basename. */ @@ -45,18 +72,131 @@ export function collectRetiredNamesFromPaths(paths: Iterable): Set() + for (const candidate of repos) { + if ( + isFolderRepo(candidate) || + getRetirementCollisionKey(candidate, settings) !== collisionKey + ) { + continue + } + for (const name of getRetiredNames(candidate.id)) { + names.add(name) + } + } + return [...names] +} + +export function getRetiredWorktreeNamesByRepo( + store: RetirementStore, + repoIds: Iterable, + repos: readonly Repo[], + settings: RetirementPathSettings +): Record { + const getRetiredNames = store.getRetiredWorktreeNames?.bind(store) + if (!getRetiredNames) { + return {} + } + const namesByCollisionKey = new Map>() + for (const candidate of repos) { + if (isFolderRepo(candidate)) { + continue + } + const collisionKey = getRetirementCollisionKey(candidate, settings) + let names = namesByCollisionKey.get(collisionKey) + if (!names) { + names = new Set() + namesByCollisionKey.set(collisionKey, names) + } + for (const name of getRetiredNames(candidate.id)) { + names.add(name) + } + } + const requestedRepoIds = new Set(repoIds) + const byRepo: Record = {} + for (const repo of repos) { + if (!requestedRepoIds.has(repo.id) || isFolderRepo(repo)) { + continue + } + const names = namesByCollisionKey.get(getRetirementCollisionKey(repo, settings)) + if (names?.size) { + byRepo[repo.id] = [...names] + } + } + return byRepo +} + +function parentPath(path: string): string { + const trimmed = path.replace(/[/\\]+$/, '') + const separatorIndex = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + return separatorIndex < 0 ? '' : trimmed.slice(0, separatorIndex) +} + +export function ensureRetiredWorktreeNamesBackfilled( + store: RetirementStore, + repo: Repo, + settings: RetirementPathSettings +): Promise { + const mergeRetiredNames = store.mergeRetiredWorktreeNames?.bind(store) + if (isFolderRepo(repo) || repo.connectionId || !mergeRetiredNames) { + return Promise.resolve() + } + let storeBackfills = backfillsByStore.get(store) + if (!storeBackfills) { + storeBackfills = new Map() + backfillsByStore.set(store, storeBackfills) + } + const collisionKey = getRetirementCollisionKey(repo, settings) + const existing = storeBackfills.get(collisionKey) + if (existing) { + return existing + } + const backfill = discoverRetiredWorktreeNames({ + workspaceRoots: [parentPath(getRetirementProbePath(repo, settings))] + }).then((names) => { + mergeRetiredNames(repo.id, names) + }) + storeBackfills.set(collisionKey, backfill) + return backfill +} + /** Transcript bucket names are the workspace cwd with every non-alphanumeric run flattened to a - * dash. Encoding a repo's worktree parent the same way lets us keep retirement per-repo: a name - * spent under one repo's root says nothing about the same name under another's. */ + * dash. Encoding the worktree parent keeps retirement scoped to the cwd collision domain. */ export function encodePathForBucketMatch(path: string): string { return path.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase() } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3f76135d2d8..b4941cc26c6 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1593,7 +1593,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: blank name with no other seed → globally-unique creature name so workspaces don't collide across repos or on a literal default. // Retired names are excluded too, so a recreated workspace never reuses a deleted one's path. - const retiredWorktreeNames = useRetiredWorktreeNames(repoId) + const retiredNamesRefreshKey = useMemo( + () => + (worktreesByRepo[repoId] ?? []) + .map((worktree) => worktree.path) + .sort() + .join('\0'), + [repoId, worktreesByRepo] + ) + const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } = + useRetiredWorktreeNames(repoId, retiredNamesRefreshKey) const fallbackCreatureName = useMemo( () => getSuggestedCreatureName(worktreesByRepo, undefined, retiredWorktreeNames), [worktreesByRepo, retiredWorktreeNames] @@ -3571,6 +3580,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if ( !workspaceSeedName || + retiredWorktreeNamesLoading || selectedRepoRequiresConnection || shouldWaitForSetupCheck || shouldWaitForIssueAutomationCheck || @@ -4034,6 +4044,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS shouldWaitForIssueAutomationCheck, shouldWaitForSetupCheck, sourceIntentBlocksCreate, + retiredWorktreeNamesLoading, taskSourceContext, workspaceSeedName, isProjectGroupTarget, @@ -4084,6 +4095,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if ( !workspaceNameSeed || + retiredWorktreeNamesLoading || sourceIntentBlocksCreate || selectedRepoRequiresConnection || (requiresExplicitSetupChoice && !setupDecision) || @@ -4620,6 +4632,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.openAgentTabsInChatByDefault, smartNameMode, sourceIntentBlocksCreate, + retiredWorktreeNamesLoading, disabledTuiAgents, setupDecision, sparseEnabled, @@ -4653,9 +4666,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseError } const repoCreateDisabled = - createGateMode === 'quick' + retiredWorktreeNamesLoading || + (createGateMode === 'quick' ? getQuickComposerCreateDisabled(createGateInput) - : getFullComposerCreateDisabled(createGateInput) + : getFullComposerCreateDisabled(createGateInput)) const createDisabled = isProjectGroupTarget ? folderCreateDisabled : repoCreateDisabled const cardProps: ComposerCardProps = { eligibleRepos: isProjectGroupTarget ? folderSourceRepos : eligibleRepos, diff --git a/src/renderer/src/hooks/useRetiredWorktreeNames.ts b/src/renderer/src/hooks/useRetiredWorktreeNames.ts index dbe725e8833..8dfdc0bb592 100644 --- a/src/renderer/src/hooks/useRetiredWorktreeNames.ts +++ b/src/renderer/src/hooks/useRetiredWorktreeNames.ts @@ -8,12 +8,22 @@ import { useEffect, useState } from 'react' * * Failure returns an empty set, which degrades to the pre-existing behavior (a spent name can be * suggested) rather than blocking workspace creation. */ -export function useRetiredWorktreeNames(repoId: string | null | undefined): string[] { - const [retiredNames, setRetiredNames] = useState([]) +export function useRetiredWorktreeNames( + repoId: string | null | undefined, + refreshKey: unknown +): { + names: string[] + loading: boolean +} { + const [loaded, setLoaded] = useState<{ + repoId: string + refreshKey: unknown + names: string[] + } | null>(null) useEffect(() => { if (!repoId) { - setRetiredNames([]) + setLoaded(null) return } let cancelled = false @@ -21,19 +31,23 @@ export function useRetiredWorktreeNames(repoId: string | null | undefined): stri .listRetiredNames({ repoId }) .then((names) => { if (!cancelled) { - setRetiredNames(names) + setLoaded({ repoId, refreshKey, names }) } }) .catch((err) => { if (!cancelled) { - setRetiredNames([]) + setLoaded({ repoId, refreshKey, names: [] }) } console.warn(`Failed to load retired workspace names for repo ${repoId}:`, err) }) return () => { cancelled = true } - }, [repoId]) + }, [refreshKey, repoId]) - return retiredNames + return { + names: + loaded && loaded.repoId === repoId && loaded.refreshKey === refreshKey ? loaded.names : [], + loading: Boolean(repoId) && (loaded?.repoId !== repoId || loaded?.refreshKey !== refreshKey) + } }