fix(composer): preserve explicit workspace names after selecting tasks (#26937)

Preserve names entered after selecting a work item in desktop, mobile and folder creation. Reuse the existing source and last-generated-name state, and avoid repeating source naming when a delayed base lookup finishes.

Verified the current-main merge composition: 70 focused tests and renderer typecheck pass; newer execution-host routing and folder launch checks remain intact. The reviewed PR head has no failed or pending CI checks and no unresolved review threads.
This commit is contained in:
Neil
2026-10-10 15:32:21 -07:00
committed by GitHub
parent eaf5dbed9e
commit 68519e63b9
22 changed files with 729 additions and 43 deletions
@@ -50,7 +50,11 @@ export function buildLinearLinkedWorkItem(issue: {
// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name
// replaces the current field only when it's empty, still the last auto-name, or
// a lookup query — never a name the user deliberately typed.
export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean {
export function shouldApplyAutoName(args: {
currentName: string
lastAutoName: string
lookupTextIsQuery?: boolean
}): boolean {
return shouldApplyWorkspaceSourceAutoName(args)
}
@@ -0,0 +1,123 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
import type { LinearIssue } from '../../../src/shared/linear/issue-types'
import { useMobileComposerSource } from './use-mobile-composer-source'
import { buildTaskWorkspaceCreateParams } from './workspace-create-params'
const mounted: ReactTestRenderer[] = []
afterEach(() => {
act(() => {
for (const renderer of mounted.splice(0)) {
renderer.unmount()
}
})
})
function renderMobileComposerSource() {
const holder: { value?: ReturnType<typeof useMobileComposerSource> } = {}
function Probe() {
holder.value = useMobileComposerSource({ client: null, selectedRepoId: 'fixture-repo' })
return null
}
act(() => {
mounted.push(create(createElement(Probe)))
})
return {
get current() {
if (!holder.value) {
throw new Error('Composer source did not render')
}
return holder.value
}
}
}
function issue(number: number): GitHubWorkItem {
return {
id: `issue-${number}`,
type: 'issue',
number,
title: 'Fix export',
state: 'open',
url: `https://github.com/fixture/repo/issues/${number}`,
repoId: 'fixture-repo',
labels: [],
updatedAt: '',
author: null
}
}
describe('mobile workspace name ownership', () => {
const linearIssue: LinearIssue = {
id: 'linear-9',
identifier: 'ENG-9',
title: 'Fix export',
url: 'https://linear.app/fixture/issue/ENG-9',
state: { name: 'Open', type: 'unstarted', color: '' },
team: { id: 'engineering', name: 'Engineering', key: 'ENG' },
labels: [],
labelIds: [],
priority: 0,
updatedAt: ''
}
it('keeps a manual Linear identifier name when its source refreshes', () => {
const result = renderMobileComposerSource()
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
act(() => result.current.setName('ENG-9'))
act(() =>
result.current.handleSmartLinearIssueSelect({ ...linearIssue, title: 'Fix refreshed export' })
)
expect(result.current.name).toBe('ENG-9')
expect(result.current.isNameAutoManaged).toBe(false)
})
it('still replaces an unselected Linear identifier query', () => {
const result = renderMobileComposerSource()
act(() => result.current.setName('ENG-9'))
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
expect(result.current.name).toBe('eng-9-fix-export')
expect(result.current.isNameAutoManaged).toBe(true)
})
it.each([2, 347])('keeps an edited name when issue #%s replaces the source', (number) => {
const result = renderMobileComposerSource()
act(() => result.current.handleSmartGitHubItemSelect(issue(2)))
act(() => result.current.setName('002'))
act(() => result.current.handleSmartGitHubItemSelect(issue(number)))
expect(result.current.name).toBe('002')
expect(result.current.isNameAutoManaged).toBe(false)
expect(
buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: { ...issue(number), type: 'issue', repoId: 'fixture-repo' }
},
targetRepoId: 'fixture-repo',
setupDecision: 'skip',
agent: 'blank',
workspaceName: result.current.name,
nameIsAutoManaged: result.current.isNameAutoManaged
})
).toMatchObject({
name: '002',
displayName: '002',
displayNameKind: 'user',
linkedIssue: number
})
})
it.each(['002', '347', '#347'])('still generates a name when picking query %s', (query) => {
const result = renderMobileComposerSource()
act(() => result.current.setName(query))
act(() => result.current.handleSmartGitHubItemSelect(issue(query === '002' ? 2 : 347)))
expect(result.current.name).toBe('fix-export')
expect(result.current.isNameAutoManaged).toBe(true)
})
})
@@ -0,0 +1,27 @@
import { useCallback, useRef, useState } from 'react'
import { shouldApplyAutoName } from './composer-linked-work-item'
import type { MobileLinkedWorkItem } from './mobile-composer-source-types'
export function useMobileComposerName(linkedWorkItem: MobileLinkedWorkItem | null) {
const [name, setNameState] = useState('')
const lastAutoNameRef = useRef('')
const setName = useCallback((value: string) => setNameState(value), [])
const applyAutoName = useCallback(
(suggested: string, currentName: string) => {
if (
suggested &&
shouldApplyAutoName({
currentName,
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
})
) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
},
[linkedWorkItem]
)
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
return { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged }
}
+11 -17
View File
@@ -28,6 +28,7 @@ import type {
MobileLinkedWorkItem,
SmartNameSelection
} from './mobile-composer-source-types'
import { useMobileComposerName } from './use-mobile-composer-name'
const EMPTY_BASE: ComposerBaseState = {}
export type UseMobileComposerSourceArgs = {
@@ -39,7 +40,6 @@ export type UseMobileComposerSourceArgs = {
export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
const { client, selectedRepoId, worktreeBranches = [], onError } = args
const [name, setNameState] = useState('')
const [linkedWorkItem, setLinkedWorkItem] = useState<MobileLinkedWorkItem | null>(null)
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
@@ -50,19 +50,12 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
// may contain slashes) is kept verbatim as the git branch (folder is sanitized).
const [branchCreateIntent, setBranchCreateIntent] = useState(false)
const lastAutoNameRef = useRef('')
const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null)
// Guards async base resolution: only the latest selection applies its result.
const resolveTokenRef = useRef(0)
const setName = useCallback((value: string) => setNameState(value), [])
const applyAutoName = useCallback((suggested: string, currentName: string) => {
if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
}, [])
const { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged } =
useMobileComposerName(linkedWorkItem)
const clearBaseAndBranch = useCallback(() => {
branchSelectionRef.current = null
@@ -201,18 +194,23 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
resolveTokenRef.current += 1
setLinkedWorkItem(buildLinearLinkedWorkItem(issue))
const suggested = resolveLinearAutoName(issue)
const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase()
const identifierTyped =
!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase()
if (
suggested &&
(identifierTyped ||
shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current }))
shouldApplyAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
}))
) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
clearBaseAndBranch()
},
[clearBaseAndBranch, name]
[clearBaseAndBranch, linkedWorkItem, name]
)
const handleSmartBranchSelect = useCallback(
@@ -302,10 +300,6 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
[base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch]
)
// Auto-managed until the user edits the name away from the last derived value;
// desktop suppresses the workspace displayName once the name is user-edited.
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
return {
name,
setName,
@@ -65,6 +65,36 @@ function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWo
}
describe('submitFolderWorkspaceCreate', () => {
it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => {
const linkedWorkItem = {
provider: 'github' as const,
type: 'issue' as const,
number: Number(name),
title: 'Fix export',
url: `https://github.com/fixture/repo/issues/${Number(name)}`
}
const createFolderWorkspace = vi.fn(async () => null)
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name,
lastAutoName: 'Fix export',
linkedWorkItem,
note: '',
quickAgent: null,
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name,
connectionId: null,
linkedTask: linkedWorkItem
})
})
beforeEach(() => {
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' })
})
@@ -4,7 +4,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -82,7 +82,11 @@ export async function submitFolderWorkspaceCreate({
onOpenChange
}: SubmitFolderWorkspaceCreateParams): Promise<boolean> {
const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null
const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name)
const nameIsAutoManaged = shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName,
lookupTextIsQuery: !linkedWorkItem
})
const workspaceName =
nameIsAutoManaged && linkedName
? linkedName
@@ -135,7 +135,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
item: GitHubWorkItem,
nextPushTarget?: GitPushTarget,
nextBranchNameOverride?: string,
nextCompareBaseRef?: string
nextCompareBaseRef?: string,
options: { sourceAlreadyLinked?: boolean } = {}
): void => {
setBaseBranch(nextBaseBranch)
setBaseBranchNamesWorkspace(true)
@@ -146,7 +147,10 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
// Why: a Start-from PR pick is also a linkedWorkItem assignment; reuse applyLinkedWorkItem so auto-name and linkedPR stay one code path.
applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) })
// Provider picks already linked the source; a delayed base result must not replay its name.
if (!options.sourceAlreadyLinked) {
applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) })
}
// Why: prefill the note from the PR (only when empty or still an auto-fill) so the sidebar surfaces it without clobbering user text.
const identity = resolveGitHubWorkItemIdentity(item)
if (identity.type === 'pr') {
@@ -180,7 +184,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
nextBaseBranch: string,
item: GitLabWorkItem,
nextPushTarget?: GitPushTarget,
nextCompareBaseRef?: string
nextCompareBaseRef?: string,
options: { sourceAlreadyLinked?: boolean } = {}
): void => {
setBaseBranch(nextBaseBranch)
setBaseBranchNamesWorkspace(true)
@@ -189,7 +194,9 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
applyLinkedGitLabWorkItem(item)
if (!options.sourceAlreadyLinked) {
applyLinkedGitLabWorkItem(item)
}
if (item.type === 'mr') {
const suggestedNote = `MR !${item.number} — ${item.title}`
const currentNote = noteRef.current
@@ -3,7 +3,7 @@ import {
getLinkedWorkItemWorkspaceName
} from '../../../../shared/workspace-name'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source'
import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context'
import { getGitHubLinkedWorkItemIdentity } from './source-selection-decisions'
@@ -30,7 +30,11 @@ export type ComposerDecisions = {
item: LinkedWorkItemSummary | null | undefined,
context: TaskSourceContext | null | undefined
) => TaskSourceContext | null
isExplicitWorkspaceNameInput: (input: { name: string; lastAutoName: string }) => boolean
isExplicitWorkspaceNameInput: (input: {
name: string
lastAutoName: string
lookupTextIsQuery?: boolean
}) => boolean
resolveInitialWorkspaceRunSeed: (input: {
draftProjectId?: string | null
draftHostId?: string | null
@@ -67,13 +71,15 @@ export function canResolveFolderSmartGitHubSubmit({
export function isExplicitWorkspaceNameInput({
name,
lastAutoName
lastAutoName,
lookupTextIsQuery
}: {
name: string
lastAutoName: string
lookupTextIsQuery?: boolean
}): boolean {
// Why: a user-authored name must win over linked-item and first-message AI naming.
return Boolean(name.trim()) && name !== lastAutoName && !isWorkItemLookupText(name)
return !shouldApplyWorkspaceSourceAutoName({ currentName: name, lastAutoName, lookupTextIsQuery })
}
export function resolveSmartGitHubCreateNames({
@@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
import type { GitHubPrStartPoint } from '../../../../shared/worktree/types'
import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions'
import { useGitHubSourceApplication } from './github-source-application'
import { useSourceIdentityActions } from './source-identity-actions'
import { useBranchStartPointActions } from './branch-start-point-actions'
import { useGitHubProviderSelection } from './github-provider-selection'
import { useGitLabProviderSelection } from './gitlab-provider-selection'
const repo = {
id: 'fixture-repo',
path: '/repos/fixture',
displayName: 'Fixture',
badgeColor: '',
addedAt: 0
}
const pr: GitHubWorkItem = {
id: 'pr-2',
type: 'pr',
number: 2,
title: 'Fix export',
state: 'open',
url: 'https://github.com/fixture/repo/pull/2',
labels: [],
updatedAt: '',
author: null,
repoId: repo.id
}
const mr: GitLabWorkItem = {
id: 'mr-2',
number: 2,
title: 'Fix export',
labels: [],
updatedAt: '',
author: null,
repoId: repo.id,
type: 'mr',
state: 'opened',
url: 'https://gitlab.com/fixture/repo/-/merge_requests/2'
}
let originalApi: PropertyDescriptor | undefined
const resolvePrBase = vi.fn<Window['api']['worktrees']['resolvePrBase']>()
const resolveMrBase = vi.fn<Window['api']['worktrees']['resolveMrBase']>()
beforeEach(() => {
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
Object.defineProperty(window, 'api', {
configurable: true,
value: { worktrees: { resolvePrBase, resolveMrBase } }
})
})
afterEach(() => {
vi.resetAllMocks()
if (originalApi) {
Object.defineProperty(window, 'api', originalApi)
} else {
Reflect.deleteProperty(window, 'api')
}
})
function deferred() {
let resolve!: (value: GitHubPrStartPoint) => void
const promise = new Promise<GitHubPrStartPoint>((next) => {
resolve = next
})
return { promise, resolve }
}
function useDeferredSource() {
const [name, setName] = useState('')
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(null)
const [baseBranch, setBaseBranch] = useState<string | undefined>()
const common = {
name,
setName,
linkedWorkItem,
setLinkedWorkItem,
baseBranch,
setBaseBranch,
lastAutoNameRef: useRef(''),
branchAutoNameRef: useRef(''),
smartGitHubPrStartPointSelectionRef: useRef<SmartGitHubPrStartPointSelection | null>(null),
lastAutoNoteRef: useRef(''),
noteRef: useRef(''),
initialProjectGroupAppliedRef: useRef(false),
selectedRepoGitHubSourceContext: null,
branchNameOverride: undefined,
branchNameOverridePreservesNameEdits: false,
forkPushWarning: null,
pushTarget: undefined,
baseBranchNamesWorkspace: false,
isProjectGroupTarget: false,
settings: null,
eligibleRepos: [repo],
selectedRepo: repo,
setBranchNameOverride: vi.fn(),
setBranchNameOverridePreservesNameEdits: vi.fn(),
setCreateError: vi.fn(),
setForkPushWarning: vi.fn(),
setLinkDebouncedQuery: vi.fn(),
setLinkDirectItem: vi.fn(),
setLinkPopoverOpen: vi.fn(),
setLinkQuery: vi.fn(),
setLinkedGitLabIssue: vi.fn(),
setLinkedGitLabMR: vi.fn(),
setLinkedIssue: vi.fn(),
setLinkedPR: vi.fn(),
setLinkedTaskSourceContext: vi.fn(),
setPushTarget: vi.fn(),
setReuseEligibleBranch: vi.fn(),
setReuseSelectedBranch: vi.fn(),
setCompareBaseRef: vi.fn(),
setStartFromResetHint: vi.fn(),
setBaseBranchNamesWorkspace: vi.fn(),
setNote: vi.fn(),
setProjectError: vi.fn(),
setSelectedProjectGroupId: vi.fn(),
setSparseDirectories: vi.fn(),
setSparseEnabled: vi.fn(),
setSparseSelectedPresetId: vi.fn(),
handleRepoChange: vi.fn()
}
const application = useGitHubSourceApplication(common)
const identity = useSourceIdentityActions({ ...common, ...application })
const branch = useBranchStartPointActions({ ...common, ...application, ...identity })
const github = useGitHubProviderSelection({ ...common, ...application, ...branch })
const gitlab = useGitLabProviderSelection({ ...common, ...identity, ...branch })
return { ...github, ...gitlab, ...identity, ...branch, name, linkedWorkItem, baseBranch }
}
describe('name ownership while resolving review bases', () => {
it.each(['github', 'gitlab'])(
'keeps an Advanced name while %s resolves its base',
async (provider) => {
const pending = deferred()
resolvePrBase.mockReturnValue(pending.promise)
resolveMrBase.mockReturnValue(pending.promise)
const { result } = renderHook(useDeferredSource)
act(() => {
if (provider === 'github') {
result.current.handleSmartGitHubItemSelect(pr)
} else {
result.current.handleSmartGitLabItemSelect(mr)
}
})
act(() => result.current.handleNameValueChange('002'))
await act(async () => {
pending.resolve({ baseBranch: 'feature/export' })
await pending.promise
})
expect(result.current.name).toBe('002')
expect(result.current.linkedWorkItem).toMatchObject({ provider, number: 2 })
expect(result.current.baseBranch).toBe('feature/export')
}
)
it.each(['github', 'gitlab'])(
'still links and auto-names a direct %s Start-from selection',
(provider) => {
const { result } = renderHook(useDeferredSource)
act(() => {
if (provider === 'github') {
result.current.handleBaseBranchPrSelect('feature/export', pr)
} else {
result.current.handleBaseBranchMrSelect('feature/export', mr)
}
})
expect(result.current.name).toBe('fix-export')
expect(result.current.linkedWorkItem).toMatchObject({ provider, number: 2 })
}
)
})
@@ -56,6 +56,7 @@ export function useComposerExternalSync(target: ComposerTargetState): ComposerEx
const githubSourceApplication = useGitHubSourceApplication({
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
linkedWorkItem: target.sourceContextState.linkedWorkItem,
name: target.sourceContextState.name,
selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext,
setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride,
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { useRef, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { getSmartGitHubSubmitResolution } from '@/lib/smart-github-submit'
import { useGitHubSourceApplication } from './github-source-application'
import { useSourceIdentityActions } from './source-identity-actions'
import { useQuickSubmitSourcePreparation } from './quick-submit-source-preparation'
import * as decisions from './composer-decisions'
import type {
PendingSmartGitHubSubmitResolution,
SmartGitHubPrStartPointSelection
} from './source-selection-decisions'
function issue(number: number): GitHubWorkItem {
return {
id: `issue-${number}`,
type: 'issue',
number,
title: 'Fix export',
state: 'open',
url: `https://github.com/fixture/repo/issues/${number}`,
labels: [],
updatedAt: '',
author: null,
repoId: 'fixture-repo'
}
}
function useNameOwnership(draft?: { name: string; linkedWorkItem: LinkedWorkItemSummary }) {
const [name, setName] = useState(draft?.name ?? '')
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(
draft?.linkedWorkItem ?? null
)
const lastAutoNameRef = useRef(
decisions.getInitialAutoManagedWorkspaceName({
initialName: '',
draftName: draft?.name,
draftLinkedWorkItem: draft?.linkedWorkItem
})
)
const common = {
name,
setName,
linkedWorkItem,
setLinkedWorkItem,
lastAutoNameRef,
branchAutoNameRef: useRef(''),
smartGitHubPrStartPointSelectionRef: useRef<SmartGitHubPrStartPointSelection | null>(null),
selectedRepoGitHubSourceContext: null,
branchNameOverride: undefined,
branchNameOverridePreservesNameEdits: false,
forkPushWarning: null,
pushTarget: undefined,
setBranchNameOverride: vi.fn(),
setBranchNameOverridePreservesNameEdits: vi.fn(),
setCreateError: vi.fn(),
setForkPushWarning: vi.fn(),
setLinkDebouncedQuery: vi.fn(),
setLinkDirectItem: vi.fn(),
setLinkPopoverOpen: vi.fn(),
setLinkQuery: vi.fn(),
setLinkedGitLabIssue: vi.fn(),
setLinkedGitLabMR: vi.fn(),
setLinkedIssue: vi.fn(),
setLinkedPR: vi.fn(),
setLinkedTaskSourceContext: vi.fn(),
setPushTarget: vi.fn(),
setReuseEligibleBranch: vi.fn(),
setReuseSelectedBranch: vi.fn()
}
const application = useGitHubSourceApplication(common)
const identity = useSourceIdentityActions({ ...common, ...application })
const source = useQuickSubmitSourcePreparation({
...common,
baseBranch: 'main',
compareBaseRef: undefined,
disabledTuiAgents: [],
effectiveLinkedPR: null,
decisions,
fallbackCreatureName: 'otter',
linkedGitLabMR: null,
parsedLinkedIssueNumber: linkedWorkItem?.number ?? null
})
return {
...application,
...identity,
name,
linkedWorkItem,
prepare: (resolution: PendingSmartGitHubSubmitResolution = { kind: 'none' }) =>
source.prepareQuickSubmitSource(resolution, null, name || 'otter')
}
}
describe('workspace name ownership after linking a source', () => {
it.each(['347', '002'])('submits Advanced name %s as user input', (name) => {
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.applyLinkedWorkItem(issue(Number(name))))
act(() => result.current.handleNameValueChange(name))
expect(result.current.prepare()).toMatchObject({
workspaceName: name,
nameIsAutoManaged: false,
submitLinkedWorkItem: { number: Number(name), url: issue(Number(name)).url }
})
})
it.each([2, 347])('preserves an edited name when source #%s replaces the source', (number) => {
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.applyLinkedWorkItem(issue(2)))
act(() => result.current.handleNameValueChange('002'))
act(() => result.current.applyLinkedWorkItem(issue(number)))
expect(result.current.name).toBe('002')
expect(result.current.prepare()).toMatchObject({
workspaceName: '002',
nameIsAutoManaged: false,
submitLinkedWorkItem: { number }
})
})
it('restores manual ownership from an existing draft without a new persisted flag', () => {
const { result } = renderHook(() => useNameOwnership({ name: '002', linkedWorkItem: issue(2) }))
expect(result.current.prepare()).toMatchObject({
workspaceName: '002',
nameIsAutoManaged: false
})
})
it('keeps manual ownership when the linked source is removed', () => {
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.applyLinkedWorkItem(issue(2)))
act(() => result.current.handleNameValueChange('002'))
act(() => result.current.handleRemoveLinkedWorkItem())
expect(result.current.prepare()).toMatchObject({
workspaceName: '002',
nameIsAutoManaged: false,
submitLinkedWorkItem: null
})
})
it.each(['347', '002', '#347'])('still auto-names a source picked from query %s', (query) => {
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.handleNameValueChange(query))
act(() => result.current.applyLinkedWorkItem(issue(query === '002' ? 2 : 347)))
expect(result.current.prepare()).toMatchObject({
workspaceName: 'fix-export',
nameIsAutoManaged: true
})
})
it('restores automatic naming when the Advanced name is cleared', () => {
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.applyLinkedWorkItem(issue(2)))
act(() => result.current.handleNameValueChange('002'))
act(() => result.current.handleNameValueChange(''))
expect(result.current.prepare()).toMatchObject({
workspaceName: 'fix-export',
nameIsAutoManaged: true
})
})
it('keeps automatic ownership when a generated name is restored or refreshed', () => {
const { result } = renderHook(() =>
useNameOwnership({ name: 'fix-export', linkedWorkItem: issue(2) })
)
act(() => result.current.applyLinkedWorkItem({ ...issue(2), title: 'Fix refreshed export' }))
expect(result.current.prepare()).toMatchObject({
workspaceName: 'fix-refreshed-export',
nameIsAutoManaged: true
})
})
it('still resolves a pasted lookup at submit time as an automatic name', () => {
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.handleNameValueChange(issue(2).url))
expect(
result.current.prepare({
...getSmartGitHubSubmitResolution(issue(2)),
kind: 'metadata-only'
})
).toMatchObject({ workspaceName: 'fix-export', nameIsAutoManaged: true })
})
it('keeps an Advanced name when the selected pull request resolves its base at submit time', () => {
const pr: GitHubWorkItem = {
...issue(2),
type: 'pr',
url: 'https://github.com/fixture/repo/pull/2'
}
const { result } = renderHook(() => useNameOwnership())
act(() => result.current.applyLinkedWorkItem(pr))
act(() => result.current.handleNameValueChange('002'))
expect(
result.current.prepare({
...getSmartGitHubSubmitResolution(pr),
kind: 'pr-start-point',
baseBranch: 'feature/export'
})
).toMatchObject({ workspaceName: '002', nameIsAutoManaged: false, submitLinkedPR: 2 })
})
})
@@ -5,9 +5,23 @@ import { describe, expect, it, vi } from 'vitest'
import { useIssueSourceActions } from './issue-source-actions'
import { resolveDraftBaseBranchNamesWorkspace } from './workspace-identity-state'
import { useGitHubProviderSelection } from './github-provider-selection'
import type { LinearIssue } from '../../../../shared/linear/issue-types'
type Input = Parameters<typeof useIssueSourceActions>[0]
const linearIssue: LinearIssue = {
id: 'linear-9',
identifier: 'ENG-9',
title: 'Fix refreshed export',
url: 'https://linear.app/fixture/issue/ENG-9',
state: { name: 'Open', type: 'unstarted', color: '' },
team: { id: 'engineering', name: 'Engineering', key: 'ENG' },
labels: [],
labelIds: [],
priority: 0,
updatedAt: ''
}
function createInput(overrides: Partial<Input> = {}): Input {
return {
baseBranch: undefined,
@@ -42,6 +56,43 @@ function createInput(overrides: Partial<Input> = {}): Input {
}
describe('composer name source selection', () => {
it.each([false, true])(
'keeps an Advanced Linear identifier name on source refresh (folder %s)',
(isProjectGroupTarget) => {
const input = createInput({
isProjectGroupTarget,
name: 'ENG-9',
lastAutoNameRef: { current: 'eng-9-fix-export' },
linkedWorkItem: {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Fix export',
url: linearIssue.url,
linearIdentifier: 'ENG-9'
}
})
const { result } = renderHook(() => useIssueSourceActions(input))
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
expect(input.setName).not.toHaveBeenCalled()
expect(input.setLinkedWorkItem).toHaveBeenCalledWith(
expect.objectContaining({ linearIdentifier: 'ENG-9' })
)
}
)
it.each([false, true])(
'still replaces an unselected Linear identifier query (folder %s)',
(isProjectGroupTarget) => {
const input = createInput({ isProjectGroupTarget, name: 'ENG-9' })
const { result } = renderHook(() => useIssueSourceActions(input))
act(() => result.current.handleSmartLinearIssueSelect(linearIssue))
expect(input.setName).toHaveBeenCalledTimes(1)
}
)
it.each([false, true])('clears only source-owned bases when selecting Jira (%s)', (owned) => {
const input = createInput({ baseBranch: 'release/1.2', baseBranchNamesWorkspace: owned })
const { result } = renderHook(() => useIssueSourceActions(input))
@@ -25,14 +25,16 @@ export type ComposerSourceModel = {
nextBaseBranch: string,
item: GitLabWorkItem,
nextPushTarget?: GitPushTarget | undefined,
nextCompareBaseRef?: string | undefined
nextCompareBaseRef?: string | undefined,
options?: { sourceAlreadyLinked?: boolean }
) => void
handleBaseBranchPrSelect: (
nextBaseBranch: string,
item: GitHubWorkItem,
nextPushTarget?: GitPushTarget | undefined,
nextBranchNameOverride?: string | undefined,
nextCompareBaseRef?: string | undefined
nextCompareBaseRef?: string | undefined,
options?: { sourceAlreadyLinked?: boolean }
) => void
handleBranchNameOverrideChange: (value: string | undefined) => void
handleClearSmartNameSelection: () => void
@@ -159,6 +159,7 @@ export function useComposerSourceState(
handleBaseBranchPrSelect: branchStartPointActions.handleBaseBranchPrSelect,
isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget,
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
linkedWorkItem: target.sourceContextState.linkedWorkItem,
name: target.sourceContextState.name,
selectedRepo: target.runtimeTargetSelection.selectedRepo,
selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext,
@@ -188,6 +189,7 @@ export function useComposerSourceState(
handleBaseBranchMrSelect: branchStartPointActions.handleBaseBranchMrSelect,
isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget,
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
linkedWorkItem: target.sourceContextState.linkedWorkItem,
name: target.sourceContextState.name,
selectedRepo: target.runtimeTargetSelection.selectedRepo,
setBaseBranch: target.workspaceIdentityState.setBaseBranch,
@@ -9,6 +9,7 @@ type GitHubProviderSelectionInput = Pick<
| 'handleBaseBranchPrSelect'
| 'isProjectGroupTarget'
| 'lastAutoNameRef'
| 'linkedWorkItem'
| 'name'
| 'selectedRepo'
| 'selectedRepoGitHubSourceContext'
@@ -55,6 +56,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
handleBaseBranchPrSelect,
isProjectGroupTarget,
lastAutoNameRef,
linkedWorkItem,
name,
selectedRepo,
selectedRepoGitHubSourceContext,
@@ -96,7 +98,8 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
nextName &&
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
})
) {
setName(nextName)
@@ -150,7 +153,8 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
normalizedItem,
result.pushTarget,
result.branchNameOverride,
result.compareBaseRef
result.compareBaseRef,
{ sourceAlreadyLinked: true }
)
// Why: a fork PR push lands on the contributor's fork; without maintainer-edits allowed GitHub rejects it, so warn up front.
setForkPushWarning(getForkPushWarning(result))
@@ -176,6 +180,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
handleBaseBranchPrSelect,
isProjectGroupTarget,
name,
linkedWorkItem,
selectedRepo,
selectedRepoGitHubSourceContext,
branchAutoNameRef,
@@ -4,6 +4,7 @@ type GitHubSourceApplicationInput = Pick<
ComposerModel,
| 'branchAutoNameRef'
| 'lastAutoNameRef'
| 'linkedWorkItem'
| 'name'
| 'selectedRepoGitHubSourceContext'
| 'setBranchNameOverride'
@@ -27,6 +28,7 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput)
const {
branchAutoNameRef,
lastAutoNameRef,
linkedWorkItem,
name,
selectedRepoGitHubSourceContext,
setBranchNameOverride,
@@ -73,7 +75,8 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput)
suggestedName &&
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
})
) {
setName(suggestedName)
@@ -87,6 +90,7 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput)
},
[
name,
linkedWorkItem,
selectedRepoGitHubSourceContext,
branchAutoNameRef,
lastAutoNameRef,
@@ -8,6 +8,7 @@ type GitLabProviderSelectionInput = Pick<
| 'handleBaseBranchMrSelect'
| 'isProjectGroupTarget'
| 'lastAutoNameRef'
| 'linkedWorkItem'
| 'name'
| 'selectedRepo'
| 'setBaseBranch'
@@ -47,6 +48,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
handleBaseBranchMrSelect,
isProjectGroupTarget,
lastAutoNameRef,
linkedWorkItem,
name,
selectedRepo,
setBaseBranch,
@@ -81,7 +83,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
nextName &&
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
})
) {
setName(nextName)
@@ -149,7 +152,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
result.baseBranch,
item,
result.pushTarget,
result.compareBaseRef
result.compareBaseRef,
{ sourceAlreadyLinked: true }
)
})
.catch((error: unknown) => {
@@ -169,6 +173,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput)
handleBaseBranchMrSelect,
isProjectGroupTarget,
name,
linkedWorkItem,
selectedRepo,
branchAutoNameRef,
lastAutoNameRef,
@@ -103,9 +103,10 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
if (
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
}) ||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
(!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase())
) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
@@ -124,9 +125,10 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
if (
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
}) ||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
(!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase())
) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
@@ -141,6 +143,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
[
isProjectGroupTarget,
name,
linkedWorkItem,
branchAutoNameRef,
lastAutoNameRef,
setBranchNameOverride,
@@ -182,7 +185,8 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
suggestedName &&
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
})
) {
setName(suggestedName)
@@ -191,6 +195,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
},
[
name,
linkedWorkItem,
baseBranchNamesWorkspace,
branchAutoNameRef,
lastAutoNameRef,
@@ -71,7 +71,8 @@ export function useQuickSubmitSourcePreparation(input: QuickSubmitSourcePreparat
const nameIsAutoManaged = !isExplicitWorkspaceNameInput({
name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem && smartGitHubResolution.kind !== 'none'
})
const smartGitHubCreateNames =
@@ -111,7 +111,8 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
nextName &&
shouldApplyWorkspaceSourceAutoName({
currentName: name,
lastAutoName: lastAutoNameRef.current
lastAutoName: lastAutoNameRef.current,
lookupTextIsQuery: !linkedWorkItem
})
) {
setName(nextName)
@@ -123,6 +124,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
},
[
name,
linkedWorkItem,
branchAutoNameRef,
lastAutoNameRef,
setBranchNameOverride,
@@ -10,6 +10,24 @@ import {
} from './workspace-source'
describe('workspace source policy', () => {
it.each(['347', '002', '#347', 'https://github.com/fixture/repo/issues/347'])(
'keeps manual name %s after the query field becomes a selected source',
(currentName) => {
expect(
shouldApplyWorkspaceSourceAutoName({
currentName,
lastAutoName: 'fix-export',
lookupTextIsQuery: false
})
).toBe(false)
expect(
shouldApplyWorkspaceSourceAutoName({
currentName,
lastAutoName: 'fix-export'
})
).toBe(true)
}
)
const linear = buildLinearWorkspaceSource({
identifier: 'ENG-42',
title: 'Ship mobile parity',
+3 -1
View File
@@ -160,11 +160,13 @@ export function buildJiraWorkspaceSource(
export function shouldApplyWorkspaceSourceAutoName(args: {
currentName: string
lastAutoName: string
lookupTextIsQuery?: boolean
}): boolean {
return (
!args.currentName.trim() ||
args.currentName === args.lastAutoName ||
isWorkItemLookupText(args.currentName)
// A selected source exposes a name field; its manual edits are no longer lookup queries.
(args.lookupTextIsQuery !== false && isWorkItemLookupText(args.currentName))
)
}