From 9ebfd2e8ae3e84d76a95919aa45abff8f480a622 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:00:26 -0700 Subject: [PATCH] Reapply "feat(composer): choose a base ref in the New Workspace composer" (#21536) (#21543) This reverts commit a7b9ea59951e048749725924d75db6cea1a37b56. Co-authored-by: Vincent <47273853+Tkotm76@users.noreply.github.com> --- ...wWorkspaceComposerCard.start-from.test.tsx | 229 ++++++++++++++++++ .../automations/AutomationWorkspaceField.tsx | 2 +- .../new-workspace/ComposerBaseRefPicker.tsx | 42 ++++ .../NewWorkspaceComposerNameSection.tsx | 25 +- .../base-ref-picker-visibility.test.ts | 44 ++++ .../base-ref-picker-visibility.ts | 25 ++ .../new-workspace-composer-card-props.ts | 3 + .../CreateFromPicker.test.tsx | 36 ++- .../CreateFromPicker.tsx | 5 +- .../branch-start-point-actions.test.ts | 95 ++++++++ .../branch-start-point-actions.ts | 24 +- .../composer-state/composer-external-sync.ts | 1 + .../composer-name-source-selection.test.ts | 193 +++++++++++++++ .../composer-state/composer-source-state.ts | 6 + .../composer-state/composer-target-state.ts | 1 + .../composer-state/draft-target-sync.test.ts | 20 ++ .../hooks/composer-state/draft-target-sync.ts | 5 +- .../github-provider-selection.ts | 7 +- .../github-submit-resolution.ts | 5 + .../hooks/composer-state/identity-model.ts | 2 + .../composer-state/issue-source-actions.ts | 18 +- .../multiple-create-reset.test.ts | 2 + .../work-item-source-actions.ts | 4 + .../workspace-identity-state.ts | 23 ++ .../store/slices/ui/ui-slice-contract-core.ts | 3 + 25 files changed, 805 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx create mode 100644 src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx create mode 100644 src/renderer/src/components/new-workspace/base-ref-picker-visibility.test.ts create mode 100644 src/renderer/src/components/new-workspace/base-ref-picker-visibility.ts rename src/renderer/src/components/{automations => repo}/CreateFromPicker.test.tsx (74%) rename src/renderer/src/components/{automations => repo}/CreateFromPicker.tsx (97%) create mode 100644 src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx new file mode 100644 index 00000000000..5b186fc9378 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx @@ -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: () => +})) + +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ + default: () => +})) + +vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ + default: () =>
+})) + +// 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 + }) => ( +
+ + +
+ ) +})) + +function renderCard( + overrides: Partial> = {} +): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + act(() => { + createRoot(container).render( + {}} + 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('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') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationWorkspaceField.tsx b/src/renderer/src/components/automations/AutomationWorkspaceField.tsx index 80e6feeda32..9bbcf37cb9a 100644 --- a/src/renderer/src/components/automations/AutomationWorkspaceField.tsx +++ b/src/renderer/src/components/automations/AutomationWorkspaceField.tsx @@ -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' diff --git a/src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx b/src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx new file mode 100644 index 00000000000..e97f980ee87 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx @@ -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 ( +
+ onBaseBranchChange(nextBaseBranch || undefined)} + /> + {resetHint ?

{resetHint}

: null} +
+ ) +} + +export default ComposerBaseRefPicker diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx index 225e0f790a5..f05ef9d06a1 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx @@ -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 (