fix(worktree): send name provenance and stop gating Create on the fetch

Desktop and mobile now mark a create as generated-name only when the user
typed nothing and the composer fell back to the suggestion, so the host knows
which names it may retire.

Remove the retired-names loading gate from every create path. The host already
skips retired candidates before doing any git work, so the client gate bought
nothing while it could disable Create for the length of a full mobile
reconnect ladder (the wait had no timeout) and blank the desktop button
between queued creates. The suggestion still waits; the button never does.

Also make the web client call worktree.listRetiredNames instead of hardcoding
an empty list — the method is registered and mobile-allowlisted, so the
comment claiming no wire call existed was wrong — and filter the mobile
response to strings so a malformed row cannot throw during normalization.
This commit is contained in:
Brennan Benson
2026-08-13 17:43:56 -07:00
parent 5aef758fb0
commit ef06e49a64
11 changed files with 115 additions and 18 deletions
+4 -4
View File
@@ -189,8 +189,8 @@ function NewWorktreeModalContent({
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(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 =
@@ -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
})
@@ -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<boolean>
}): Promise<WorktreeCreateResult> {
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) {
@@ -34,7 +34,11 @@ export function useRetiredWorktreeNames(
| { retiredNamesByRepo?: Record<string, string[]> }
| 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) {
+13 -9
View File
@@ -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,
@@ -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
@@ -135,6 +135,7 @@ async function executeWorktreeCreation(
preparedRequest.linkedGiteaPR,
preparedRequest.compareBaseRef,
{
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
...(preparedRequest.linkedWorkItem !== undefined
? { linkedWorkItem: preparedRequest.linkedWorkItem }
: {}),
@@ -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<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
@@ -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({
@@ -3945,6 +3945,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const createArgs = {
repoId,
name: candidateName,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch,
...(compareBaseRef ? { compareBaseRef } : {}),
...(candidateBranchNameOverride
+14 -4
View File
@@ -1772,10 +1772,20 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['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<string, string[]> }>(
'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) => {