Reapply "feat(composer): choose a base ref in the New Workspace composer" (#21536) (#21543)

This reverts commit a7b9ea5995.

Co-authored-by: Vincent <47273853+Tkotm76@users.noreply.github.com>
This commit is contained in:
Neil
2026-09-18 16:00:26 -07:00
committed by GitHub
co-authored by Vincent
parent a7b9ea5995
commit 9ebfd2e8ae
25 changed files with 805 additions and 15 deletions
@@ -0,0 +1,229 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import NewWorkspaceComposerCard from './NewWorkspaceComposerCard'
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) =>
selector({
closeModal: vi.fn(),
openModal: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
setRuntimeEnvironmentStatus: vi.fn(),
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
repos: [],
worktreesByRepo: {}
}),
{ getState: () => ({}) }
)
}))
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
useContextualTour: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
default: () => <button type="button">Agent picker</button>
}))
vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({
AddRemoteHostDialog: () => null
}))
vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({
default: () => <input aria-label="workspace name" />
}))
vi.mock('@/components/new-workspace/ProjectCombobox', () => ({
default: () => <div data-testid="project-combobox" />
}))
// Why: the picker owns its own test; here it only has to report its value and emit picks.
vi.mock('@/components/repo/CreateFromPicker', () => ({
CreateFromPicker: ({
value,
onValueChange
}: {
value: string
onValueChange: (next: string) => void
}) => (
<div data-testid="base-ref-picker" data-value={value}>
<button type="button" onClick={() => onValueChange('release/1.2')}>
Pick release
</button>
<button type="button" onClick={() => onValueChange('')}>
Pick project default
</button>
</div>
)
}))
function renderCard(
overrides: Partial<React.ComponentProps<typeof NewWorkspaceComposerCard>> = {}
): HTMLDivElement {
const container = document.createElement('div')
document.body.appendChild(container)
act(() => {
createRoot(container).render(
<NewWorkspaceComposerCard
quickAgent={null}
onQuickAgentChange={() => {}}
eligibleRepos={[]}
repoId="repo-a"
selectedRepoIsGit
onRepoChange={() => {}}
onProjectChange={() => {}}
primaryActionLabel="Create workspace"
name=""
onNameValueChange={() => {}}
branchNameOverride={undefined}
onBranchNameOverrideChange={() => {}}
onSmartGitHubItemSelect={() => {}}
onSmartGitLabItemSelect={() => {}}
onSmartBranchSelect={() => {}}
onSmartLinearIssueSelect={() => {}}
smartNameSelection={{ kind: 'jira', label: 'ERP-1491' }}
onClearSmartNameSelection={() => {}}
canReuseSelectedBranch={false}
reuseSelectedBranch={false}
onReuseSelectedBranchChange={() => {}}
forkPushWarning={null}
detectedAgentIds={null}
onOpenAgentSettings={() => {}}
advancedOpen={false}
onToggleAdvanced={() => {}}
parentWorktreeId={null}
onParentWorktreeIdChange={() => {}}
createDisabled={false}
projectError={null}
creating={false}
onCreate={() => {}}
note=""
onNoteChange={() => {}}
setupConfig={null}
requiresExplicitSetupChoice={false}
setupDecision={null}
onSetupDecisionChange={() => {}}
setupAgentStartupPolicy="start-immediately"
onSetupAgentStartupPolicyChange={() => {}}
shouldWaitForSetupCheck={false}
resolvedSetupDecision={null}
createError={null}
selectedRepoConnectionId={null}
selectedRepoSshStatus={null}
selectedRepoRequiresConnection={false}
selectedRepoConnectInProgress={false}
onConnectSelectedRepo={async () => {}}
canUseSparseCheckout={false}
sparsePresets={[]}
sparseSelectedPresetId={null}
onSparseSelectPreset={() => {}}
branchesEnabled
setupControlsEnabled={false}
sparseControlsEnabled={false}
baseBranch={undefined}
onBaseBranchChange={() => {}}
startFromResetHint={null}
{...overrides}
/>
)
})
return container
}
function clickButton(container: HTMLDivElement, label: string): void {
const button = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(candidate) => candidate.textContent === label
)
act(() => button?.click())
}
describe('NewWorkspaceComposerCard start from', () => {
let container: HTMLDivElement | null = null
afterEach(() => {
container?.remove()
container = null
})
it('offers a base ref while a Jira issue names the workspace', () => {
container = renderCard()
expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeTruthy()
})
it('reports the picked ref to the composer', () => {
const picks: (string | undefined)[] = []
container = renderCard({ onBaseBranchChange: (next) => picks.push(next) })
clickButton(container, 'Pick release')
expect(picks).toEqual(['release/1.2'])
})
it('reports the project default as no base at all', () => {
const picks: (string | undefined)[] = []
container = renderCard({
baseBranch: 'release/1.2',
onBaseBranchChange: (next) => picks.push(next)
})
clickButton(container, 'Pick project default')
expect(picks).toEqual([undefined])
})
// Why: picking a base clears reuse, so offering one here would silently turn a checkout of
// the picked branch into a new branch off something else.
it('omits the base ref for a branch source, which already is the base', () => {
container = renderCard({
smartNameSelection: { kind: 'branch', label: 'feature/export-v2' },
baseBranch: 'feature/export-v2'
})
expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeNull()
})
it('offers the base ref while a plain typed name owns the field', () => {
container = renderCard({ smartNameSelection: null, name: 'my-own-name' })
expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeTruthy()
})
it.each([
['github-pr', { kind: 'github-pr' as const, label: '#42 Fix' }],
['gitlab-mr', { kind: 'gitlab-mr' as const, label: '!42 Fix' }]
])(
'omits the base ref for a %s source that carries its own base',
(_label, smartNameSelection) => {
container = renderCard({ smartNameSelection })
expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeNull()
}
)
it('omits the base ref when branches are disabled', () => {
container = renderCard({ branchesEnabled: false })
expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeNull()
})
it('surfaces the reset hint left by a project switch', () => {
container = renderCard({ startFromResetHint: 'was origin/main' })
expect(container.textContent).toContain('was origin/main')
})
})
@@ -1,12 +1,12 @@
import { Info } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { CreateFromPicker } from '@/components/repo/CreateFromPicker'
import { translate } from '@/i18n/i18n'
import type { AutomationWorkspaceMode } from '../../../../shared/automations-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts'
import { CreateFromPicker } from './CreateFromPicker'
import { WorkspaceCombobox } from './WorkspaceCombobox'
import type { AutomationDraft } from './AutomationEditorDialog'
@@ -0,0 +1,42 @@
import React from 'react'
import { CreateFromPicker } from '@/components/repo/CreateFromPicker'
import { useRepoMap, useWorktreesForRepo } from '@/store/selectors'
type ComposerBaseRefPickerProps = {
repoId: string
baseBranch: string | undefined
onBaseBranchChange: (value: string | undefined) => void
resetHint: string | null | undefined
}
/**
* Base ref control for the New Workspace composer.
*
* Owns its own store reads so the name section stays presentational and the
* worktree subscription only exists while the picker is actually on screen.
*/
export function ComposerBaseRefPicker({
repoId,
baseBranch,
onBaseBranchChange,
resetHint
}: ComposerBaseRefPickerProps): React.JSX.Element {
const repoMap = useRepoMap()
const repoWorktrees = useWorktreesForRepo(repoId)
return (
<div className="space-y-1 pt-1">
<CreateFromPicker
// Why: branch search state is repo-scoped, so a project switch must drop it before the next paint.
key={repoId}
repoId={repoId}
repoMap={repoMap}
worktrees={repoWorktrees}
value={baseBranch ?? ''}
onValueChange={(nextBaseBranch) => onBaseBranchChange(nextBaseBranch || undefined)}
/>
{resetHint ? <p className="text-[11px] text-muted-foreground">{resetHint}</p> : null}
</div>
)
}
export default ComposerBaseRefPicker
@@ -3,6 +3,8 @@ import { AlertTriangle, Check } from 'lucide-react'
import SmartWorkspaceNameField from '@/components/new-workspace/SmartWorkspaceNameField'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { shouldShowComposerBaseRefPicker } from './base-ref-picker-visibility'
import { ComposerBaseRefPicker } from './ComposerBaseRefPicker'
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
type NewWorkspaceComposerNameSectionProps = Pick<
@@ -35,6 +37,9 @@ type NewWorkspaceComposerNameSectionProps = Pick<
| 'canReuseSelectedBranch'
| 'reuseSelectedBranch'
| 'onReuseSelectedBranchChange'
| 'baseBranch'
| 'onBaseBranchChange'
| 'startFromResetHint'
> & {
onNamePlainEnter: () => void
}
@@ -68,8 +73,18 @@ export function NewWorkspaceComposerNameSection({
forkPushWarning,
canReuseSelectedBranch,
reuseSelectedBranch,
onReuseSelectedBranchChange
onReuseSelectedBranchChange,
baseBranch,
onBaseBranchChange,
startFromResetHint
}: NewWorkspaceComposerNameSectionProps): React.JSX.Element {
const showBaseRefPicker =
Boolean(onBaseBranchChange) &&
shouldShowComposerBaseRefPicker({
selectedRepoIsGit,
branchesEnabled,
smartNameSelectionKind: smartNameSelection?.kind ?? null
})
return (
<div className="min-w-0 space-y-1" data-contextual-tour-target="workspace-creation-name">
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
@@ -120,6 +135,14 @@ export function NewWorkspaceComposerNameSection({
<span>{forkPushWarning}</span>
</p>
) : null}
{showBaseRefPicker && onBaseBranchChange ? (
<ComposerBaseRefPicker
repoId={repoId}
baseBranch={baseBranch}
onBaseBranchChange={onBaseBranchChange}
resetHint={startFromResetHint}
/>
) : null}
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { shouldShowComposerBaseRefPicker } from './base-ref-picker-visibility'
const gitRepo = { selectedRepoIsGit: true, branchesEnabled: true }
describe('shouldShowComposerBaseRefPicker', () => {
it('offers a base ref when no source is selected yet', () => {
expect(shouldShowComposerBaseRefPicker({ ...gitRepo, smartNameSelectionKind: null })).toBe(true)
})
it.each(['github-issue', 'gitlab-issue', 'linear', 'jira'] as const)(
'offers a base ref for a %s source',
(smartNameSelectionKind) => {
expect(shouldShowComposerBaseRefPicker({ ...gitRepo, smartNameSelectionKind })).toBe(true)
}
)
it.each(['github-pr', 'gitlab-mr', 'branch'] as const)(
'hides the base ref for a %s source that carries its own base',
(smartNameSelectionKind) => {
expect(shouldShowComposerBaseRefPicker({ ...gitRepo, smartNameSelectionKind })).toBe(false)
}
)
it('hides the base ref for a non-git project', () => {
expect(
shouldShowComposerBaseRefPicker({
selectedRepoIsGit: false,
branchesEnabled: true,
smartNameSelectionKind: 'jira'
})
).toBe(false)
})
it('hides the base ref when branches are disabled, as on a folder workspace', () => {
expect(
shouldShowComposerBaseRefPicker({
selectedRepoIsGit: true,
branchesEnabled: false,
smartNameSelectionKind: 'jira'
})
).toBe(false)
})
})
@@ -0,0 +1,25 @@
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
/**
* Whether the composer offers a base ref for the worktree it is about to create.
*
* The name field's tabs pick how the workspace is NAMED; the base ref is a separate
* decision, so the picker stays available for every naming source — except those that
* already carry a base of their own.
*/
export function shouldShowComposerBaseRefPicker(args: {
selectedRepoIsGit: boolean
branchesEnabled: boolean
smartNameSelectionKind: SmartWorkspaceNameSelection['kind'] | null
}): boolean {
if (!args.selectedRepoIsGit || !args.branchesEnabled) {
return false
}
// Why: these already carry a base — a PR/MR pins its own head, and a branch pick IS the base,
// where overriding would silently turn a checkout of it into a new branch off something else.
return (
args.smartNameSelectionKind !== 'github-pr' &&
args.smartNameSelectionKind !== 'gitlab-mr' &&
args.smartNameSelectionKind !== 'branch'
)
}
@@ -62,6 +62,9 @@ export type NewWorkspaceComposerCardProps = {
onNameValueChange: (value: string) => void
branchNameOverride: string | undefined
onBranchNameOverrideChange: (value: string | undefined) => void
baseBranch?: string
onBaseBranchChange?: (value: string | undefined) => void
startFromResetHint?: string | null
parentWorktreeId?: string | null
onParentWorktreeIdChange?: (value: string | null) => void
selectedRepoExecutionHostId?: ExecutionHostId | null
@@ -11,7 +11,19 @@ import {
} from '@/runtime/runtime-repo-client'
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
// Why: the real popover owns open state; expose onOpenChange so a test can open the list.
Popover: ({
children,
onOpenChange
}: {
children: React.ReactNode
onOpenChange?: (open: boolean) => void
}) => (
<div>
<button type="button" data-testid="open-popover" onClick={() => onOpenChange?.(true)} />
{children}
</div>
),
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
@@ -101,6 +113,28 @@ describe('CreateFromPicker host routing', () => {
)
})
// Why: the composer's Branch tab already lists on an empty query; a minimum-length gate left
// this picker showing only the default ref and branches that happen to have a worktree.
it('lists the repo branches as soon as it opens, with no query typed', async () => {
const repo = makeRepo({ executionHostId: 'local' })
storeState.repos = [repo]
await renderPicker(repo)
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="open-popover"]')?.click()
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 250))
})
expect(searchRuntimeRepoBaseRefs).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
repo.id,
'',
30
)
})
it('keeps an explicit local repo on the local client even when a runtime is focused', async () => {
const repo = makeRepo({ executionHostId: 'local' })
storeState.repos = [repo]
@@ -1,4 +1,3 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: picker base-ref defaults and search results come from debounced runtime IPC, so loading/result state is intentionally synchronized from effects. */
import React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -144,7 +143,9 @@ export function CreateFromPicker({
return
}
const trimmedQuery = query.trim()
if (!open || !repoId || trimmedQuery.length < 2) {
// Why: an empty query lists the repo's branches, as the composer's Branch tab already does;
// a minimum length left this picker showing only the default and worktree branches.
if (!open || !repoId) {
setSearchResults([])
setIsSearching(false)
return
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useBranchStartPointActions } from './branch-start-point-actions'
type Input = Parameters<typeof useBranchStartPointActions>[0]
function createInput(overrides: Partial<Input> = {}): Input {
return {
applyLinkedGitLabWorkItem: vi.fn(),
applyLinkedWorkItem: vi.fn(),
baseBranch: undefined,
baseBranchNamesWorkspace: true,
branchAutoNameRef: { current: 'alice/sta-42-fix-export' },
handleRepoChange: vi.fn(),
initialProjectGroupAppliedRef: { current: false },
lastAutoNoteRef: { current: '' },
noteRef: { current: '' },
setBaseBranch: vi.fn(),
setBaseBranchNamesWorkspace: vi.fn(),
setBranchNameOverride: vi.fn(),
setBranchNameOverridePreservesNameEdits: vi.fn(),
setCompareBaseRef: vi.fn(),
setForkPushWarning: vi.fn(),
setNote: vi.fn(),
setProjectError: vi.fn(),
setSelectedProjectGroupId: vi.fn(),
setPushTarget: vi.fn(),
setReuseEligibleBranch: vi.fn(),
setReuseSelectedBranch: vi.fn(),
setSparseDirectories: vi.fn(),
setSparseEnabled: vi.fn(),
setSparseSelectedPresetId: vi.fn(),
setStartFromResetHint: vi.fn(),
smartGitHubPrStartPointSelectionRef: { current: null },
...overrides
}
}
describe('composer base changes', () => {
it('marks a PR head as source-owned after replacing an independent base', () => {
const input = createInput({ baseBranch: 'release/1.2', baseBranchNamesWorkspace: false })
const { result } = renderHook(() => useBranchStartPointActions(input))
act(() =>
result.current.handleBaseBranchPrSelect('refs/pull/42/head', {
id: 'pr-42',
type: 'pr',
number: 42,
title: 'Fix export',
state: 'open',
url: 'https://github.com/o/r/pull/42',
labels: [],
updatedAt: '',
author: null,
repoId: 'repo-1'
})
)
expect(input.setBaseBranch).toHaveBeenCalledWith('refs/pull/42/head')
expect(input.setBaseBranchNamesWorkspace).toHaveBeenCalledWith(true)
})
it.each([
{ baseBranch: undefined, baseBranchNamesWorkspace: true },
{ baseBranch: 'release/1.2', baseBranchNamesWorkspace: false }
])('preserves independent branch naming state from %j', (previous) => {
const input = createInput(previous)
const { result } = renderHook(() => useBranchStartPointActions(input))
act(() => result.current.handleBaseBranchChange('release/2.0'))
act(() => result.current.handleBaseBranchChange(undefined))
expect(input.setBaseBranch).toHaveBeenNthCalledWith(1, 'release/2.0')
expect(input.setBaseBranch).toHaveBeenNthCalledWith(2, undefined)
expect(input.setBaseBranchNamesWorkspace).toHaveBeenCalledWith(false)
expect(input.setBranchNameOverride).not.toHaveBeenCalled()
expect(input.setBranchNameOverridePreservesNameEdits).not.toHaveBeenCalled()
expect(input.branchAutoNameRef.current).toBe('alice/sta-42-fix-export')
})
it('still drops branch reuse and its pinned name when replacing a source-owned base', () => {
const input = createInput({ baseBranch: 'feature/existing' })
const { result } = renderHook(() => useBranchStartPointActions(input))
act(() => result.current.handleBaseBranchChange('release/2.0'))
expect(input.setBranchNameOverride).toHaveBeenCalledWith(undefined)
expect(input.setBranchNameOverridePreservesNameEdits).toHaveBeenCalledWith(false)
expect(input.setReuseEligibleBranch).toHaveBeenCalledWith(null)
expect(input.setReuseSelectedBranch).toHaveBeenCalledWith(false)
expect(input.branchAutoNameRef.current).toBe('')
})
})
@@ -4,12 +4,15 @@ type BranchStartPointActionsInput = Pick<
ComposerModel,
| 'applyLinkedGitLabWorkItem'
| 'applyLinkedWorkItem'
| 'baseBranch'
| 'baseBranchNamesWorkspace'
| 'branchAutoNameRef'
| 'handleRepoChange'
| 'initialProjectGroupAppliedRef'
| 'lastAutoNoteRef'
| 'noteRef'
| 'setBaseBranch'
| 'setBaseBranchNamesWorkspace'
| 'setBranchNameOverride'
| 'setBranchNameOverridePreservesNameEdits'
| 'setCompareBaseRef'
@@ -38,12 +41,15 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
const {
applyLinkedGitLabWorkItem,
applyLinkedWorkItem,
baseBranch,
baseBranchNamesWorkspace,
branchAutoNameRef,
handleRepoChange,
initialProjectGroupAppliedRef,
lastAutoNoteRef,
noteRef,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -91,20 +97,26 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
(next: string | undefined): void => {
smartGitHubPrStartPointSelectionRef.current = null
setBaseBranch(next)
setBaseBranchNamesWorkspace(false)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
// Why (#5181): Start-from means "new branch from this base", so it never reuses — clear reuse state from a prior smart-field branch pick.
setBranchNameOverridePreservesNameEdits(false)
// Only source-owned refs pin a name that changing the base invalidates.
if (baseBranch && baseBranchNamesWorkspace) {
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
branchAutoNameRef.current = ''
}
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
},
[
baseBranch,
baseBranchNamesWorkspace,
branchAutoNameRef,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -126,6 +138,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
nextCompareBaseRef?: string
): void => {
setBaseBranch(nextBaseBranch)
setBaseBranchNamesWorkspace(true)
setCompareBaseRef(nextCompareBaseRef)
setPushTarget(nextPushTarget)
setBranchNameOverride(nextBranchNameOverride)
@@ -151,6 +164,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
lastAutoNoteRef,
noteRef,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -169,6 +183,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
nextCompareBaseRef?: string
): void => {
setBaseBranch(nextBaseBranch)
setBaseBranchNamesWorkspace(true)
setCompareBaseRef(nextCompareBaseRef)
setPushTarget(nextPushTarget)
setBranchNameOverride(undefined)
@@ -190,6 +205,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput)
lastAutoNoteRef,
noteRef,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setCompareBaseRef,
setNote,
@@ -85,6 +85,7 @@ export function useComposerExternalSync(target: ComposerTargetState): ComposerEx
selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext,
selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit,
setBaseBranch: target.workspaceIdentityState.setBaseBranch,
setBaseBranchNamesWorkspace: target.workspaceIdentityState.setBaseBranchNamesWorkspace,
setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride,
setBranchNameOverridePreservesNameEdits:
target.workspaceIdentityState.setBranchNameOverridePreservesNameEdits,
@@ -0,0 +1,193 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
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'
type Input = Parameters<typeof useIssueSourceActions>[0]
function createInput(overrides: Partial<Input> = {}): Input {
return {
baseBranch: undefined,
baseBranchNamesWorkspace: true,
branchAutoNameRef: { current: '' },
isProjectGroupTarget: false,
lastAutoNameRef: { current: '' },
lastAutoNoteRef: { current: '' },
linkedWorkItem: null,
name: '',
noteRef: { current: '' },
setBaseBranch: vi.fn(),
setBranchNameOverride: vi.fn(),
setBranchNameOverridePreservesNameEdits: vi.fn(),
setCompareBaseRef: vi.fn(),
setForkPushWarning: vi.fn(),
setLinkedGitLabIssue: vi.fn(),
setLinkedGitLabMR: vi.fn(),
setLinkedIssue: vi.fn(),
setLinkedPR: vi.fn(),
setLinkedTaskSourceContext: vi.fn(),
setLinkedWorkItem: vi.fn(),
setName: vi.fn(),
setNote: vi.fn(),
setPushTarget: vi.fn(),
setReuseEligibleBranch: vi.fn(),
setReuseSelectedBranch: vi.fn(),
setStartFromResetHint: vi.fn(),
smartGitHubPrStartPointSelectionRef: { current: null },
...overrides
} as Input
}
describe('composer name source selection', () => {
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))
act(() =>
result.current.handleSmartJiraIssueSelect(
{
id: '42',
key: 'APP-42',
title: 'Fix export',
url: 'https://jira.example/browse/APP-42',
project: { id: 'app', key: 'APP', name: 'App' },
issueType: { id: 'bug', name: 'Bug' },
status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To do' },
labels: [],
updatedAt: '',
createdAt: ''
},
{ kind: 'task-source', provider: 'jira', projectId: 'app', hostId: 'local' }
)
)
expect(input.setBaseBranch).toHaveBeenCalledTimes(owned ? 1 : 0)
if (owned) {
expect(input.setBaseBranch).toHaveBeenCalledWith(undefined)
}
})
it.each([false, true])('clears only source-owned bases when removing a source (%s)', (owned) => {
const input = createInput({ baseBranch: 'release/1.2', baseBranchNamesWorkspace: owned })
const { result } = renderHook(() => useIssueSourceActions(input))
act(() => result.current.handleClearSmartNameSelection())
expect(input.setBaseBranch).toHaveBeenCalledTimes(owned ? 1 : 0)
if (owned) {
expect(input.setBaseBranch).toHaveBeenCalledWith(undefined)
}
})
it.each([false, true])(
'clears only source-owned bases when selecting a GitHub issue (%s)',
(owned) => {
const input = {
...createInput({ baseBranch: 'release/1.2', baseBranchNamesWorkspace: owned }),
applyLinkedWorkItem: vi.fn(),
eligibleRepos: [],
handleBaseBranchPrSelect: vi.fn(),
selectedRepo: undefined,
selectedRepoGitHubSourceContext: null,
settings: null
}
const { result } = renderHook(() => useGitHubProviderSelection(input))
act(() =>
result.current.handleSmartGitHubItemSelect({
id: 'issue-42',
type: 'issue',
number: 42,
title: 'Fix export',
state: 'open',
url: 'https://github.com/o/r/issues/42',
labels: [],
updatedAt: '',
author: null,
repoId: 'repo-1'
})
)
expect(input.setBaseBranch).toHaveBeenCalledTimes(owned ? 1 : 0)
if (owned) {
expect(input.setBaseBranch).toHaveBeenCalledWith(undefined)
}
}
)
it('names the workspace after a branch the name field picked', () => {
const { result } = renderHook(() =>
useIssueSourceActions(
createInput({ baseBranch: 'feature/export-v2', baseBranchNamesWorkspace: true })
)
)
expect(result.current.smartNameSelection).toEqual({
kind: 'branch',
label: 'feature/export-v2'
})
})
// Why: the field swaps the text input for a source pill, so a pill here would hide a typed name.
it('claims no source for a base ref the composer picker chose, leaving a typed name on screen', () => {
const { result } = renderHook(() =>
useIssueSourceActions(
createInput({
baseBranch: 'release/1.2',
baseBranchNamesWorkspace: false,
name: 'my-own-name'
})
)
)
expect(result.current.smartNameSelection).toBeNull()
})
it('keeps a linked issue as the source whatever the base ref', () => {
const { result } = renderHook(() =>
useIssueSourceActions(
createInput({
baseBranch: 'release/1.2',
baseBranchNamesWorkspace: false,
linkedWorkItem: {
type: 'issue',
number: 42,
title: 'Broken export',
url: 'https://github.com/o/r/issues/42'
} as Input['linkedWorkItem']
})
)
)
expect(result.current.smartNameSelection?.kind).toBe('github-issue')
})
})
describe('base ref intent across a draft round trip', () => {
it('restores a base ref chosen in the picker as a base, not as a name source', () => {
expect(resolveDraftBaseBranchNamesWorkspace({ persistDraft: true, draftValue: false })).toBe(
false
)
})
it('restores a branch picked in the name field as a name source', () => {
expect(resolveDraftBaseBranchNamesWorkspace({ persistDraft: true, draftValue: true })).toBe(
true
)
})
it('treats a draft written before the flag existed as a name source', () => {
expect(
resolveDraftBaseBranchNamesWorkspace({ persistDraft: true, draftValue: undefined })
).toBe(true)
})
it('ignores any stored intent when the composer does not persist drafts', () => {
expect(resolveDraftBaseBranchNamesWorkspace({ persistDraft: false, draftValue: false })).toBe(
true
)
})
})
@@ -132,6 +132,8 @@ export function useComposerSourceState(
workspaceHostScope: target.composerTargetStore.workspaceHostScope
})
const branchStartPointActions = useBranchStartPointActions({
baseBranch: target.workspaceIdentityState.baseBranch,
baseBranchNamesWorkspace: target.workspaceIdentityState.baseBranchNamesWorkspace,
applyLinkedGitLabWorkItem: sourceIdentityActions.applyLinkedGitLabWorkItem,
applyLinkedWorkItem: external.githubSourceApplication.applyLinkedWorkItem,
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
@@ -140,6 +142,7 @@ export function useComposerSourceState(
lastAutoNoteRef: target.asyncComposerState.lastAutoNoteRef,
noteRef: target.asyncComposerState.noteRef,
setBaseBranch: target.workspaceIdentityState.setBaseBranch,
setBaseBranchNamesWorkspace: target.workspaceIdentityState.setBaseBranchNamesWorkspace,
setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride,
setBranchNameOverridePreservesNameEdits:
target.workspaceIdentityState.setBranchNameOverridePreservesNameEdits,
@@ -159,6 +162,7 @@ export function useComposerSourceState(
target.asyncComposerState.smartGitHubPrStartPointSelectionRef
})
const githubProviderSelection = useGitHubProviderSelection({
baseBranchNamesWorkspace: target.workspaceIdentityState.baseBranchNamesWorkspace,
applyLinkedWorkItem: external.githubSourceApplication.applyLinkedWorkItem,
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
eligibleRepos: target.composerTargetStore.eligibleRepos,
@@ -220,6 +224,7 @@ export function useComposerSourceState(
repoId: target.initialTargetState.repoId,
reuseEligibleBranch: target.workspaceIdentityState.reuseEligibleBranch,
setBaseBranch: target.workspaceIdentityState.setBaseBranch,
setBaseBranchNamesWorkspace: target.workspaceIdentityState.setBaseBranchNamesWorkspace,
setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride,
setBranchNameOverridePreservesNameEdits:
target.workspaceIdentityState.setBranchNameOverridePreservesNameEdits,
@@ -236,6 +241,7 @@ export function useComposerSourceState(
})
const issueSourceActions = useIssueSourceActions({
baseBranch: target.workspaceIdentityState.baseBranch,
baseBranchNamesWorkspace: target.workspaceIdentityState.baseBranchNamesWorkspace,
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget,
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
@@ -156,6 +156,7 @@ export function useComposerTargetState(
agentPrompt: sourceContextState.agentPrompt,
attachmentPaths: sourceContextState.attachmentPaths,
baseBranch: workspaceIdentityState.baseBranch,
baseBranchNamesWorkspace: workspaceIdentityState.baseBranchNamesWorkspace,
compareBaseRef: workspaceIdentityState.compareBaseRef,
eligibleRepos: composerTargetStore.eligibleRepos,
fetchSparsePresets: composerTargetStore.fetchSparsePresets,
@@ -19,6 +19,7 @@ function createState(overrides: Partial<DraftTargetSyncInput> = {}): DraftTarget
agentPrompt: 'Fix the issue',
attachmentPaths: ['/tmp/context.txt'],
baseBranch: 'main',
baseBranchNamesWorkspace: true,
compareBaseRef: 'origin/main',
eligibleRepos: [],
fetchSparsePresets: vi.fn<DraftTargetSyncInput['fetchSparsePresets']>(),
@@ -78,6 +79,25 @@ describe('useDraftTargetSync', () => {
expect(setRepoId).toHaveBeenCalledWith('repo-1')
})
it('persists whether the base ref also names the workspace', () => {
const setNewWorkspaceDraft = vi.fn<DraftTargetSyncInput['setNewWorkspaceDraft']>()
const state = createState({
eligibleRepos: [createRepo('repo-1')],
baseBranch: 'release/2.1',
baseBranchNamesWorkspace: false,
setNewWorkspaceDraft
})
renderHook(() => useDraftTargetSync(state))
expect(setNewWorkspaceDraft).toHaveBeenCalledWith(
expect.objectContaining({
baseBranch: 'release/2.1',
baseBranchNamesWorkspace: false
})
)
})
it('does not persist or repair transient quick-composer state', () => {
const state = createState({
persistDraft: false,
@@ -6,6 +6,7 @@ export type DraftTargetSyncInput = Pick<
| 'agentPrompt'
| 'attachmentPaths'
| 'baseBranch'
| 'baseBranchNamesWorkspace'
| 'compareBaseRef'
| 'eligibleRepos'
| 'fetchSparsePresets'
@@ -36,6 +37,7 @@ export function useDraftTargetSync(state: DraftTargetSyncInput): void {
agentPrompt,
attachmentPaths,
baseBranch,
baseBranchNamesWorkspace,
compareBaseRef,
eligibleRepos,
fetchSparsePresets,
@@ -98,7 +100,7 @@ export function useDraftTargetSync(state: DraftTargetSyncInput): void {
linkedPR,
linkedGitLabIssue,
linkedGitLabMR,
...(baseBranch !== undefined ? { baseBranch } : {}),
...(baseBranch !== undefined ? { baseBranch, baseBranchNamesWorkspace } : {}),
...(compareBaseRef !== undefined ? { compareBaseRef } : {})
})
}, [
@@ -106,6 +108,7 @@ export function useDraftTargetSync(state: DraftTargetSyncInput): void {
agentPrompt,
attachmentPaths,
baseBranch,
baseBranchNamesWorkspace,
compareBaseRef,
linkedIssue,
linkedPR,
@@ -3,6 +3,7 @@ import type { ComposerModel } from './composer-model'
type GitHubProviderSelectionInput = Pick<
ComposerModel,
| 'applyLinkedWorkItem'
| 'baseBranchNamesWorkspace'
| 'branchAutoNameRef'
| 'eligibleRepos'
| 'handleBaseBranchPrSelect'
@@ -47,6 +48,7 @@ import type { SmartGitHubPrStartPointSelection } from './source-selection-decisi
export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) {
const {
applyLinkedWorkItem,
baseBranchNamesWorkspace,
branchAutoNameRef,
eligibleRepos,
handleBaseBranchPrSelect,
@@ -112,7 +114,9 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId)
applyLinkedWorkItem(normalizedItem)
if (identity.type !== 'pr' || !runRepo) {
setBaseBranch(undefined)
if (identity.type === 'pr' || baseBranchNamesWorkspace) {
setBaseBranch(undefined)
}
setCompareBaseRef(undefined)
setPushTarget(undefined)
return
@@ -171,6 +175,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput)
},
[
applyLinkedWorkItem,
baseBranchNamesWorkspace,
eligibleRepos,
handleBaseBranchPrSelect,
isProjectGroupTarget,
@@ -12,6 +12,7 @@ type GitHubSubmitResolutionInput = Pick<
| 'selectedRepoGitHubSourceContext'
| 'selectedRepoIsGit'
| 'setBaseBranch'
| 'setBaseBranchNamesWorkspace'
| 'setBranchNameOverride'
| 'setBranchNameOverridePreservesNameEdits'
| 'setCompareBaseRef'
@@ -62,6 +63,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) {
selectedRepoGitHubSourceContext,
selectedRepoIsGit,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -133,6 +135,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) {
: {})
}
setBaseBranch(selectedPrStartPoint.baseBranch)
setBaseBranchNamesWorkspace(true)
setCompareBaseRef(selectedPrStartPoint.compareBaseRef)
setPushTarget(selectedPrStartPoint.pushTarget)
if (selectedPrStartPoint.branchNameOverride) {
@@ -234,6 +237,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) {
lastAutoNameRef.current = resolution.workspaceName
if (prStartPoint) {
setBaseBranch(prStartPoint.baseBranch)
setBaseBranchNamesWorkspace(true)
setCompareBaseRef(prStartPoint.compareBaseRef)
setPushTarget(prStartPoint.pushTarget)
if (prStartPoint.branchNameOverride) {
@@ -263,6 +267,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) {
branchAutoNameRef,
lastAutoNameRef,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -14,6 +14,8 @@ export type ComposerIdentityModel = {
setLinkedGitLabMR: React.Dispatch<React.SetStateAction<number | null>>
baseBranch: string | undefined
setBaseBranch: React.Dispatch<React.SetStateAction<string | undefined>>
baseBranchNamesWorkspace: boolean
setBaseBranchNamesWorkspace: React.Dispatch<React.SetStateAction<boolean>>
compareBaseRef: string | undefined
setCompareBaseRef: React.Dispatch<React.SetStateAction<string | undefined>>
branchNameOverride: string | undefined
@@ -3,6 +3,7 @@ import type { ComposerModel } from './composer-model'
type IssueSourceActionsInput = Pick<
ComposerModel,
| 'baseBranch'
| 'baseBranchNamesWorkspace'
| 'branchAutoNameRef'
| 'isProjectGroupTarget'
| 'lastAutoNameRef'
@@ -59,6 +60,7 @@ import type { TaskSourceContext } from '../../../../shared/task-source-context'
export function useIssueSourceActions(input: IssueSourceActionsInput) {
const {
baseBranch,
baseBranchNamesWorkspace,
branchAutoNameRef,
isProjectGroupTarget,
lastAutoNameRef,
@@ -161,7 +163,9 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setBaseBranch(undefined)
if (baseBranchNamesWorkspace) {
setBaseBranch(undefined)
}
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
@@ -187,6 +191,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
},
[
name,
baseBranchNamesWorkspace,
branchAutoNameRef,
lastAutoNameRef,
setBaseBranch,
@@ -213,7 +218,9 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
setLinkedGitLabMR(null)
setLinkedWorkItem(null)
setLinkedTaskSourceContext(null)
setBaseBranch(undefined)
if (baseBranchNamesWorkspace) {
setBaseBranch(undefined)
}
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
@@ -233,6 +240,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
}
}, [
name,
baseBranchNamesWorkspace,
branchAutoNameRef,
lastAutoNameRef,
lastAutoNoteRef,
@@ -263,9 +271,11 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) {
}
return buildWorkspaceSourceSelection({
linkedWorkItem,
baseBranch
// Why: only a branch picked to NAME the workspace becomes a source pill; a base ref
// chosen in the composer's own picker must leave a typed name on screen.
baseBranch: baseBranchNamesWorkspace ? baseBranch : undefined
}) as SmartWorkspaceNameSelection | null
}, [baseBranch, isProjectGroupTarget, linkedWorkItem])
}, [baseBranch, baseBranchNamesWorkspace, isProjectGroupTarget, linkedWorkItem])
return {
handleSmartLinearIssueSelect,
@@ -63,6 +63,8 @@ function useSelectedSourceReset(
)
const source = useIssueSourceActions({
baseBranch,
// Why: a PR/issue/branch source owns the base, so "create more" must still clear it.
baseBranchNamesWorkspace: true,
branchAutoNameRef,
isProjectGroupTarget,
lastAutoNameRef,
@@ -8,6 +8,7 @@ type WorkItemSourceActionsInput = Pick<
| 'repoId'
| 'reuseEligibleBranch'
| 'setBaseBranch'
| 'setBaseBranchNamesWorkspace'
| 'setBranchNameOverride'
| 'setBranchNameOverridePreservesNameEdits'
| 'setCompareBaseRef'
@@ -35,6 +36,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
repoId,
reuseEligibleBranch,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -59,6 +61,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId)
})
setBaseBranch(selection.baseBranch)
setBaseBranchNamesWorkspace(true)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setStartFromResetHint(null)
@@ -86,6 +89,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
branchAutoNameRef,
lastAutoNameRef,
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
@@ -27,6 +27,19 @@ import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tu
import { getAgentCatalog } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
/**
* Whether a restored draft's `baseBranch` still names the workspace.
*
* A draft written before the flag existed carries no intent, so it restores as a branch pick —
* the behavior it had when it was saved.
*/
export function resolveDraftBaseBranchNamesWorkspace(args: {
persistDraft: boolean
draftValue: boolean | undefined
}): boolean {
return args.persistDraft ? (args.draftValue ?? true) : true
}
export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
const {
initialBaseBranch,
@@ -91,6 +104,14 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
const [baseBranch, setBaseBranch] = useState<string | undefined>(
persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch
)
// Why: a branch picked to name the workspace shows as a source pill; a base ref chosen in
// the composer's own picker must not, or it would hide the name the user typed.
const [baseBranchNamesWorkspace, setBaseBranchNamesWorkspace] = useState(() =>
resolveDraftBaseBranchNamesWorkspace({
persistDraft,
draftValue: newWorkspaceDraft?.baseBranchNamesWorkspace
})
)
const [compareBaseRef, setCompareBaseRef] = useState<string | undefined>(
persistDraft ? newWorkspaceDraft?.compareBaseRef : undefined
@@ -193,6 +214,8 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
setLinkedGitLabMR,
baseBranch,
setBaseBranch,
baseBranchNamesWorkspace,
setBaseBranchNamesWorkspace,
compareBaseRef,
setCompareBaseRef,
branchNameOverride,
@@ -99,6 +99,9 @@ export type NewWorkspaceDraft = {
linkedGitLabMR?: number | null
// Why: repo-scoped start ref from the "Start from" picker; absent means "use the repo's effective base ref".
baseBranch?: string
// Why: false when `baseBranch` came from the base-ref picker rather than a branch pick, so restoring the
// draft doesn't turn it back into a name-field pill. Absent on pre-flag drafts, which restore as a pick.
baseBranchNamesWorkspace?: boolean
// Why: review worktrees start from a head ref/SHA while Source Control compares against the provider target branch.
compareBaseRef?: string
}