diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 644a6f27184..0da42f35f21 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -189,8 +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 { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } = - useRetiredWorktreeNames(client, selectedRepo?.id) + // Create is never gated on it: the host skips retired candidates before doing any git work. + const { names: retiredWorktreeNames } = useRetiredWorktreeNames(client, selectedRepo?.id) const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } = useNewWorktreeDrawerNavigation(visible) const createInFlightRef = useRef(false) @@ -565,7 +565,7 @@ function NewWorktreeModalContent({ } async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo || retiredWorktreeNamesLoading || createInFlightRef.current) { + if (!client || !selectedRepo || createInFlightRef.current) { return } createInFlightRef.current = true @@ -662,6 +662,7 @@ function NewWorktreeModalContent({ client, repoId: selectedRepo.id, baseName, + nameWasGenerated: !trimmedName, createdWithAgentId, comment: trimmedNote, setupDecision, @@ -685,7 +686,6 @@ function NewWorktreeModalContent({ const canCreate = selectedRepo != null && !creating && - !retiredWorktreeNamesLoading && !sshGate.requiresConnection && (!needsSetupChoice || setupDecisionChoice != null) const visibleAgentOptions = diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index b1b391c55a4..32e9e162ef4 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -34,6 +34,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -56,6 +57,25 @@ describe('createBlankWorkspace', () => { expect('comment' in params).toBe(false) }) + it('marks the name as generated only when the user typed nothing', async () => { + // Why: the host retires generated names permanently; a name the user chose must stay reusable. + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-3' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: true, + supportsIdempotentCutoverRetry: true + }) + + expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true }) + }) + it('sends startupAgent (not a pre-built command) so the host resolves launch args', async () => { // Why: regression — the modal used to send a bare startupCommand ('claude') // that skipped the host's default `--dangerously-skip-permissions`. @@ -69,6 +89,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: 'claude', comment: 'spike', setupDecision: 'run', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -100,6 +121,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -125,6 +147,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) @@ -143,6 +166,7 @@ describe('createBlankWorkspace', () => { createdWithAgentId: undefined, comment: undefined, setupDecision: 'skip', + nameWasGenerated: false, supportsIdempotentCutoverRetry: true }) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index 2cbed698276..14c095a777e 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -16,6 +16,9 @@ export async function createBlankWorkspace(args: { createdWithAgentId: TuiAgent | undefined comment: string | undefined setupDecision: WorkspaceCreateSetupDecision + /** True when `baseName` is a generated creature name rather than one the user typed; only then + * may the host retire it. */ + nameWasGenerated: boolean supportsIdempotentCutoverRetry: boolean | Promise }): Promise { return createWorktreeWithNameRetry({ @@ -27,6 +30,7 @@ export async function createBlankWorkspace(args: { repo: `id:${args.repoId}`, setupDecision: args.setupDecision, name, + ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), ...agentLaunchCreateFields(args.createdWithAgentId) } if (args.comment) { diff --git a/mobile/src/worktree/use-retired-worktree-names.ts b/mobile/src/worktree/use-retired-worktree-names.ts index c875f2ca0c1..a641b3f9d4b 100644 --- a/mobile/src/worktree/use-retired-worktree-names.ts +++ b/mobile/src/worktree/use-retired-worktree-names.ts @@ -34,7 +34,11 @@ export function useRetiredWorktreeNames( | { retiredNamesByRepo?: Record } | undefined const names = result?.retiredNamesByRepo?.[repoId] - setLoaded({ repoId, names: Array.isArray(names) ? names : [] }) + setLoaded({ + repoId, + // A host can answer with a malformed row; a non-string would throw when normalized. + names: Array.isArray(names) ? names.filter((name) => typeof name === 'string') : [] + }) }) .catch(() => { if (!cancelled) { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index b4941cc26c6..dc43f47ae06 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1601,8 +1601,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS .join('\0'), [repoId, worktreesByRepo] ) - const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } = - useRetiredWorktreeNames(repoId, retiredNamesRefreshKey) + // Create is never gated on this load: the host skips retired candidates before any git work, so + // a pending fetch can only cost a suggestion, never correctness. + const { names: retiredWorktreeNames } = useRetiredWorktreeNames(repoId, retiredNamesRefreshKey) const fallbackCreatureName = useMemo( () => getSuggestedCreatureName(worktreesByRepo, undefined, retiredWorktreeNames), [worktreesByRepo, retiredWorktreeNames] @@ -3580,7 +3581,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if ( !workspaceSeedName || - retiredWorktreeNamesLoading || selectedRepoRequiresConnection || shouldWaitForSetupCheck || shouldWaitForIssueAutomationCheck || @@ -3648,6 +3648,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (!workspaceName) { return } + // Why: only a name Orca generated may be retired — the creature pool contains ordinary words + // ("orca", "runner", "molly") a user can type deliberately and expect to reuse. + const nameWasGenerated = !name.trim() && workspaceName === fallbackCreatureName const submitBaseBranch = smartGitHubResolution.kind === 'pr-start-point' ? smartGitHubResolution.baseBranch @@ -3898,6 +3901,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS { linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), linkedTaskSourceContext: taskSourceContext, + nameWasGenerated, ...(!backendStartup && startupPlan?.draftPrompt ? { startupDraft: startupPlan.draftPrompt } : {}) @@ -4044,9 +4048,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS shouldWaitForIssueAutomationCheck, shouldWaitForSetupCheck, sourceIntentBlocksCreate, - retiredWorktreeNamesLoading, taskSourceContext, workspaceSeedName, + fallbackCreatureName, isProjectGroupTarget, submitFolderTarget ]) @@ -4095,7 +4099,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if ( !workspaceNameSeed || - retiredWorktreeNamesLoading || sourceIntentBlocksCreate || selectedRepoRequiresConnection || (requiresExplicitSetupChoice && !setupDecision) || @@ -4184,6 +4187,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (!workspaceName) { return } + // Why: only a name Orca generated may be retired — see the full-composer submit path. + const nameWasGenerated = !name.trim() && workspaceName === fallbackCreatureName const smartSubmitBaseBranch = smartGitHubResolution.kind === 'pr-start-point' ? smartGitHubResolution.baseBranch @@ -4512,6 +4517,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS linkedTaskSourceContext: taskSourceContext, ...(workspaceRunContext ? { workspaceRunContext } : {}), name: workspaceName, + ...(nameWasGenerated ? { nameWasGenerated: true } : {}), ...(createDisplayName ? { displayName: createDisplayName } : {}), ...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}), ...(selectedRepoIsGit && submitCompareBaseRef @@ -4632,7 +4638,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.openAgentTabsInChatByDefault, smartNameMode, sourceIntentBlocksCreate, - retiredWorktreeNamesLoading, disabledTuiAgents, setupDecision, sparseEnabled, @@ -4666,10 +4671,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseError } const repoCreateDisabled = - retiredWorktreeNamesLoading || - (createGateMode === 'quick' + 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/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index d635e82555a..b1c45e9adc2 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -56,6 +56,8 @@ export type WorktreeCreationRequest = { * local-vs-runtime progress behavior even if the focused runtime changes. */ worktreeCreateProgressMode?: WorktreeCreationProgressMode name: string + /** True only when `name` came from the creature-name generator; gates host-side retirement. */ + nameWasGenerated?: boolean displayName?: string baseBranch?: string compareBaseRef?: string diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 132f4825dc8..c822bed55ab 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -135,6 +135,7 @@ async function executeWorktreeCreation( preparedRequest.linkedGiteaPR, preparedRequest.compareBaseRef, { + ...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}), ...(preparedRequest.linkedWorkItem !== undefined ? { linkedWorkItem: preparedRequest.linkedWorkItem } : {}), diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index c2980357ee8..995ca3bd8aa 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -209,6 +209,8 @@ export type WorktreeSlice = { linkedTaskSourceContext?: TaskSourceContext | null /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ startupDraft?: string + /** True only when `name` came from the creature-name generator; gates host-side retirement. */ + nameWasGenerated?: boolean } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6077204ba51..7349b9a35e7 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4867,6 +4867,51 @@ describe('createWorktree base status merge', () => { expect(mockApi.worktrees.prefetchCreateBase).not.toHaveBeenCalled() }) + it('marks the create payload as a generated name only when the caller says so', async () => { + // Why: the host retires generated names permanently, and the creature pool contains ordinary + // words ("orca", "runner", "molly"). A name the user typed must stay reusable. + const store = createTestStore() + mockApi.worktrees.create.mockResolvedValue({ + worktree: makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + }) + + await store.getState().createWorktree('repo1', 'nautilus', 'origin/main') + expect(mockApi.worktrees.create.mock.calls[0][0]).not.toHaveProperty('nameWasGenerated') + + mockApi.worktrees.create.mockClear() + await store + .getState() + .createWorktree( + 'repo1', + 'nautilus', + 'origin/main', + 'inherit', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { nameWasGenerated: true } + ) + expect(mockApi.worktrees.create.mock.calls[0][0]).toMatchObject({ nameWasGenerated: true }) + }) + it('passes linked work item and creation agent metadata through the create IPC payload', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 00e421cb7df..4fd22ab07f7 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3945,6 +3945,7 @@ export const createWorktreeSlice: StateCreator const createArgs = { repoId, name: candidateName, + ...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}), baseBranch, ...(compareBaseRef ? { compareBaseRef } : {}), ...(candidateBranchNameOverride diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a67258618b9..8c10342a1f3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1772,10 +1772,20 @@ function createWorktreesApi(): NonNullable['worktrees']> { withRuntimeWorktreeOwner(worktree, owned.hostId) ) }, - // Why: retirement is host-owned state with no wire call, and paired hosts update independently - // of clients. Returning empty degrades a web-client suggestion to the pre-existing behavior - // (a spent name can be offered) rather than inventing an RPC an older host cannot answer. - listRetiredNames: async () => [], + // Why the catch: a host predating this method answers `method_not_found`, and an empty list + // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. + listRetiredNames: async ({ repoId }) => { + try { + const result = await callRuntimeResult<{ retiredNamesByRepo?: Record }>( + 'worktree.listRetiredNames', + { repo: repoId } + ) + const names = result.retiredNamesByRepo?.[repoId] + return Array.isArray(names) ? names.filter((name) => typeof name === 'string') : [] + } catch { + return [] + } + }, listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), listAll: () => listAllRuntimeWorktrees(), create: async (args) => {