diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index e968e499afa..707b9916c5c 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -140,27 +140,14 @@ export function useComposerSubmitOrchestration( workspaceSeedName: target.derivedComposerState.workspaceSeedName }) const multipleCreateReset = useMultipleCreateReset({ + handleClearSmartNameSelection: source.issueSourceActions.handleClearSmartNameSelection, lastAutoNameRef: target.asyncComposerState.lastAutoNameRef, nameInputRef: target.asyncComposerState.nameInputRef, setAgentPrompt: target.sourceContextState.setAgentPrompt, setAttachmentPaths: target.sourceContextState.setAttachmentPaths, - setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride, - setBranchNameOverridePreservesNameEdits: - target.workspaceIdentityState.setBranchNameOverridePreservesNameEdits, - setCompareBaseRef: target.workspaceIdentityState.setCompareBaseRef, setCreateError: target.asyncComposerState.setCreateError, - setForkPushWarning: target.workspaceIdentityState.setForkPushWarning, - setLinkedGitLabIssue: target.workspaceIdentityState.setLinkedGitLabIssue, - setLinkedGitLabMR: target.workspaceIdentityState.setLinkedGitLabMR, - setLinkedIssue: target.workspaceIdentityState.setLinkedIssue, - setLinkedPR: target.workspaceIdentityState.setLinkedPR, - setLinkedTaskSourceContext: target.sourceContextState.setLinkedTaskSourceContext, - setLinkedWorkItem: target.sourceContextState.setLinkedWorkItem, setName: target.sourceContextState.setName, - setNote: target.sourceContextState.setNote, - setPushTarget: target.workspaceIdentityState.setPushTarget, - setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch, - setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint + setNote: target.sourceContextState.setNote }) const quickSubmitSourcePreparation = useQuickSubmitSourcePreparation({ baseBranch: target.workspaceIdentityState.baseBranch, diff --git a/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts b/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts new file mode 100644 index 00000000000..910143cace8 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom + +import { useRef, useState } from 'react' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import { useIssueSourceActions } from './issue-source-actions' +import { useMultipleCreateReset } from './multiple-create-reset' +import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' + +const sources: LinkedWorkItemSummary[] = [ + { + provider: 'github', + type: 'pr', + number: 42, + title: 'Fix checkout', + url: 'https://github.com/acme/app/pull/42' + }, + { + provider: 'github', + type: 'issue', + number: 43, + title: 'Fix checkout', + url: 'https://github.com/acme/app/issues/43' + }, + { + provider: 'gitlab', + type: 'mr', + number: 44, + title: 'Fix checkout', + url: 'https://gitlab.com/acme/app/-/merge_requests/44' + } +] + +function useSelectedSourceReset( + initialItem: LinkedWorkItemSummary | null, + isProjectGroupTarget = false, + initialBaseBranch: string | undefined = '1234567890abcdef1234567890abcdef12345678' +) { + const [linkedWorkItem, setLinkedWorkItem] = useState(initialItem) + const [baseBranch, setBaseBranch] = useState(initialBaseBranch) + const [name, setName] = useState('fix-checkout') + const [note, setNote] = useState('User note') + const lastAutoNameRef = useRef(name) + const branchAutoNameRef = useRef('fix-checkout') + const lastAutoNoteRef = useRef('Generated note') + const smartGitHubPrStartPointSelectionRef = useRef( + initialItem?.provider === 'github' && initialItem.type === 'pr' + ? { + repoId: 'repo-1', + item: { + ...initialItem, + type: 'pr', + id: 'pr-42', + repoId: 'repo-1', + state: 'open', + labels: [], + updatedAt: '2026-09-01T00:00:00Z', + author: null + } + } + : null + ) + const source = useIssueSourceActions({ + baseBranch, + branchAutoNameRef, + isProjectGroupTarget, + lastAutoNameRef, + lastAutoNoteRef, + linkedWorkItem, + name, + noteRef: useRef(note), + setBaseBranch, + 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, + setName, + setNote, + setPushTarget: vi.fn(), + setReuseEligibleBranch: vi.fn(), + setReuseSelectedBranch: vi.fn(), + setStartFromResetHint: vi.fn(), + smartGitHubPrStartPointSelectionRef + }) + const reset = useMultipleCreateReset({ + handleClearSmartNameSelection: source.handleClearSmartNameSelection, + lastAutoNameRef, + nameInputRef: useRef(null), + setAgentPrompt: vi.fn(), + setAttachmentPaths: vi.fn(), + setCreateError: vi.fn(), + setName, + setNote + }) + return { + ...reset, + selection: source.smartNameSelection, + linkedWorkItem, + baseBranch, + name, + note, + branchAutoNameRef, + smartGitHubPrStartPointSelectionRef + } +} + +describe('create more source reset', () => { + it.each(sources)( + 'clears $provider $type and its checkout source before the next create', + (item) => { + const { result } = renderHook(() => useSelectedSourceReset(item)) + expect(result.current.selection?.label).toContain('Fix checkout') + + if (item.provider === 'github' && item.type === 'pr') { + expect(result.current.smartGitHubPrStartPointSelectionRef.current).not.toBeNull() + } + + act(() => result.current.resetForNextCreate()) + + expect(result.current.smartGitHubPrStartPointSelectionRef.current).toBeNull() + expect(result.current.selection).toBeNull() + expect(result.current.linkedWorkItem).toBeNull() + expect(result.current.baseBranch).toBeUndefined() + expect(result.current.name).toBe('') + expect(result.current.note).toBe('') + expect(result.current.branchAutoNameRef.current).toBe('') + } + ) + + it.each(['linear', 'jira'] as const)('clears a %s task on a folder target', (provider) => { + const item: LinkedWorkItemSummary = { + provider, + type: 'issue', + number: 0, + title: 'Fix checkout', + url: + provider === 'linear' + ? 'https://linear.app/acme/issue/APP-45' + : 'https://acme.atlassian.net/browse/APP-45' + } + const { result } = renderHook(() => useSelectedSourceReset(item, true)) + expect(result.current.selection?.kind).toBe(provider) + + act(() => result.current.resetForNextCreate()) + + expect(result.current.selection).toBeNull() + expect(result.current.linkedWorkItem).toBeNull() + expect(result.current.name).toBe('') + expect(result.current.note).toBe('') + }) + + it('clears a plain branch selection before the next create', () => { + const { result } = renderHook(() => useSelectedSourceReset(null, false, 'feature/checkout')) + expect(result.current.selection).toEqual({ kind: 'branch', label: 'feature/checkout' }) + + act(() => result.current.resetForNextCreate()) + + expect(result.current.selection).toBeNull() + expect(result.current.baseBranch).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-state/multiple-create-reset.ts b/src/renderer/src/hooks/composer-state/multiple-create-reset.ts index b1b77720934..e078d692717 100644 --- a/src/renderer/src/hooks/composer-state/multiple-create-reset.ts +++ b/src/renderer/src/hooks/composer-state/multiple-create-reset.ts @@ -2,96 +2,48 @@ import type { ComposerModel } from './composer-model' type MultipleCreateResetInput = Pick< ComposerModel, + | 'handleClearSmartNameSelection' | 'lastAutoNameRef' | 'nameInputRef' | 'setAgentPrompt' | 'setAttachmentPaths' - | 'setBranchNameOverride' - | 'setBranchNameOverridePreservesNameEdits' - | 'setCompareBaseRef' | 'setCreateError' - | 'setForkPushWarning' - | 'setLinkedGitLabIssue' - | 'setLinkedGitLabMR' - | 'setLinkedIssue' - | 'setLinkedPR' - | 'setLinkedTaskSourceContext' - | 'setLinkedWorkItem' | 'setName' | 'setNote' - | 'setPushTarget' - | 'setReuseSelectedBranch' - | 'setStartFromResetHint' > import { useCallback } from 'react' export function useMultipleCreateReset(input: MultipleCreateResetInput) { const { + handleClearSmartNameSelection, lastAutoNameRef, nameInputRef, setAgentPrompt, setAttachmentPaths, - setBranchNameOverride, - setBranchNameOverridePreservesNameEdits, - setCompareBaseRef, setCreateError, - setForkPushWarning, - setLinkedGitLabIssue, - setLinkedGitLabMR, - setLinkedIssue, - setLinkedPR, - setLinkedTaskSourceContext, - setLinkedWorkItem, setName, - setNote, - setPushTarget, - setReuseSelectedBranch, - setStartFromResetHint + setNote } = input const resetForNextCreate = useCallback(() => { - // Why: clear identity fields derived from a PR pick while retaining repo, base, agent, and group context for sequential creates. + // Clear the checkout source too, so a PR's resolved SHA cannot become the next selection. + handleClearSmartNameSelection() setName('') lastAutoNameRef.current = '' setAgentPrompt('') setNote('') setAttachmentPaths([]) - setLinkedWorkItem(null) - setLinkedTaskSourceContext(null) - setLinkedIssue('') - setLinkedPR(null) - setLinkedGitLabIssue(null) - setLinkedGitLabMR(null) - setBranchNameOverride(undefined) - setBranchNameOverridePreservesNameEdits(false) - setCompareBaseRef(undefined) - setPushTarget(undefined) - setReuseSelectedBranch(false) - setStartFromResetHint(null) - setForkPushWarning(null) setCreateError(null) requestAnimationFrame(() => nameInputRef.current?.focus()) }, [ + handleClearSmartNameSelection, lastAutoNameRef, nameInputRef, setAgentPrompt, setAttachmentPaths, - setBranchNameOverride, - setBranchNameOverridePreservesNameEdits, - setCompareBaseRef, setCreateError, - setForkPushWarning, - setLinkedGitLabIssue, - setLinkedGitLabMR, - setLinkedIssue, - setLinkedPR, - setLinkedTaskSourceContext, - setLinkedWorkItem, setName, - setNote, - setPushTarget, - setReuseSelectedBranch, - setStartFromResetHint + setNote ]) return { diff --git a/tests/e2e/new-workspace-create-more.spec.ts b/tests/e2e/new-workspace-create-more.spec.ts new file mode 100644 index 00000000000..b166d61dfe5 --- /dev/null +++ b/tests/e2e/new-workspace-create-more.spec.ts @@ -0,0 +1,106 @@ +import { writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +test.use({ orcaAppExtraEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +test('Create more clears the GitHub PR source before the next worktree', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: testRepoPath, + encoding: 'utf8' + }).trim() + await electronApp.evaluate(({ ipcMain }, baseBranch) => { + ipcMain.removeHandler('worktrees:resolvePrBase') + ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch })) + }, sha) + await orcaPage.evaluate(() => { + const store = window.__store! + const state = store.getState() + store.setState({ settings: { ...state.settings!, defaultTuiAgent: 'blank' } }) + }) + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + await orcaPage.evaluate(() => { + const store = window.__store! + const repoId = store.getState().repos[0].id + const item = { + id: 'pr-4242', + provider: 'github' as const, + type: 'pr' as const, + number: 4242, + title: 'Fix workspace task reset', + state: 'open' as const, + url: 'https://github.com/acme/app/pull/4242', + labels: [], + updatedAt: '2026-09-01T00:00:00Z', + author: 'e2e', + repoId + } + store.setState({ + getCachedWorkItems: () => [item], + fetchWorkItems: async () => [item], + fetchWorkItemsAcrossRepos: async () => ({ + items: [item], + failedCount: 0, + githubUnavailable: false + }) + }) + }) + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + await input.click() + await orcaPage + .getByRole('option', { name: '#4242 Fix workspace task reset', exact: true }) + .click() + const pill = dialog.locator('[data-workspace-source-pill="true"]') + await expect(pill).toContainText('Fix workspace task reset') + await dialog.getByRole('switch', { name: 'Create more' }).click() + await dialog.getByRole('button', { name: /^Create/ }).click() + await expect(dialog).toBeVisible() + await expect(input).toHaveValue('') + await expect + .poll(() => + orcaPage.evaluate(() => + window + .__store!.getState() + .allWorktrees() + .some((worktree) => worktree.linkedPR === 4242) + ) + ) + .toBe(true) + const cdp = await orcaPage.context().newCDPSession(orcaPage) + const screenshot = await cdp.send('Page.captureScreenshot') + const proofPath = testInfo.outputPath('create-more-result.png') + writeFileSync(proofPath, Buffer.from(screenshot.data, 'base64')) + await testInfo.attach('create-more-result.png', { + path: proofPath, + contentType: 'image/png' + }) + await cdp.detach() + await expect(pill).toHaveCount(0) + await expect(dialog.getByRole('switch', { name: 'Create more' })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await input.fill('next-independent-worktree') + await dialog.getByRole('button', { name: /^Create/ }).click() + await expect + .poll(() => + orcaPage.evaluate(() => { + const worktree = window + .__store!.getState() + .allWorktrees() + .find((entry) => entry.displayName === 'next-independent-worktree') + return worktree ? { linkedPR: worktree.linkedPR, linkedIssue: worktree.linkedIssue } : null + }) + ) + .toEqual({ linkedPR: null, linkedIssue: null }) + await expect(input).toHaveValue('') + await expect(pill).toHaveCount(0) +})