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.
This commit is contained in:
Jinwoo Hong
2026-08-31 19:08:05 -04:00
committed by GitHub
parent b44ef1e59d
commit 8f15f217a2
67 changed files with 1353 additions and 100 deletions
+1
View File
@@ -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",
@@ -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)
@@ -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)
}
@@ -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)
})
@@ -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 () => {
+36 -5
View File
@@ -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<WorktreeCreateResult> {
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<WorktreeCreateResult> {
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<string, unknown>): Record<string, unknown> => {
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<WorktreeCreateResult> {
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) {
@@ -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<string, unknown>
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 })
@@ -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: {
+6 -3
View File
@@ -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,
+41 -2
View File
@@ -55,7 +55,7 @@ async function flush(): Promise<void> {
// 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(
+11 -2
View File
@@ -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 ?? '')) {
+4 -1
View File
@@ -230,9 +230,12 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
}
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<RuntimeWorktreeCreateResult>('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,
@@ -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,
@@ -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',
@@ -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,
@@ -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,
+3
View File
@@ -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
+95
View File
@@ -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<Pick<WorktreeMeta, 'displayName' | 'displayNameIsPinned'>> {
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)
}
+100
View File
@@ -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,
+7 -24
View File
@@ -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.
*/
@@ -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',
+17 -1
View File
@@ -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,
+30 -18
View File
@@ -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,
@@ -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([
{
@@ -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,
@@ -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
}
@@ -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, {
+29 -3
View File
@@ -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<WorktreeMeta> {
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,
+3
View File
@@ -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',
+32 -8
View File
@@ -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 } : {}),
@@ -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
@@ -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(),
@@ -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()
@@ -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,
@@ -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,
@@ -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,
@@ -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(
@@ -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,
@@ -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) => {
@@ -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'
@@ -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 = {
+4
View File
@@ -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,
@@ -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 }
: {}),
@@ -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.
@@ -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,
@@ -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 }
@@ -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.
@@ -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
@@ -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 }
: {}),
@@ -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'
})
})
})
@@ -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<T> = { [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
@@ -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<Worktree[]>((resolve) => {
resolveListing = resolve
})
worktreeListMock.mockReturnValueOnce(listing)
store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial<AppState>)
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<Worktree[]>((resolve) => {
resolveListing = resolve
})
)
store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial<AppState>)
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<Worktree[]>((resolve) => {
resolveListing = resolve
})
)
store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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<void>((resolve) => {
resolvePersist = resolve
})
)
mockApi.worktrees.list.mockResolvedValueOnce([staleResponse])
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
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({
@@ -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<AppState>)
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<AppState>)
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({
@@ -204,6 +204,7 @@ describe('worktree remote runtime mutations', () => {
executionHostId: 'local',
updates: {
displayName: 'Fix auth',
displayNameIsPinned: true,
pendingFirstAgentMessageRename: false,
firstAgentMessageRenameError: null
}
@@ -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 } : {}),
@@ -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<string, DetectedWorktreeListResult>
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<string, DetectedWorktreeListResult>
const automatic = applyDetectedWorktreeUpdates(detachedState, detached.id, {
displayName: '',
displayNameIsPinned: false
})
expect(automatic['repo-1']?.worktrees[0]).toMatchObject({
displayName: 'Agent workspace',
displayNameMode: 'automatic'
})
})
})
@@ -17,6 +17,7 @@ import { worktreeMatchesHost } from './worktree-host-ownership'
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()
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
}
@@ -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<T extends Worktree>(
})
}
export function preserveConcurrentDisplayName<T extends Worktree>(
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<StateCreator<AppState, [], [], WorktreeSlice>>[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)
@@ -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
? {
@@ -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 !== '' &&
@@ -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<PendingDisplayNameWrite>()
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<WorktreeMeta>,
executionHostId?: ExecutionHostId,
identityKey?: string
): Promise<void> {
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<WorktreeMeta>,
@@ -53,6 +53,7 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['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,
@@ -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: {
+2
View File
@@ -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
@@ -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())
}
+2
View File
@@ -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
+2
View File
@@ -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