From 8f15f217a22953cc4e5da20010fc9a3de930aeea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:08:05 -0400 Subject: [PATCH] Preserve user-set workspace names across branch changes (#17448) * fix(worktrees): preserve user workspace names across branch changes * test(worktrees): cover pinned rename metadata * fix(workspaces): address display-name review edge cases * fix(workspaces): keep automatic names fresh across refreshes * fix(workspaces): preserve legacy CLI labels * fix(workspaces): preserve display-name provenance across hosts * fix(workspaces): honor legacy display-name provenance * fix(workspaces): fence display-name refresh races * fix(workspaces): accept peer renames from provenance-less hosts The old-host preserve fence kept a pinned local label on every refresh, which also suppressed a legitimate rename another client persisted through the same host until app restart. Narrow it to labels the host re-derived itself (branch short name, or path basename when detached); any other changed label in a mode-less response is explicit meta a peer wrote there. Stale prior-label responses stay covered by the downstream staleness fence, in-flight writes by the pending fence. * refactor(workspaces): unify display-name pin derivation Three call sites (renderer optimistic update, local IPC updateMeta handler, remote worktree.set handler) each restated the same formula; a future edit to one would silently skew provenance between paths. --- config/tsconfig.tc.web.json | 1 + .../src/tasks/blank-workspace-create.test.ts | 4 +- mobile/src/tasks/blank-workspace-create.ts | 3 + .../mobile-tasks-refactor-parity.test.ts | 8 +- .../src/tasks/source-workspace-create.test.ts | 65 ++++- mobile/src/tasks/source-workspace-create.ts | 41 +++- ...-mobile-tasks-workspace-create-actions.tsx | 18 +- .../src/tasks/workspace-create-params.test.ts | 27 ++ mobile/src/tasks/workspace-create-params.ts | 9 +- .../src/tasks/worktree-create-retry.test.ts | 43 +++- mobile/src/tasks/worktree-create-retry.ts | 13 +- src/cli/handlers/worktree.ts | 5 +- src/cli/index-worktree-create-agent.test.ts | 6 + src/cli/index-worktree-create-linear.test.ts | 4 + src/cli/index-worktree-create-parent.test.ts | 16 ++ src/cli/index-worktree-create-target.test.ts | 6 + src/main/index.ts | 3 + src/main/ipc/worktree-display-name.ts | 95 ++++++++ src/main/ipc/worktree-logic.test.ts | 100 ++++++++ src/main/ipc/worktree-logic.ts | 31 +-- src/main/ipc/worktree-metadata-merge.test.ts | 41 ++++ src/main/ipc/worktree-metadata-merge.ts | 18 +- src/main/ipc/worktree-remote.ts | 48 ++-- ...ktrees-create-metadata-persistence.test.ts | 37 +++ .../create/folder-workspace-creation.ts | 13 +- .../register-worktree-metadata-handlers.ts | 3 + .../provisioned-root-ssh-adoption.test.ts | 27 ++ src/main/provisioned-root-ssh-adoption.ts | 32 ++- src/main/runtime/orca-runtime.test.ts | 3 + src/main/runtime/orca-runtime.ts | 40 ++- .../orchestration-federated-worker-start.ts | 1 + .../orchestration-federation-start-schema.ts | 1 + .../methods/orchestration-federation.test.ts | 13 + .../rpc/methods/orchestration-federation.ts | 1 + .../methods/orchestration-worker-topology.ts | 1 + ...orchestration-workers-new-worktree.test.ts | 1 + .../rpc/methods/worktree-create-args.test.ts | 13 + .../rpc/methods/worktree-create-args.ts | 1 + .../rpc/methods/worktree-create-schemas.ts | 1 + .../rpc/methods/worktree-schemas.test.ts | 9 + src/main/runtime/rpc/methods/worktree.test.ts | 27 ++ src/main/runtime/rpc/methods/worktree.ts | 4 + .../composer-state/full-creation-execution.ts | 4 + .../composer-state/full-submit-preparation.ts | 9 +- .../quick-creation-execution.ts | 2 + .../composer-state/quick-creation-request.ts | 2 + .../quick-submit-preparation.ts | 9 +- .../src/lib/pending-worktree-creation.ts | 1 + .../src/lib/worktree-creation-flow-execute.ts | 3 + .../worktree-meta-update-application.test.ts | 44 ++++ .../worktree-meta-update-application.ts | 13 +- .../worktrees-fetch-listing-merge.test.ts | 230 ++++++++++++++++++ ...orktrees-git-identity-branch-title.test.ts | 38 +++ .../worktrees-metadata-persistence.test.ts | 1 + .../create/worktree-create-payload.ts | 4 + .../listing/detected-worktree-meta.test.ts | 69 ++++++ .../listing/detected-worktree-meta.ts | 11 +- .../listing/fetched-worktree-merge.ts | 84 ++++++- .../metadata/update-worktree-meta.ts | 11 +- .../metadata/worktree-git-identity-update.ts | 6 +- .../metadata/worktree-meta-persist.ts | 64 ++++- .../src/web/preload-api/web-worktrees-api.ts | 1 + .../web-preload-api-workspace-catalog.test.ts | 4 + src/shared/worktree/create-types.ts | 2 + .../worktree/display-name-provenance.ts | 4 + src/shared/worktree/meta-types.ts | 2 + src/shared/worktree/types.ts | 2 + 67 files changed, 1353 insertions(+), 100 deletions(-) create mode 100644 src/main/ipc/worktree-display-name.ts create mode 100644 src/renderer/src/store/slices/worktree-meta-update-application.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts create mode 100644 src/shared/worktree/display-name-provenance.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 3dcc8b43a34..8ac4c7754bb 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -10,6 +10,7 @@ "../src/main/gitlab/mappers.ts", "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", + "../src/main/ipc/worktree-display-name.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/ipc/worktree-path-comparison.ts", diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index 1d3cf2c15eb..da2e187a303 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -28,7 +28,7 @@ function fakeClient(script: (method: string, call: number) => unknown, calls: Ca } describe('createBlankWorkspace', () => { - it('sends no agent-launch fields for a blank workspace', async () => { + it('pins a manually entered blank-workspace name and sends no agent-launch fields', async () => { const calls: Call[] = [] const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) @@ -51,6 +51,8 @@ describe('createBlankWorkspace', () => { repo: 'id:repo-1', setupDecision: 'inherit', name: 'octopus', + displayName: 'octopus', + displayNameKind: 'user', // Idempotency key so a create interrupted by a connection migration can be // safely retried without the host spawning a duplicate worktree. clientMutationId: expect.any(String) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index 77d0dccc4d3..3c38ac37447 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -32,6 +32,9 @@ export async function createBlankWorkspace(args: { repo: `id:${args.repoId}`, setupDecision: args.setupDecision, name, + ...(args.nameWasGenerated + ? { displayNameKind: 'generated' as const } + : { displayName: args.baseName, displayNameKind: 'user' as const }), ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), ...agentLaunchCreateFields(args.createdWithAgentId) } diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 78e5261ea3f..8d35398c3df 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -16,11 +16,11 @@ const hash = (parts: string[] | string): string => .update(Array.isArray(parts) ? parts.join('\n') : parts) .digest('hex') -const PRE_REFACTOR_SCREEN_HOOKS = '1d8e1b69c4ac80e5e035cc2ece62a88a73bb35b0b60af74f3e75e54db5df3f75' +const PRE_REFACTOR_SCREEN_HOOKS = '42174315a76c475d09dcb7209af4481f01258c4c9dc012127ff07a893d8cd291' const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const PRE_REFACTOR_STATEMENTS = 'ef0e5bd607a96ff11fb60cff285bc01dfcfeb804cd29a896cca107b09994b610' +const PRE_REFACTOR_STATEMENTS = '9323fbee7c3806f37de42578ba73ce659c786c0ed5f8b6bcbc321b201ca50a73' const PRE_REFACTOR_DECLARATIONS = 'cff54172af17a877789be1479c2eb6ca97d83c3e31dd831cd59395962f2b4c4a' -const PRE_REFACTOR_SEMANTICS = '60be4eee5513751530f98925828dc59e9775838842dafb5e5c7e9f25bf8a016d' +const PRE_REFACTOR_SEMANTICS = '5219d210d6f274e9ce2716a37c4c6fc4860a736a80f059ab6e89da6123043263' const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' const PRE_REFACTOR_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f' @@ -49,7 +49,7 @@ describe('Mobile Tasks refactor parity', () => { it('preserves RPC calls, runtime strings, and JSX host signatures', () => { const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_498) + expect(semantics.split('\n')).toHaveLength(3_499) expect(hash(semantics)).toBe(PRE_REFACTOR_SEMANTICS) }) diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index 66974f63443..a86a5b463c1 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -174,7 +174,62 @@ describe('createWorkspaceFromComposerSource', () => { }) }) - it('suppresses displayName when the name is user-edited (not auto-managed)', async () => { + it('does not pin an automatically managed branch selection without a custom label', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-auto-branch' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).not.toHaveProperty('displayName') + expect(calls[0]!.params).not.toHaveProperty('displayNameKind') + }) + + it('does not pin an auto-derived branch label even when the draft is populated', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-auto-branch-draft' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'topic' + } + + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: 'topic', + nameIsAutoManaged: true + }) + + expect(calls[0]!.params).not.toHaveProperty('displayName') + expect(calls[0]!.params).not.toHaveProperty('displayNameKind') + }) + + it('pins a custom label for a new branch selection', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-labeled-branch' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'feature/login' + } + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: ' Login work ' + }) + expect(calls[0]!.params).toMatchObject({ + displayName: 'Login work', + displayNameKind: 'user' + }) + }) + + it('pins displayName when the name is user-edited (not auto-managed)', async () => { const calls: Call[] = [] const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls) const selection: MobileComposerCreateSelection = { @@ -195,8 +250,12 @@ describe('createWorkspaceFromComposerSource', () => { workspaceName: 'my-name', nameIsAutoManaged: false }) - expect(calls[0]!.params.displayName).toBeUndefined() - expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 }) + expect(calls[0]!.params).toMatchObject({ + name: 'my-name', + displayName: 'my-name', + displayNameKind: 'user', + linkedIssue: 7 + }) }) it('creates a new branch off a ref, bumping the branch on collision', async () => { diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 53e13e68ea3..6e666005ac3 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -28,8 +28,8 @@ export type CreateWorkspaceFromComposerArgs = { setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined - note: string | undefined nameIsAutoManaged?: boolean + note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe } @@ -91,8 +91,8 @@ async function createWorkItemWorkspace(args: { setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined - note: string | undefined nameIsAutoManaged?: boolean + note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args @@ -150,12 +150,23 @@ async function createBranchWorkspace(args: { setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined + nameIsAutoManaged?: boolean note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { - const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const { + client, + selection, + targetRepoId, + setupDecision, + agent, + workspaceName, + nameIsAutoManaged, + note + } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice const comment = note?.trim() + const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim() const applyCommon = (params: Record): Record => { Object.assign(params, agentLaunchCreateFields(createdWithAgentId)) if (comment) { @@ -181,6 +192,9 @@ async function createBranchWorkspace(args: { applyCommon({ repo: `id:${targetRepoId}`, name, + ...(manualDisplayName + ? { displayName: manualDisplayName, displayNameKind: 'user' as const } + : {}), setupDecision, baseBranch: selection.refName, branchNameOverride: selection.localBranchName @@ -203,7 +217,10 @@ async function createBranchWorkspace(args: { repo: `id:${targetRepoId}`, name: candidate, setupDecision, - baseBranch: selection.baseBranch + baseBranch: selection.baseBranch, + ...(manualDisplayName + ? { displayName: manualDisplayName, displayNameKind: 'user' as const } + : {}) } if (selection.branchNameOverride) { params.branchNameOverride = candidate @@ -220,11 +237,22 @@ async function createNewBranchWorkspace(args: { setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined + nameIsAutoManaged?: boolean note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { - const { client, selection, targetRepoId, setupDecision, agent, note } = args + const { + client, + selection, + targetRepoId, + setupDecision, + agent, + workspaceName, + nameIsAutoManaged, + note + } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim() const comment = note?.trim() // A brand-new branch off the repo's default base. The typed name is kept as the // git branch (via branchNameOverride) so a slash like `feature/login` survives; @@ -240,6 +268,9 @@ async function createNewBranchWorkspace(args: { name: candidate, setupDecision, branchNameOverride: candidate, + ...(manualDisplayName + ? { displayName: manualDisplayName, displayNameKind: 'user' as const } + : {}), ...agentLaunchCreateFields(createdWithAgentId) } if (comment) { diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx index dd8b2effab8..91a7da4d234 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx @@ -39,7 +39,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod taskStateHydrated, tasksSupported, trustedOrcaHooks, - workspaceDetectedAgentIds + workspaceDetectedAgentIds, + workspaceLastAutoName } = model const createWorkspace = useCallback( async ( @@ -149,6 +150,9 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod }) return } + const trimmedWorkspaceName = workspaceNameOverride?.trim() ?? '' + const nameIsAutoManaged = + !trimmedWorkspaceName || trimmedWorkspaceName === workspaceLastAutoName let params: Record if (item.provider === 'github') { const source = item.source @@ -192,7 +196,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod baseBranch: baseBranchOverride, branchNameOverride, sparseCheckout: sparseCheckoutOverride, - hostedStartPoint: prStartPoint + hostedStartPoint: prStartPoint, + nameIsAutoManaged }) } else if (item.provider === 'gitlab') { const source = item.source @@ -236,7 +241,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod baseBranch: baseBranchOverride, branchNameOverride, sparseCheckout: sparseCheckoutOverride, - hostedStartPoint: mrStartPoint + hostedStartPoint: mrStartPoint, + nameIsAutoManaged }) } else { params = buildTaskWorkspaceCreateParams({ @@ -248,7 +254,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod note: comment, baseBranch: baseBranchOverride, branchNameOverride, - sparseCheckout: sparseCheckoutOverride + sparseCheckout: sparseCheckoutOverride, + nameIsAutoManaged }) } const response = await client.sendRequest('worktree.create', params, { @@ -289,7 +296,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod taskStateHydrated, tasksSupported, trustedOrcaHooks, - workspaceDetectedAgentIds + workspaceDetectedAgentIds, + workspaceLastAutoName ] ) return Object.assign(model, { createWorkspace }) diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index 157891131b8..36eed50d80c 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -41,6 +41,7 @@ describe('task workspace create params', () => { repo: 'id:repo-1', name: 'mobile-tasks', displayName: 'Fix mobile tasks', + displayNameKind: 'generated', setupDecision: 'run', activate: true, startupDraft: 'https://github.com/acme/app/pull/123', @@ -72,6 +73,7 @@ describe('task workspace create params', () => { repo: 'id:repo-1', name: 'issue-88', displayName: 'Investigate login', + displayNameKind: 'generated', setupDecision: 'skip', activate: true, linkedIssue: 88 @@ -80,6 +82,31 @@ describe('task workspace create params', () => { expect(params).not.toHaveProperty('createdWithAgent') }) + it('marks an edited task label as user-owned', () => { + const params = buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { + type: 'issue', + repoId: 'repo-1', + number: 88, + title: 'Investigate login', + url: 'https://github.com/acme/app/issues/88' + } + }, + targetRepoId: 'ignored-for-github', + setupDecision: 'skip', + workspaceName: 'My workspace', + nameIsAutoManaged: false + }) + + expect(params).toMatchObject({ + name: 'My workspace', + displayName: 'My workspace', + displayNameKind: 'user' + }) + }) + it('keeps the startup draft when no agent was provided so the host can auto-pick', () => { const params = buildTaskWorkspaceCreateParams({ item: { diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index 8693944230a..218c4fe37b0 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -108,8 +108,7 @@ export function buildTaskWorkspaceCreateParams(args: { const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget - // Why: desktop only sends displayName while the name is still auto-derived; a - // user-edited name suppresses it so the runtime keeps the user's chosen name. + // Preserve provenance so the host can distinguish an intentional label from a generated title. const sourceName = item.provider === 'linear' ? getWorkspaceSourceName({ @@ -121,7 +120,11 @@ export function buildTaskWorkspaceCreateParams(args: { linearIdentifier: item.source.identifier }) : getWorkspaceSourceName({ provider: item.provider, ...item.source }) - const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} + const displayName = nameIsAutoManaged + ? { displayName: sourceName.displayName, displayNameKind: 'generated' as const } + : workspaceName?.trim() + ? { displayName: workspaceName, displayNameKind: 'user' as const } + : {} const common = { setupDecision, activate: true, diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index b4463207ebe..beb9d463e32 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -55,7 +55,7 @@ async function flush(): Promise { // cutover). Records every call so tests can assert on the clientMutationId. function scriptedClient( outcomes: Array< - | { id: string } + | { id: string; displayName?: string } | { errorMessage: string } // takesMs models how long the ambiguity took to SURFACE — a clean close is // instant, a half-open socket waits out the liveness watchdog or the timeout. @@ -103,7 +103,12 @@ function scriptedClient( return { id: '1', ok: true, - result: { worktree: { id: outcome.id } }, + result: { + worktree: { + id: outcome.id, + ...(outcome.displayName !== undefined ? { displayName: outcome.displayName } : {}) + } + }, _meta: { runtimeId: 'r' } } } @@ -227,6 +232,40 @@ describe('createWorktreeWithNameRetry', () => { expect(attempts[1]!.params.name).toBe('topic-2') }) + it('uses the host-selected display name after a collision retry', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ errorMessage: 'already exists locally' }, { id: 'wt-host-name', displayName: 'topic-3' }], + attempts + ) + + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'topic', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: false + }) + ).resolves.toEqual({ worktreeId: 'wt-host-name', name: 'topic-3' }) + }) + + it('falls back to the client candidate when an older host omits displayName', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ errorMessage: 'already exists locally' }, { id: 'wt-legacy' }], + attempts + ) + + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'topic', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: false + }) + ).resolves.toEqual({ worktreeId: 'wt-legacy', name: 'topic-2' }) + }) + it('advances generated retries without nesting suffixes', async () => { const attempts: Attempt[] = [] const client = scriptedClient( diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 971dfa759cf..a3fa8ae2e1b 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -82,8 +82,17 @@ export async function createWorktreeWithNameRetry( : candidateParams const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency) if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - return { worktreeId: result.worktree.id, name: candidateName } + const result = (response as RpcSuccess).result as { + worktree: { id: string; displayName?: string } + } + const authoritativeName = result.worktree.displayName + return { + worktreeId: result.worktree.id, + name: + typeof authoritativeName === 'string' && authoritativeName.trim() + ? authoritativeName + : candidateName + } } lastError = response.error.message if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 3b951672a71..484bcf9ea6d 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -230,9 +230,12 @@ export const WORKTREE_HANDLERS: Record = { } const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue') const activate = flags.get('activate') === true || flags.get('run-hooks') === true + const name = getRequiredStringFlag(flags, 'name') const result = await client.call('worktree.create', { repo: await getCreateRepoSelector(flags, cwdParentWorktree, client), - name: getRequiredStringFlag(flags, 'name'), + name, + displayName: name, + displayNameKind: 'user', baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), ...linearIssueLink, diff --git a/src/cli/index-worktree-create-agent.test.ts b/src/cli/index-worktree-create-agent.test.ts index 025275c28ce..f1d79ef21df 100644 --- a/src/cli/index-worktree-create-agent.test.ts +++ b/src/cli/index-worktree-create-agent.test.ts @@ -72,6 +72,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', + displayName: 'feature', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -122,6 +124,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'agent-task', + displayName: 'agent-task', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -169,6 +173,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'agent-task', + displayName: 'agent-task', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, diff --git a/src/cli/index-worktree-create-linear.test.ts b/src/cli/index-worktree-create-linear.test.ts index e05e9dd3078..3c36f9239bc 100644 --- a/src/cli/index-worktree-create-linear.test.ts +++ b/src/cli/index-worktree-create-linear.test.ts @@ -87,6 +87,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', + displayName: 'feature', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, linkedLinearIssue: 'STA-335', @@ -137,6 +139,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('worktree.create', { repo: 'id:repo-1', name: 'feature', + displayName: 'feature', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, linkedLinearIssue: 'STA-335', diff --git a/src/cli/index-worktree-create-parent.test.ts b/src/cli/index-worktree-create-parent.test.ts index 196de823736..ba9532e0918 100644 --- a/src/cli/index-worktree-create-parent.test.ts +++ b/src/cli/index-worktree-create-parent.test.ts @@ -106,6 +106,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -152,6 +154,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -219,6 +223,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -263,6 +269,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -308,6 +316,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -369,6 +379,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -517,6 +529,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -559,6 +573,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, diff --git a/src/cli/index-worktree-create-target.test.ts b/src/cli/index-worktree-create-target.test.ts index 840aea90f1e..630b5f3c664 100644 --- a/src/cli/index-worktree-create-target.test.ts +++ b/src/cli/index-worktree-create-target.test.ts @@ -72,6 +72,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', + displayName: 'feature', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -147,6 +149,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-gpu', name: 'feature', + displayName: 'feature', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, @@ -259,6 +263,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'child', + displayName: 'child', + displayNameKind: 'user', baseBranch: undefined, linkedIssue: undefined, comment: undefined, diff --git a/src/main/index.ts b/src/main/index.ts index 9b8c514f54d..f1cb0fd6cd8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -668,6 +668,9 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { } currentStore.setWorktreeMeta(worktreeId, { displayName, + // The first-agent title is an intentional user-facing label; keep it stable after the + // generated branch is renamed and across subsequent catalog refreshes. + displayNameIsPinned: true, pendingFirstAgentMessageRename: false, // Success clears the failure badge (redundant with the explicit setRenameError(null)). firstAgentMessageRenameError: null diff --git a/src/main/ipc/worktree-display-name.ts b/src/main/ipc/worktree-display-name.ts new file mode 100644 index 00000000000..72902f07b27 --- /dev/null +++ b/src/main/ipc/worktree-display-name.ts @@ -0,0 +1,95 @@ +import type { CreateWorktreeArgs } from '../../shared/worktree/create-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +type DisplayNameKind = CreateWorktreeArgs['displayNameKind'] + +export function sanitizeWorktreeDisplayName(input: string): string | undefined { + const withoutControls = Array.from(input, (char) => { + const code = char.charCodeAt(0) + return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char + }).join('') + const sanitized = withoutControls + // Why: titles come from external systems; bidi overrides could visually reorder sidebar text. + .replace(/[\u202a-\u202e\u2066-\u2069]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + .trim() + + return sanitized || undefined +} + +export function resolveWorktreeCreateDisplayName( + input: string | undefined, + kind: DisplayNameKind +): string | undefined { + if (!input) { + return undefined + } + if (kind !== 'user') { + return sanitizeWorktreeDisplayName(input) + } + const safe = Array.from(input, (char) => { + const code = char.charCodeAt(0) + return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char + }) + .join('') + .replace(/[\u202a-\u202e\u2066-\u2069]/g, '') + .trim() + return safe || undefined +} + +/** Resolve the create label, including the pre-provenance CLI contract. */ +export function resolveWorktreeCreateDisplayNameRequest( + input: string | undefined, + kind: DisplayNameKind, + fallbackName: string, + cliCreated: boolean, + nameWasGenerated = false +): { value: string | undefined; kind: DisplayNameKind } { + // The CLI name is always an explicit command argument; its marker wins over a + // missing or malformed kind so a future client cannot make it auto-managed. + // Legacy clients omitted displayNameKind: an explicit displayName was the artifact-title + // contract, while a name-only request was user-entered unless marked as generated. + const effectiveKind = cliCreated + ? 'user' + : (kind ?? (input !== undefined || nameWasGenerated ? 'generated' : 'user')) + const effectiveInput = input ?? (effectiveKind === 'user' ? fallbackName : undefined) + return { + value: resolveWorktreeCreateDisplayName(effectiveInput, effectiveKind), + kind: effectiveKind + } +} + +export function resolveWorktreeCreateDisplayNameMeta( + requestedDisplayName: string | undefined, + branchName: string, + kind: DisplayNameKind, + fallback: { requestedName: string; sanitizedName: string } +): Partial> { + if (requestedDisplayName !== undefined) { + // Generated labels equal to their branch stay automatic; user labels remain fixed even when equal. + if (kind !== 'user' && requestedDisplayName === branchName) { + return {} + } + return { displayName: requestedDisplayName, displayNameIsPinned: true } + } + // A user label that sanitizes away is an empty label, so keep the generated fallback automatic. + if (kind === 'user') { + return { displayNameIsPinned: false } + } + if (fallback.requestedName === branchName) { + return { displayName: fallback.requestedName, displayNameIsPinned: false } + } + return shouldSetDisplayName(fallback.requestedName, branchName, fallback.sanitizedName) + ? { displayName: fallback.requestedName, displayNameIsPinned: true } + : {} +} + +export function shouldSetDisplayName( + requestedName: string, + branchName: string, + sanitizedName: string +): boolean { + return !(branchName === requestedName && sanitizedName === requestedName) +} diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index ae76b28da0c..27e3f9f1a69 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it } from 'vitest' import { sanitizeWorktreeName, sanitizeWorktreeDisplayName, + resolveWorktreeCreateDisplayName, + resolveWorktreeCreateDisplayNameRequest, + resolveWorktreeCreateDisplayNameMeta, ensurePathWithinWorkspace, computeBranchName, getConfiguredBranchPrefix, @@ -128,6 +131,101 @@ describe('sanitizeWorktreeDisplayName', () => { it('returns undefined when nothing displayable remains', () => { expect(sanitizeWorktreeDisplayName('\u0000\n\t')).toBeUndefined() }) + + it('returns undefined for an unusable user label', () => { + expect(resolveWorktreeCreateDisplayName('\u0000\u202e', 'user')).toBeUndefined() + }) +}) + +describe('worktree create display-name provenance', () => { + it('recovers the name-only contract from an older CLI request', () => { + expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', true)).toEqual({ + value: 'feature', + kind: 'user' + }) + }) + + it('recovers a legacy name-only user create without CLI provenance', () => { + expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', false)).toEqual( + { + value: 'feature', + kind: 'user' + } + ) + expect( + resolveWorktreeCreateDisplayNameMeta('feature', 'feature', 'user', { + requestedName: 'feature', + sanitizedName: 'feature' + }) + ).toEqual({ displayName: 'feature', displayNameIsPinned: true }) + }) + + it('keeps a legacy generated name automatic when nameWasGenerated is set', () => { + expect( + resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'nautilus', false, true) + ).toEqual({ + value: undefined, + kind: 'generated' + }) + }) + + it('keeps a legacy artifact display name generated when its kind is absent', () => { + expect( + resolveWorktreeCreateDisplayNameRequest('Issue title', undefined, 'feature', false) + ).toEqual({ value: 'Issue title', kind: 'generated' }) + }) + + it('treats a CLI name as intentional even if a caller supplies generated provenance', () => { + expect( + resolveWorktreeCreateDisplayNameRequest('Agent label', 'generated', 'feature', true) + ).toEqual({ value: 'Agent label', kind: 'user' }) + }) + + it('preserves exact user text apart from edge whitespace and controls', () => { + expect(resolveWorktreeCreateDisplayName(' My Label\n', 'user')).toBe('My Label') + }) + + it('pins user labels without adding collision suffixes to visible text', () => { + expect( + resolveWorktreeCreateDisplayNameMeta('My Label', 'my-label-2', 'user', { + requestedName: 'My Label', + sanitizedName: 'my-label-2' + }) + ).toEqual({ displayName: 'My Label', displayNameIsPinned: true }) + }) + + it('keeps generated labels automatic only when they equal the branch', () => { + expect( + resolveWorktreeCreateDisplayNameMeta('Issue title', 'feature-2', 'generated', { + requestedName: 'feature-2', + sanitizedName: 'feature-2' + }) + ).toEqual({ displayName: 'Issue title', displayNameIsPinned: true }) + expect( + resolveWorktreeCreateDisplayNameMeta('feature-2', 'feature-2', 'generated', { + requestedName: 'feature-2', + sanitizedName: 'feature-2' + }) + ).toEqual({}) + }) + + it('keeps a slashy branch label automatic when only its folder is sanitized', () => { + expect( + resolveWorktreeCreateDisplayNameMeta(undefined, 'feature/login', undefined, { + requestedName: 'feature/login', + sanitizedName: 'feature-login' + }) + ).toEqual({ displayName: 'feature/login', displayNameIsPinned: false }) + }) + + it('keeps a user label that sanitizes away automatic', () => { + expect( + resolveWorktreeCreateDisplayNameMeta(undefined, 'feature-2', 'user', { + requestedName: 'feature', + sanitizedName: 'feature-2' + }) + ).toEqual({ displayNameIsPinned: false }) + }) }) describe('ensurePathWithinWorkspace', () => { @@ -459,6 +557,7 @@ describe('mergeWorktree', () => { it('merges with full metadata', () => { const meta = { displayName: 'My Feature', + displayNameIsPinned: true, comment: 'WIP', linkedIssue: 42, linkedPR: 10, @@ -500,6 +599,7 @@ describe('mergeWorktree', () => { isBare: false, isMainWorktree: false, displayName: 'My Feature', + displayNameMode: 'fixed', comment: 'WIP', linkedIssue: 42, linkedPR: 10, diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index c71d132bba5..e32ce95cca5 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -61,22 +61,13 @@ function containsEmoji(input: string): boolean { ) } -export function sanitizeWorktreeDisplayName(input: string): string | undefined { - const withoutControls = Array.from(input, (char) => { - const code = char.charCodeAt(0) - return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char - }).join('') - const sanitized = withoutControls - // Why: titles come from external systems. Strip bidi override controls so a - // malicious title cannot visually reorder adjacent sidebar text. - .replace(/[\u202a-\u202e\u2066-\u2069]/g, '') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 120) - .trim() - - return sanitized || undefined -} +export { + resolveWorktreeCreateDisplayName, + resolveWorktreeCreateDisplayNameRequest, + resolveWorktreeCreateDisplayNameMeta, + sanitizeWorktreeDisplayName, + shouldSetDisplayName +} from './worktree-display-name' /** * Ensure a target path is within the workspace directory (prevent path traversal). @@ -283,14 +274,6 @@ function shouldMirrorWorkspaceDirInsideWsl(repoPath: string, workspaceDir: strin * A display name is set only when the user's requested name differs from * both the branch name and the sanitized name (i.e. it was modified). */ -export function shouldSetDisplayName( - requestedName: string, - branchName: string, - sanitizedName: string -): boolean { - return !(branchName === requestedName && sanitizedName === requestedName) -} - /** * Parse a composite worktreeId ("repoId::worktreePath") into its parts. */ diff --git a/src/main/ipc/worktree-metadata-merge.test.ts b/src/main/ipc/worktree-metadata-merge.test.ts index 4bb5eee99a6..099ec56a0aa 100644 --- a/src/main/ipc/worktree-metadata-merge.test.ts +++ b/src/main/ipc/worktree-metadata-merge.test.ts @@ -12,6 +12,47 @@ const git: GitWorktreeInfo = { } describe('mergeWorktree identity projection', () => { + it('re-derives an automatic display name from the current branch', () => { + const worktree = mergeWorktree( + 'repo-1', + { ...git, branch: 'refs/heads/main' }, + { + displayName: 'feature', + displayNameIsPinned: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + ) + + expect(worktree.displayName).toBe('main') + expect(worktree.displayNameMode).toBe('automatic') + }) + + it('treats legacy CLI labels as fixed display names', () => { + const worktree = mergeWorktree('repo-1', git, { + displayName: 'feature', + cliProvenance: { kind: 'created-by-cli', createdAt: 1 }, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + }) + + expect(worktree.displayNameMode).toBe('fixed') + }) + it('publishes canonical identity when host and instance metadata are known', () => { const worktree = mergeWorktree('repo-1', git, { instanceId: '11111111-1111-4111-8111-111111111111', diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index bcafef1f4c4..7cd2e296ffd 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -18,6 +18,10 @@ export function mergeWorktree( const branchShort = git.branch.replace(/^refs\/heads\//, '') const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance) const worktreeId = `${repoId}::${git.path}` + const automaticDisplayName = branchShort || defaultDisplayName || basename(git.path) + // CLI-created labels predate displayNameIsPinned but are still explicit names. + const legacyCliDisplayNameIsPinned = + meta?.displayNameIsPinned === undefined && meta?.cliProvenance?.kind === 'created-by-cli' return { id: worktreeId, ...(meta?.instanceId && meta.hostId @@ -46,7 +50,19 @@ export function mergeWorktree( isBare: git.isBare, ...(git.isSparse === true ? { isSparse: true } : {}), isMainWorktree: git.isMainWorktree, - displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), + // Automatic labels follow the live branch; persisted values are only authoritative when pinned. + displayName: + meta?.displayNameIsPinned === false + ? automaticDisplayName + : meta?.displayName || automaticDisplayName, + displayNameMode: + meta?.displayNameIsPinned === true || legacyCliDisplayNameIsPinned + ? 'fixed' + : meta?.displayNameIsPinned === false + ? 'automatic' + : meta?.displayName && meta.displayName.trim() !== branchShort + ? 'fixed' + : 'automatic', comment: meta?.comment || '', linkedIssue: meta?.linkedIssue ?? null, linkedPR: meta?.linkedPR ?? null, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 5e1f004972b..6fa40e5b1d1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -78,7 +78,8 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { } import { sanitizeWorktreeName, - sanitizeWorktreeDisplayName, + resolveWorktreeCreateDisplayNameRequest, + resolveWorktreeCreateDisplayNameMeta, computeValidatedBranchName, computeWorktreePath, computeRemoteWorktreePath, @@ -87,7 +88,6 @@ import { getWorktreeCreationLayout, getWorktreePathSettings, hasRepoWorktreeBasePath, - shouldSetDisplayName, mergeWorktree } from './worktree-logic' import { findCreatedWorktree, resolveCreatedWorktree } from './created-worktree-reconciliation' @@ -1546,9 +1546,14 @@ export async function createRemoteWorktree( let effectiveRequestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) let effectiveSanitizedName = sanitizedName - const requestedDisplayName = args.displayName - ? sanitizeWorktreeDisplayName(args.displayName) - : undefined + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true + ) + const requestedDisplayName = displayNameRequest.value // Why: base resolution probes refs via generic git.exec; register the repo root first so relays don't report a valid base as stale. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path]) @@ -1878,11 +1883,12 @@ export async function createRemoteWorktree( baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), - ...(requestedDisplayName - ? { displayName: requestedDisplayName } - : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) - ? { displayName: effectiveRequestedName } - : {}), + ...resolveWorktreeCreateDisplayNameMeta( + requestedDisplayName, + branchName, + displayNameRequest.kind, + { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } + ), ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent) ? { pendingFirstAgentMessageRename: true } @@ -2021,9 +2027,14 @@ export async function createLocalWorktree( const requestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) - const requestedDisplayName = args.displayName - ? sanitizeWorktreeDisplayName(args.displayName) - : undefined + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true + ) + const requestedDisplayName = displayNameRequest.value // Why: explicit branches and non-username prefix modes never consume this; skipping the probe preserves the exact generated branch name. // Username and base resolution are independent read-only probes. Starting // both before awaiting removes one serial git/config round trip from create. @@ -2551,11 +2562,12 @@ export async function createLocalWorktree( baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), - ...(requestedDisplayName - ? { displayName: requestedDisplayName } - : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) - ? { displayName: effectiveRequestedName } - : {}), + ...resolveWorktreeCreateDisplayNameMeta( + requestedDisplayName, + branchName, + displayNameRequest.kind, + { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } + ), ...(sparseDirectories.length > 0 ? { sparseDirectories, diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 1aeff9627b4..e79df5c5738 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -253,6 +253,20 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') }) + it('persists display-name provenance at the host boundary', () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { displayName: 'Agent label' } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.objectContaining({ displayName: 'Agent label', displayNameIsPinned: true }) + ) + }) + it('does not trust renderer-authored automation provenance during local create', async () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) listWorktreesMock.mockResolvedValue([ @@ -319,6 +333,29 @@ describe('registerWorktreeHandlers', () => { }) }) + it('pins a legacy name-only user create when the branch matches', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature' + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/feature', + expect.objectContaining({ displayName: 'feature', displayNameIsPinned: true }) + ) + }) + it('persists linked issue and PR metadata during local create', async () => { listWorktreesMock.mockResolvedValue([ { diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts index 0d50cb2e565..c870ef734eb 100644 --- a/src/main/ipc/worktrees/create/folder-workspace-creation.ts +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -5,6 +5,7 @@ import type { CreateWorktreeResult } from '../../../../shared/worktree/create-ty import type { Store } from '../../../persistence/loading-store/store' import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model' +import { resolveWorktreeCreateDisplayNameRequest } from '../../worktree-logic' export function createFolderWorkspace( args: CreateWorktreeArgsWithSystemProvenance, @@ -14,12 +15,22 @@ export function createFolderWorkspace( const now = Date.now() const instanceId = randomUUID() const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true + ) const meta = store.setWorktreeMeta(worktreeId, { instanceId, ...(store.getProjectHostSetups ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) : {}), - displayName: args.displayName || args.name, + displayName: displayNameRequest.value || args.name, + ...(displayNameRequest.kind === 'user' && displayNameRequest.value + ? { displayNameIsPinned: true } + : {}), lastActivityAt: now, createdAt: now, orcaCreatedAt: now, diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index 359bd87f67d..c0600107a18 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance' import { parseExecutionHostId } from '../../../../shared/execution-host' import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' @@ -39,6 +40,8 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v validatedUpdates.displayName !== undefined ? { ...validatedUpdates, + // The host persists provenance; do not rely on renderer-authored metadata. + displayNameIsPinned: displayNameUpdatePinsLabel(validatedUpdates.displayName), pendingFirstAgentMessageRename: false, firstAgentMessageRenameError: null } diff --git a/src/main/provisioned-root-ssh-adoption.test.ts b/src/main/provisioned-root-ssh-adoption.test.ts index 76b3786dddb..1927e072880 100644 --- a/src/main/provisioned-root-ssh-adoption.test.ts +++ b/src/main/provisioned-root-ssh-adoption.test.ts @@ -73,6 +73,33 @@ describe('adoptProvisionedRootSshCheckout', () => { }) }) + it('pins an explicit label even when it equals the adopted branch', async () => { + seedRuntime(userDataPath, projectRoot) + registerSshGitProvider(connectionId, { + listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]), + exec: sparseCheckoutProbe(false) + } as never) + const { store, setWorktreeMeta } = makeStore() + + const result = await adoptProvisionedRootSshCheckout({ + userDataPath, + request: { + ...request(projectRoot), + displayName: 'fix-sandbox', + displayNameKind: 'user' + }, + repo: repo(projectRoot), + store, + isRepoCurrent: () => true + }) + + expect(result.worktree.displayName).toBe('fix-sandbox') + expect(setWorktreeMeta).toHaveBeenCalledWith( + `repo-1::${projectRoot}`, + expect.objectContaining({ displayName: 'fix-sandbox', displayNameIsPinned: true }) + ) + }) + it('rejects a recipe checkout on a branch Orca did not request', async () => { seedRuntime(userDataPath, projectRoot) registerSshGitProvider(connectionId, { diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index 6a12c63b4a2..c2e2dfe2c4b 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -23,7 +23,12 @@ import { isCurrentSshProviderAuthority } from './ssh/ssh-provider-authority' import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment' -import { getWorktreeCreationLayout, mergeWorktree } from './ipc/worktree-logic' +import { + getWorktreeCreationLayout, + mergeWorktree, + resolveWorktreeCreateDisplayNameMeta, + resolveWorktreeCreateDisplayNameRequest +} from './ipc/worktree-logic' type AdoptionArgs = AdoptProvisionedRootArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -110,7 +115,13 @@ export async function adoptProvisionedRootSshCheckout(args: { const now = Date.now() const meta = store.setWorktreeMeta( worktreeId, - buildProvisionedRootMeta(store, repo, request, now) + buildProvisionedRootMeta( + store, + repo, + request, + gitWorktree.branch.replace(/^refs\/heads\//, ''), + now + ) ) return { worktree: mergeWorktree(repo.id, gitWorktree, meta) } } @@ -155,8 +166,22 @@ function buildProvisionedRootMeta( store: Store, repo: Repo, args: AdoptionArgs, + branchName: string, now: number ): Partial { + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + false, + args.nameWasGenerated === true + ) + const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( + displayNameRequest.value, + branchName, + displayNameRequest.kind, + { requestedName: args.name, sanitizedName: args.name } + ) return { instanceId: randomUUID(), ...(store.getProjectHostSetups @@ -164,7 +189,8 @@ function buildProvisionedRootMeta( : {}), hostId: args.executionHostId, ephemeralVmCheckoutMode: 'provisioned-root', - displayName: args.displayName || args.name, + displayName: displayNameMeta.displayName ?? args.name, + ...displayNameMeta, lastActivityAt: now, createdAt: now, orcaCreatedAt: now, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index aa8280b94b6..57cb6ef889b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5320,6 +5320,8 @@ describe('OrcaRuntimeService', () => { const result = await runtime.createManagedWorktree({ repoSelector: 'id:folder-repo', name: 'folder-session', + displayName: '\u0000\u202e', + displayNameKind: 'user', createdWithAgent: 'codex', startup: { command: 'codex', viewMode: 'chat' } }) @@ -5345,6 +5347,7 @@ describe('OrcaRuntimeService', () => { orcaCreationSource: 'runtime', createdWithAgent: 'codex' }) + expect(metaById[result.worktree.id]).not.toHaveProperty('displayNameIsPinned') await expect(runtime.showManagedWorktree(`id:${result.worktree.id}`)).resolves.toMatchObject({ id: result.worktree.id, repoId: 'folder-repo', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bcc4dfbd836..405e87ef4bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -413,6 +413,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../../shared/worktree/base-ref-drift-types' import type { + CreateWorktreeArgs, CreateWorktreeResult, ForceDeleteWorktreeBranchResult, RemoveWorktreeResult @@ -1248,7 +1249,8 @@ import { isOrphanedWorktreeError, mergeWorktree, sanitizeWorktreeName, - shouldSetDisplayName, + resolveWorktreeCreateDisplayNameRequest, + resolveWorktreeCreateDisplayNameMeta, areWorktreePathsEqual } from '../ipc/worktree-logic' import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation' @@ -26993,6 +26995,7 @@ export class OrcaRuntimeService { linkedTaskSourceContext?: TaskSourceContext | null comment?: string displayName?: string + displayNameKind?: CreateWorktreeArgs['displayNameKind'] telemetrySource?: WorkspaceCreateTelemetrySource workspaceStatus?: string manualOrder?: number @@ -27066,10 +27069,21 @@ export class OrcaRuntimeService { const settings = createSettings const instanceId = randomUUID() const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true + ) + const resolvedFolderDisplayName = displayNameRequest.value const meta = this.store.setWorktreeMeta(worktreeId, { instanceId, ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), - displayName: args.displayName?.trim() || args.name, + displayName: resolvedFolderDisplayName ?? args.name, + ...(displayNameRequest.kind === 'user' && resolvedFolderDisplayName + ? { displayNameIsPinned: true } + : {}), lastActivityAt: now, createdAt: now, orcaCreatedAt: now, @@ -27261,7 +27275,14 @@ export class OrcaRuntimeService { } const hostedReviewExecutionContext = this.getHostedReviewExecutionOptions(repo) let effectiveRequestedName = args.name - const requestedDisplayName = args.displayName?.trim() || undefined + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + args.cliProvenance?.kind === 'created-by-cli', + args.nameWasGenerated === true + ) + const requestedDisplayName = displayNameRequest.value const sanitizedName = sanitizeWorktreeName(args.name) let effectiveSanitizedName = sanitizedName // Username and base resolution are independent read-only probes. Starting @@ -27704,11 +27725,12 @@ export class OrcaRuntimeService { // Why: PR/MR-created worktrees can start from a head ref/SHA while Source // Control must compare against the review target branch. const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch - const displayNameMeta = requestedDisplayName - ? { displayName: requestedDisplayName } - : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) - ? { displayName: effectiveRequestedName } - : {} + const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( + requestedDisplayName, + branchName, + displayNameRequest.kind, + { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } + ) const meta = this.store.setWorktreeMeta(worktreeId, { // Why: worktree IDs are path-derived. If a path is deleted outside Orca // and later recreated, creation must mint a fresh instance identity so @@ -28158,6 +28180,7 @@ export class OrcaRuntimeService { linkedTaskSourceContext?: TaskSourceContext | null comment?: string displayName?: string + displayNameKind?: CreateWorktreeArgs['displayNameKind'] workspaceStatus?: string manualOrder?: number sparseCheckout?: { directories: string[]; presetId?: string } @@ -28195,6 +28218,7 @@ export class OrcaRuntimeService { name: args.name, ...(args.nameWasGenerated === true ? { nameWasGenerated: true } : {}), ...(args.displayName ? { displayName: args.displayName } : {}), + ...(args.displayNameKind ? { displayNameKind: args.displayNameKind } : {}), ...(args.baseBranch ? { baseBranch: args.baseBranch } : {}), ...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}), ...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}), diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 0f6bd44f67f..9466b904b5d 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -160,6 +160,7 @@ export async function startFederatedWorker(args: { repo: params.repo, baseBranch: params.baseBranch, displayName: params.displayName, + ...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}), comment: params.comment, setup: createsWorktree ? (params.setup ?? 'run') : undefined, setupSource: createsWorktree diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts index 61bf385b782..514f282e322 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -14,6 +14,7 @@ export const FederationAttachStartParams = z.object({ repo: OptionalString, baseBranch: OptionalString, displayName: OptionalString, + displayNameKind: z.enum(['generated', 'user']).optional(), comment: OptionalString, setup: z.enum(['run', 'skip', 'inherit']).optional(), setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts index 59dd8a7a5f8..da92ac1f57a 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -211,6 +211,19 @@ describe('orchestration federation', () => { ) }) + it('carries an explicit worker label as user display-name provenance', async () => { + const task = createHomeTask() + + await homeDispatcher.dispatch(startRequest(task.id, { displayName: 'Windows release audit' })) + + expect(workerRuntime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + displayName: 'Windows release audit', + displayNameKind: 'user' + }) + ) + }) + it('does not report remotely rejected preferences as effective', async () => { const task = createHomeTask() diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index 421bf51586e..a046f5cb7b0 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -98,6 +98,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ name: params.name as string, baseBranch: params.baseBranch, displayName: params.displayName, + displayNameKind: params.displayNameKind, comment: params.comment, // setupDecision runs setup without the legacy runHooks activation side effect. runHooks: false, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 3d4a0b6bf55..582d32058a8 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -135,6 +135,7 @@ export async function createWorkerWorktree(args: { name: params.name as string, baseBranch: params.baseBranch, displayName: params.displayName, + ...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}), comment: params.comment, // setupDecision runs setup without the legacy runHooks activation side effect. runHooks: false, diff --git a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts index 2410a74c10f..c0ae7d5edd0 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts @@ -252,6 +252,7 @@ describe('orchestration new-worktree workers', () => { repoSelector: 'id:repo-explicit', baseBranch: 'origin/release', displayName: 'Windows release audit', + displayNameKind: 'user', comment: 'Created for a supervised audit', setupDecision: 'skip', runHooks: false, diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index 5a55d54460f..af74848a1e1 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -27,6 +27,19 @@ describe('buildManagedWorktreeCreateArgs', () => { }) }) + it('keeps the legacy CLI marker on a name-only create request', () => { + const args = buildManagedWorktreeCreateArgs( + WorktreeCreate.parse({ repo: 'id:repo-1', name: 'feature' }), + { ...PROVENANCE, cliProvenance: { kind: 'created-by-cli', createdAt: 1 } } + ) + + expect(args).toMatchObject({ + name: 'feature', + cliProvenance: { kind: 'created-by-cli', createdAt: 1 } + }) + expect(args.displayName).toBeUndefined() + }) + it('carries the parent-pick provenance only when the client marked it manual', () => { // Why: older clients never send it, and those creates really are CLI-flag equivalents. expect( diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index ac674f08d61..932659758bb 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -39,6 +39,7 @@ export function buildManagedWorktreeCreateArgs( linkedTaskSourceContext: params.linkedTaskSourceContext, comment: params.comment, displayName: params.displayName, + displayNameKind: params.displayNameKind, telemetrySource: params.telemetrySource, workspaceStatus: params.workspaceStatus, manualOrder: params.manualOrder, diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index e296b9c3ace..61f6eb65e35 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -45,6 +45,7 @@ export const WorktreeCreate = z linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), comment: OptionalString, displayName: OptionalString, + displayNameKind: z.enum(['generated', 'user']).optional(), telemetrySource: z .unknown() .transform((value) => { diff --git a/src/main/runtime/rpc/methods/worktree-schemas.test.ts b/src/main/runtime/rpc/methods/worktree-schemas.test.ts index 8beefc28903..8172a3cd6b0 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.test.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.test.ts @@ -3,6 +3,15 @@ import { WorktreeCreate } from './worktree-create-schemas' import { WorktreeActivate, WorktreeSet } from './worktree-schemas' describe('worktree RPC schemas', () => { + it('accepts optional display-name provenance values', () => { + expect( + WorktreeCreate.parse({ repo: 'repo-1', name: 'feature', displayNameKind: 'user' }) + ).toMatchObject({ displayNameKind: 'user' }) + expect( + WorktreeCreate.parse({ repo: 'repo-1', name: 'feature', displayNameKind: 'generated' }) + ).toMatchObject({ displayNameKind: 'generated' }) + }) + it('validates additive navigation intent', () => { expect(WorktreeActivate.parse({ worktree: 'id:wt-1', navigation: 'clients' }).navigation).toBe( 'clients' diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 79171b927a3..3b62cbfd7cd 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -147,6 +147,33 @@ describe('worktree RPC methods', () => { }) }) + it('keeps the legacy CLI name-only create shape explicit at the host boundary', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: passthroughDedupe, + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-cli' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + cliProvenanceRequest: {} + }) + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'feature', + displayName: undefined, + displayNameKind: undefined, + cliProvenance: expect.objectContaining({ kind: 'created-by-cli' }) + }) + ) + }) + it('mints automation provenance from a valid dispatch request on worktree creation', async () => { const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1') const runtime = { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 6f81bedad5b..b3d816496c3 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -4,6 +4,7 @@ import { resolveAutomationWorkspaceProvenance } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' +import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance' import { defineMethod, type RpcMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolvePairedCallerHostId } from './paired-caller-host-id' @@ -133,6 +134,9 @@ export const WORKTREE_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, + ...(params.displayName !== undefined + ? { displayNameIsPinned: displayNameUpdatePinsLabel(params.displayName) } + : {}), linkedIssue: params.linkedIssue, linkedPR: params.linkedPR, suppressedGitHubPR: params.suppressedGitHubPR, diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 04ec3e2cf6d..d16213650d8 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -78,6 +78,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { submitLinkedPR, workspaceName, nameWasGenerated, + nameIsAutoManaged, submitBaseBranch, submitCompareBaseRef, submitPushTarget, @@ -155,6 +156,9 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), linkedTaskSourceContext: taskSourceContext, nameWasGenerated, + ...(createDisplayName + ? { displayNameKind: nameIsAutoManaged ? ('generated' as const) : ('user' as const) } + : {}), ...(!backendStartup && startupPlan?.draftPrompt ? { startupDraft: startupPlan.draftPrompt } : {}), diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index 07535fd3e06..a1120fccf4e 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -163,11 +163,10 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { smartGitHubResolution.kind === 'none' && smartNameMode === 'branches' }) - const createDisplayName = - smartGitHubResolution.kind === 'none' - ? nameIsAutoManaged - ? submitTitleName?.displayName - : undefined + const createDisplayName = !nameIsAutoManaged + ? workspaceName + : smartGitHubResolution.kind === 'none' + ? submitTitleName?.displayName : smartGitHubCreateNames.displayName // Why: the first-work hook only renames blank, auto-generated git workspaces that launch an agent; persist that pending state for the card. diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index b6b14350290..f80b965ddf8 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -105,6 +105,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { submitLinkedPR, workspaceName, nameWasGenerated, + nameIsAutoManaged, submitCompareBaseRef, submitPushTarget, effectiveSetupDecision, @@ -204,6 +205,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { workspaceName, nameWasGenerated, displayName: createDisplayName, + displayNameKind: createDisplayName ? (nameIsAutoManaged ? 'generated' : 'user') : undefined, selectedRepoIsGit, baseBranch: submitBaseBranch, compareBaseRef: submitCompareBaseRef, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-request.ts b/src/renderer/src/hooks/composer-state/quick-creation-request.ts index e12f2dd6256..67aabf103b9 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-request.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-request.ts @@ -18,6 +18,7 @@ export type QuickCreationRequestInput = { workspaceName: string nameWasGenerated: boolean displayName: string | undefined + displayNameKind?: 'generated' | 'user' selectedRepoIsGit: boolean baseBranch: string | undefined compareBaseRef: string | undefined @@ -63,6 +64,7 @@ export function buildQuickCreationRequest( name: input.workspaceName, ...(input.nameWasGenerated ? { nameWasGenerated: true } : {}), ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.displayNameKind ? { displayNameKind: input.displayNameKind } : {}), ...(input.selectedRepoIsGit && input.baseBranch ? { baseBranch: input.baseBranch } : {}), ...(input.selectedRepoIsGit && input.compareBaseRef ? { compareBaseRef: input.compareBaseRef } diff --git a/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts b/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts index 5989e3e0297..39976157830 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts @@ -230,11 +230,10 @@ export function useQuickSubmitPreparation(input: QuickSubmitPreparationInput) { const submitBaseBranch = baseBranchSettlement.value - const createDisplayName = - smartGitHubResolution.kind === 'none' - ? nameIsAutoManaged - ? submitTitleName?.displayName - : undefined + const createDisplayName = !nameIsAutoManaged + ? workspaceName + : smartGitHubResolution.kind === 'none' + ? submitTitleName?.displayName : smartGitHubCreateNames.displayName // Why: quick create shares the blank-name flow; the card needs an explicit marker, not a guess from the title. diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 77f4db70744..cdddce0988b 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -66,6 +66,7 @@ export type WorktreeCreationRequest = { /** True only when `name` came from the creature-name generator; gates host-side retirement. */ nameWasGenerated?: boolean displayName?: string + displayNameKind?: 'generated' | 'user' baseBranch?: string compareBaseRef?: string setupDecision: SetupDecision diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 03232b39526..f4abe6a684b 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -99,6 +99,9 @@ export async function executeWorktreeCreation( preparedRequest.compareBaseRef, { ...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}), + ...(preparedRequest.displayNameKind + ? { displayNameKind: preparedRequest.displayNameKind } + : {}), ...(preparedRequest.linkedWorkItem !== undefined ? { linkedWorkItem: preparedRequest.linkedWorkItem } : {}), diff --git a/src/renderer/src/store/slices/worktree-meta-update-application.test.ts b/src/renderer/src/store/slices/worktree-meta-update-application.test.ts new file mode 100644 index 00000000000..c02387f0adf --- /dev/null +++ b/src/renderer/src/store/slices/worktree-meta-update-application.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { applyWorktreeUpdates } from './worktree-meta-update-application' + +describe('applyWorktreeUpdates display-name provenance', () => { + it('returns to the current branch immediately when a label is cleared', () => { + const worktree = { + id: 'repo-1::/workspace/feature', + repoId: 'repo-1', + path: '/workspace/feature', + branch: 'refs/heads/main', + displayName: 'Agent workspace' + } + + const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, { + displayName: '', + displayNameIsPinned: false + }) + + expect(next['repo-1']?.[0]).toMatchObject({ + displayName: 'main', + displayNameMode: 'automatic' + }) + }) + + it('keeps text until the host resolves a detached fallback', () => { + const worktree = { + id: 'repo-1::/workspace/feature', + repoId: 'repo-1', + path: '/workspace/feature', + branch: '', + displayName: 'Agent workspace' + } + + const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, { + displayName: '', + displayNameIsPinned: false + }) + + expect(next['repo-1']?.[0]).toMatchObject({ + displayName: 'Agent workspace', + displayNameMode: 'automatic' + }) + }) +}) diff --git a/src/renderer/src/store/slices/worktree-meta-update-application.ts b/src/renderer/src/store/slices/worktree-meta-update-application.ts index a65309f9adf..07627b6ffc4 100644 --- a/src/renderer/src/store/slices/worktree-meta-update-application.ts +++ b/src/renderer/src/store/slices/worktree-meta-update-application.ts @@ -3,6 +3,7 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { Worktree } from '../../../../shared/worktree/types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { worktreeRowMatchesMetaHost } from './worktrees/listing/worktree-meta-host-match' +import { branchName } from '@/lib/git-utils' type RequiredKey = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T] @@ -59,7 +60,17 @@ export function applyWorktreeUpdates( } changed = true - return { ...worktree, ...updates } + const next = { ...worktree, ...updates } + if (updates.displayNameIsPinned !== undefined) { + next.displayNameMode = updates.displayNameIsPinned ? 'fixed' : 'automatic' + if (updates.displayNameIsPinned === false && !updates.displayName?.trim()) { + const automaticName = branchName(next.branch) + // A detached worktree has no branch-derived label; keep the old text until the host + // projection supplies its repo/path fallback instead of flashing an empty sidebar row. + next.displayName = automaticName || worktree.displayName + } + } + return next }) if (!changed) { return worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts index 0e3ead18d88..f3bae6d97f3 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts @@ -301,6 +301,236 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo.repo1[0]?.head).toBe('def456') }) + it('does not merge a stale display name over a rename completed during refresh', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + const requestStarted = makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'old label', + displayNameMode: 'fixed' + }) + const staleResponse = makeWorktree({ + ...requestStarted, + head: 'stale-head' + }) + let resolveListing!: (worktrees: Worktree[]) => void + const listing = new Promise((resolve) => { + resolveListing = resolve + }) + worktreeListMock.mockReturnValueOnce(listing) + store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial) + + const refresh = store.getState().fetchWorktrees('repo1') + await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1)) + await store.getState().updateWorktreeMeta(worktreeId, { displayName: 'new label' }) + resolveListing([staleResponse]) + + await refresh + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + head: 'stale-head', + displayName: 'new label', + displayNameMode: 'fixed' + }) + }) + + it('keeps a rename when an older host omits display-name mode', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + const requestStarted = makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'old label', + displayNameMode: 'automatic' + }) + const staleResponse = { ...requestStarted, displayNameMode: undefined } + let resolveListing!: (worktrees: Worktree[]) => void + worktreeListMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveListing = resolve + }) + ) + store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial) + + const refresh = store.getState().fetchWorktrees('repo1') + await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1)) + await store.getState().updateWorktreeMeta(worktreeId, { displayName: 'new label' }) + resolveListing([staleResponse]) + + await refresh + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + displayName: 'new label', + displayNameMode: 'fixed' + }) + }) + + it('keeps a rename when an old-host refresh is projected with a newer mode', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + const requestStarted = makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'old label', + displayNameMode: undefined + }) + const staleResponse = { ...requestStarted, displayNameMode: 'fixed' as const } + let resolveListing!: (worktrees: Worktree[]) => void + worktreeListMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveListing = resolve + }) + ) + store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial) + + const refresh = store.getState().fetchWorktrees('repo1') + await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1)) + await store.getState().updateWorktreeMeta(worktreeId, { displayName: 'new label' }) + resolveListing([staleResponse]) + + await refresh + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + displayName: 'new label', + displayNameMode: 'fixed' + }) + }) + + it('retains an existing pinned mode when an older host omits it', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: 'feature', + displayNameMode: 'fixed' + }) + const staleResponse = { ...existing, displayNameMode: undefined } + + mockApi.worktrees.list.mockResolvedValueOnce([staleResponse]) + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + + await store.getState().fetchWorktrees('repo1') + store.getState().updateWorktreeGitIdentity(existing.id, { branch: 'refs/heads/next' }) + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + displayName: 'feature', + displayNameMode: 'fixed' + }) + }) + + it('accepts a peer rename from an older host over a pinned label', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: 'my label', + displayNameMode: 'fixed' + }) + // A changed non-branch label from a mode-less host is explicit meta a peer wrote there. + const peerRenamed = { ...existing, displayName: 'peer label', displayNameMode: undefined } + + mockApi.worktrees.list.mockResolvedValueOnce([peerRenamed]) + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('peer label') + }) + + it('suppresses an older host branch-derived relabel of a pinned name', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: 'my label', + displayNameMode: 'fixed' + }) + const rederived = { + ...existing, + branch: 'refs/heads/next', + displayName: 'next', + displayNameMode: undefined + } + + mockApi.worktrees.list.mockResolvedValueOnce([rederived]) + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + branch: 'refs/heads/next', + displayName: 'my label', + displayNameMode: 'fixed' + }) + }) + + it('suppresses an older host detached-HEAD path relabel of a pinned name', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: 'my label', + displayNameMode: 'fixed' + }) + const rederived = { ...existing, branch: '', displayName: 'wt1', displayNameMode: undefined } + + mockApi.worktrees.list.mockResolvedValueOnce([rederived]) + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + branch: '', + displayName: 'my label', + displayNameMode: 'fixed' + }) + }) + + it('does not merge a host response captured before an optimistic rename settles', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + displayName: 'old label', + displayNameMode: 'automatic' + }) + const staleResponse = { ...existing } + let resolvePersist!: () => void + mockApi.worktrees.updateMeta.mockReturnValueOnce( + new Promise((resolve) => { + resolvePersist = resolve + }) + ) + mockApi.worktrees.list.mockResolvedValueOnce([staleResponse]) + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + + const rename = store.getState().updateWorktreeMeta(existing.id, { displayName: 'new label' }) + await vi.waitFor(() => expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(1)) + const refresh = store.getState().fetchWorktrees('repo1') + + await refresh + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + displayName: 'new label', + displayNameMode: 'fixed' + }) + + resolvePersist() + await rename + }) + it('updates the repo entry when only the persisted base ref changes', async () => { const store = createTestStore() const existing = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts b/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts index ee52d067f04..9684792ba6e 100644 --- a/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts +++ b/src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts @@ -211,6 +211,44 @@ describe('updateWorktreeGitIdentity', () => { expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('My Cool Work') }) + it('preserves a pinned user title even when it equals the old branch', () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: 'feature', + displayNameMode: 'fixed' + }) + + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', { + branch: 'refs/heads/main' + }) + + expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('feature') + }) + + it('preserves a legacy CLI title even without projected display-name mode', () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: 'feature', + cliProvenance: { kind: 'created-by-cli', createdAt: 1 } + }) + + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', { + branch: 'refs/heads/main' + }) + + expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('feature') + }) + it('clears stale branch identity for detached HEAD updates', () => { const store = createTestStore() const existing = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index f7770e8b8b8..ac3d5eca571 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -204,6 +204,7 @@ describe('worktree remote runtime mutations', () => { executionHostId: 'local', updates: { displayName: 'Fix auth', + displayNameIsPinned: true, pendingFirstAgentMessageRename: false, firstAgentMessageRenameError: null } diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index b018183016d..9f8407ae3a3 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -13,6 +13,7 @@ export type CreateWorktreeCallOptions = { startupDraft?: string /** True only when `name` came from the creature-name generator; gates host-side retirement. */ nameWasGenerated?: boolean + displayNameKind?: CreateWorktreeArgs['displayNameKind'] /** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */ parentWorktreeId?: string provisionedRoot?: { @@ -51,6 +52,9 @@ function sharedCreateFields( setupDecision: request.setupDecision, sparseCheckout: request.sparseCheckout, ...(request.displayName ? { displayName: request.displayName } : {}), + ...((request.displayNameKind ?? options?.displayNameKind) + ? { displayNameKind: request.displayNameKind ?? options?.displayNameKind } + : {}), ...(request.telemetrySource ? { telemetrySource: request.telemetrySource } : {}), ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), ...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}), diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts new file mode 100644 index 00000000000..2ac3eb20241 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' +import { applyDetectedWorktreeUpdates } from './detected-worktree-meta' + +describe('applyDetectedWorktreeUpdates display-name provenance', () => { + it('projects pinning changes into detected rows', () => { + const detected = { + id: 'repo-1::/workspace/feature', + displayName: 'feature', + displayNameMode: 'automatic', + repoId: 'repo-1', + branch: 'refs/heads/feature' + } + const state = { + 'repo-1': { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [detected] + } + } as unknown as Record + + const fixed = applyDetectedWorktreeUpdates(state, detected.id, { + displayName: 'Agent workspace', + displayNameIsPinned: true + }) + expect(fixed['repo-1']?.worktrees[0]).toMatchObject({ + displayName: 'Agent workspace', + displayNameMode: 'fixed' + }) + + const automatic = applyDetectedWorktreeUpdates(state, detected.id, { + displayName: '', + displayNameIsPinned: false + }) + expect(automatic['repo-1']?.worktrees[0]).toMatchObject({ + displayName: 'feature', + displayNameMode: 'automatic' + }) + }) + + it('keeps text until the host resolves a detached fallback', () => { + const detached = { + id: 'repo-1::/workspace/feature', + displayName: 'Agent workspace', + displayNameMode: 'fixed' as const, + repoId: 'repo-1', + branch: '' + } + const detachedState = { + 'repo-1': { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [detached] + } + } as unknown as Record + + const automatic = applyDetectedWorktreeUpdates(detachedState, detached.id, { + displayName: '', + displayNameIsPinned: false + }) + + expect(automatic['repo-1']?.worktrees[0]).toMatchObject({ + displayName: 'Agent workspace', + displayNameMode: 'automatic' + }) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts index 762ad7e882a..64f4f4162ca 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts @@ -17,6 +17,7 @@ import { worktreeMatchesHost } from './worktree-host-ownership' const folderWorkspaceWorktreeCache = new WeakMap() import { worktreeRowMatchesMetaHost } from './worktree-meta-host-match' +import { branchName } from '@/lib/git-utils' export function applyDetectedWorktreeUpdates( detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'], @@ -37,7 +38,15 @@ export function applyDetectedWorktreeUpdates( } repoChanged = true changed = true - return { ...worktree, ...updates } + const next = { ...worktree, ...updates } + if (updates.displayNameIsPinned !== undefined) { + next.displayNameMode = updates.displayNameIsPinned ? 'fixed' : 'automatic' + if (updates.displayNameIsPinned === false && !updates.displayName?.trim()) { + const automaticName = branchName(next.branch) + next.displayName = automaticName || worktree.displayName + } + } + return next }) nextByRepo[repoId] = repoChanged ? { ...result, worktrees: nextWorktrees } : result } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 46b4f2fa6b4..13e6b1572c1 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -19,6 +19,8 @@ import { } from '../metadata/hosted-review-link-mutation' import { isCurrentDetectedWorktreeRefresh } from './detected-worktree-refresh-admission' import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' +import { isDisplayNamePersistencePending } from '../metadata/worktree-meta-persist' +import { branchName } from '@/lib/git-utils' import { forgetAuthoritativelyRemovedWorktrees, forgetPersistedWorktreeMetaForRemovals, @@ -52,6 +54,79 @@ export function preserveConcurrentManualOrder( }) } +export function preserveConcurrentDisplayName( + incoming: readonly T[], + requestStarted: readonly Worktree[] | undefined, + current: readonly Worktree[] | undefined, + matchesRefreshHost: (worktree: Worktree) => boolean +): T[] { + if (!requestStarted || !current) { + return [...incoming] + } + const startedById = new Map( + requestStarted.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree]) + ) + const currentById = new Map( + current.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree]) + ) + return incoming.map((worktree) => { + const started = startedById.get(worktree.id) + const latest = currentById.get(worktree.id) + if (!started || !latest) { + return worktree + } + if (isDisplayNamePersistencePending(worktree.id, latest.hostId)) { + return { + ...worktree, + displayName: latest.displayName, + ...(latest.displayNameMode !== undefined + ? { displayNameMode: latest.displayNameMode } + : { displayNameMode: undefined }) + } + } + const latestChanged = + latest.displayName !== started.displayName || + latest.displayNameMode !== started.displayNameMode + // The label is the stable stale-response marker; mode may be absent on an + // older host or newly projected by a newer one. + const incomingIsStale = worktree.displayName === started.displayName + const latestDisplayNameIsPinned = + latest.displayNameMode === 'fixed' || + (latest.displayNameMode === undefined && latest.cliProvenance?.kind === 'created-by-cli') + const incomingBranchShort = branchName(worktree.branch) + // Old hosts re-derive automatic labels from branch (or path basename when detached); + // any other label in their response is explicit meta a peer wrote there. + const incomingLooksAutomatic = + worktree.displayName === incomingBranchShort || + (incomingBranchShort === '' && + worktree.displayName === (worktree.path.split(/[\\/]/).pop() ?? '')) + if ( + worktree.displayNameMode === undefined && + latestDisplayNameIsPinned && + incomingLooksAutomatic + ) { + // Older hosts omit provenance; never let their re-derived label replace a pinned one. + return { + ...worktree, + displayName: latest.displayName, + ...(latest.displayNameMode !== undefined + ? { displayNameMode: latest.displayNameMode } + : { displayNameMode: undefined }) + } + } + if (!latestChanged || !incomingIsStale) { + return worktree + } + return { + ...worktree, + displayName: latest.displayName, + ...(latest.displayNameMode !== undefined + ? { displayNameMode: latest.displayNameMode } + : { displayNameMode: undefined }) + } + }) +} + export function mergeFetchedWorktrees( set: Parameters>[0], args: FencedWorktreeMergeArgs @@ -77,8 +152,13 @@ export function mergeFetchedWorktrees( const currentWorktrees = s.worktreesByRepo[args.repoId] const refreshResult = { ...args.refresh.result, - worktrees: preserveConcurrentManualOrder( - args.refresh.result.worktrees, + worktrees: preserveConcurrentDisplayName( + preserveConcurrentManualOrder( + args.refresh.result.worktrees, + args.requestStartedWorktrees, + currentWorktrees, + (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions) + ), args.requestStartedWorktrees, currentWorktrees, (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions) diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 76924909f58..5e5c38a71e2 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -2,6 +2,7 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { translate } from '@/i18n/i18n' import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' +import { displayNameUpdatePinsLabel } from '../../../../../../shared/worktree/display-name-provenance' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { getHostedReviewCacheKey } from '../../hosted-review-cache-identity' @@ -122,11 +123,15 @@ export function createUpdateWorktreeMeta( const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '') // Why: bump lastActivityAt on comment edits so the time-decay sort doesn't drop a just-touched worktree. + const displayNameProvenance = + 'displayName' in normalizedUpdates + ? { displayNameIsPinned: displayNameUpdatePinsLabel(normalizedUpdates.displayName) } + : {} const targetEnriched = resolvedPushTarget - ? { ...normalizedUpdates, pushTarget: resolvedPushTarget } + ? { ...normalizedUpdates, ...displayNameProvenance, pushTarget: resolvedPushTarget } : shouldClearStaleHostedReviewPushTarget - ? { ...normalizedUpdates, pushTarget: undefined } - : normalizedUpdates + ? { ...normalizedUpdates, ...displayNameProvenance, pushTarget: undefined } + : { ...normalizedUpdates, ...displayNameProvenance } const renameCleared = 'displayName' in targetEnriched ? { diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts index b4e80783739..26b1b4428a9 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts @@ -85,7 +85,11 @@ export function createUpdateWorktreeGitIdentity( } // Why: terminal branch switches only patch branch/head here; re-derive auto titles like full listing does. const currentBranchName = branchName(worktree.branch) - const wasAutoDerived = worktree.displayName === currentBranchName + const wasAutoDerived = + worktree.displayNameMode === 'automatic' || + (worktree.displayNameMode === undefined && + worktree.cliProvenance?.kind !== 'created-by-cli' && + worktree.displayName === currentBranchName) const wasDetachedAutoDerived = worktree.branch === '' && nextBranch !== '' && diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts index 48e01df4440..61114f5b320 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts @@ -15,7 +15,69 @@ import type { AppState } from '../../../types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { encodePushTargetClearForRuntimeRpc } from './hosted-review-link-mutation' -export async function persistWorktreeMeta( + +type PendingDisplayNameWrite = { + worktreeId: string + executionHostId?: ExecutionHostId +} + +const pendingDisplayNameWrites = new Set() + +function pendingDisplayNameWriteMatches( + write: PendingDisplayNameWrite, + worktreeId: string, + executionHostId?: ExecutionHostId +): boolean { + return ( + write.worktreeId === worktreeId && + (write.executionHostId === undefined || + executionHostId === undefined || + write.executionHostId === executionHostId) + ) +} + +export function isDisplayNamePersistencePending( + worktreeId: string, + executionHostId?: ExecutionHostId +): boolean { + for (const write of pendingDisplayNameWrites) { + if (pendingDisplayNameWriteMatches(write, worktreeId, executionHostId)) { + return true + } + } + return false +} + +export function persistWorktreeMeta( + settings: AppState['settings'], + worktreeId: string, + updates: Partial, + executionHostId?: ExecutionHostId, + identityKey?: string +): Promise { + const operation = persistWorktreeMetaUntracked( + settings, + worktreeId, + updates, + executionHostId, + identityKey + ) + if (!('displayName' in updates)) { + return operation + } + const write: PendingDisplayNameWrite = { + worktreeId, + executionHostId + } + pendingDisplayNameWrites.add(write) + void operation.then( + () => pendingDisplayNameWrites.delete(write), + () => pendingDisplayNameWrites.delete(write) + ) + return operation +} + +async function persistWorktreeMetaUntracked( settings: AppState['settings'], worktreeId: string, updates: Partial, diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index 43e496d3bd0..2e11b258fed 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -53,6 +53,7 @@ export function createWorktreesApi(): NonNullable['worktrees name: args.name, // Absent means user-typed, which is what the host must assume — so send it only when true. ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), + ...(args.displayNameKind ? { displayNameKind: args.displayNameKind } : {}), baseBranch: args.baseBranch, compareBaseRef: args.compareBaseRef, branchNameOverride: args.branchNameOverride, diff --git a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts index 60efde1d826..ad1ceddfc86 100644 --- a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts +++ b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts @@ -596,6 +596,8 @@ describe('web worktree preload API', () => { compareBaseRef: 'refs/remotes/origin/main', setupDecision: 'inherit', createdWithAgent: 'codex', + displayName: 'Review label', + displayNameKind: 'user', startup: { command: "codex 'summarize repo'", env: { ORCA_AGENT_MODE: 'direct' }, @@ -636,6 +638,8 @@ describe('web worktree preload API', () => { baseBranch: TEST_COMMIT_OID, compareBaseRef: 'refs/remotes/origin/main', createdWithAgent: 'codex', + displayName: 'Review label', + displayNameKind: 'user', startupCommand: "codex 'summarize repo'", startupEnv: { ORCA_AGENT_MODE: 'direct' }, startupLaunchConfig: { diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index c3c5c24a876..cb773336db1 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -68,6 +68,8 @@ export type CreateWorktreeArgs = { * branch/path seed. Used when a workspace is created from a GitHub or * Linear artifact whose title should remain readable in the sidebar. */ displayName?: string + /** Distinguishes user labels from generated artifact titles at creation time. */ + displayNameKind?: 'generated' | 'user' baseBranch?: string /** Source Control compare target when it differs from the checkout start point. */ compareBaseRef?: string diff --git a/src/shared/worktree/display-name-provenance.ts b/src/shared/worktree/display-name-provenance.ts new file mode 100644 index 00000000000..a352ab466ba --- /dev/null +++ b/src/shared/worktree/display-name-provenance.ts @@ -0,0 +1,4 @@ +/** A rename with text pins the label; empty text returns it to automatic (branch-derived). */ +export function displayNameUpdatePinsLabel(displayName: string | undefined): boolean { + return Boolean(displayName?.trim()) +} diff --git a/src/shared/worktree/meta-types.ts b/src/shared/worktree/meta-types.ts index 612bcfd97ee..1f49291fce8 100644 --- a/src/shared/worktree/meta-types.ts +++ b/src/shared/worktree/meta-types.ts @@ -28,6 +28,8 @@ export type WorktreeMeta = { /** See Worktree.creatorProvenance. */ creatorProvenance?: WorkspaceCreatorProvenance displayName: string + /** True when a user-authored label must survive branch changes. */ + displayNameIsPinned?: boolean comment: string linkedIssue: number | null linkedPR: number | null diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index 79a0fe40316..3e5c05a65bc 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -77,6 +77,8 @@ export type Worktree = { /** Checkout ownership for a recipe-provisioned main workspace. */ ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode displayName: string + /** Projection of persisted display-name provenance. */ + displayNameMode?: 'fixed' | 'automatic' comment: string linkedIssue: number | null linkedPR: number | null