mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix Smart create retaining a task checkout hash with Create more (#18727)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<LinkedWorkItemSummary | null>(initialItem)
|
||||
const [baseBranch, setBaseBranch] = useState<string | undefined>(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<SmartGitHubPrStartPointSelection | null>(
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user