fix: remember worktree creation agent (#1814)

This commit is contained in:
Neil 2026-05-14 00:42:07 -07:00 committed by GitHub
parent de04b7497f
commit 812831a18e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 251 additions and 17 deletions

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { mergeWorktree } from './worktree-logic'
describe('mergeWorktree creation agent metadata', () => {
it('forwards the creation agent metadata', () => {
const result = mergeWorktree(
'repo1',
{
path: '/workspaces/feature',
head: 'abc123',
branch: 'refs/heads/feature-x',
isBare: false,
isMainWorktree: false
},
{
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdWithAgent: 'codex'
}
)
expect(result.createdWithAgent).toBe('codex')
})
})

View File

@ -193,6 +193,7 @@ export function mergeWorktree(
sortOrder: meta?.sortOrder ?? 0,
lastActivityAt: meta?.lastActivityAt ?? 0,
...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(git.isSparse === true
? {
sparseDirectories: meta?.sparseDirectories,

View File

@ -31,6 +31,7 @@ import { createSetupRunnerScript, getEffectiveHooks, shouldRunSetupForCreate } f
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getActiveMultiplexer } from './ssh'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { isTuiAgent } from '../../shared/tui-agent-config'
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
@ -323,6 +324,7 @@ export async function createRemoteWorktree(
: shouldSetDisplayName(requestedName, branchName, sanitizedName)
? { displayName: requestedName }
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {})
}
@ -613,6 +615,7 @@ export async function createLocalWorktree(
sparsePresetId
}
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {})
}

View File

@ -407,6 +407,37 @@ describe('registerWorktreeHandlers', () => {
})
})
it('persists the selected creation agent during local create', async () => {
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
createdWithAgent: 'codex'
})
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/improve-dashboard',
expect.objectContaining({
createdWithAgent: 'codex'
})
)
expect(result).toEqual({
worktree: expect.objectContaining({
createdWithAgent: 'codex'
})
})
})
it('configures a PR push target during local create', async () => {
listWorktreesMock.mockResolvedValue([
{
@ -495,7 +526,7 @@ describe('registerWorktreeHandlers', () => {
})
})
it('persists linked issue and PR metadata during remote create', async () => {
it('persists linked issue, PR, and selected agent metadata during remote create', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@ -532,20 +563,23 @@ describe('registerWorktreeHandlers', () => {
repoId: 'repo-ssh',
name: 'improve-dashboard',
linkedIssue: 123,
linkedPR: 456
linkedPR: 456,
createdWithAgent: 'codex'
})
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-ssh::/remote/improve-dashboard',
expect.objectContaining({
linkedIssue: 123,
linkedPR: 456
linkedPR: 456,
createdWithAgent: 'codex'
})
)
expect(result).toEqual({
worktree: expect.objectContaining({
linkedIssue: 123,
linkedPR: 456
linkedPR: 456,
createdWithAgent: 'codex'
})
})
})

View File

@ -88,7 +88,11 @@ export function useAutomationDispatchEvents(): void {
'inherit',
undefined,
'unknown',
run.title
run.title,
undefined,
undefined,
undefined,
automation.agentId
)
).worktree
: automation.workspaceId

View File

@ -1319,7 +1319,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
linkedWorkItem?.title,
parsedLinkedIssueNumber ?? undefined,
effectiveLinkedPR ?? undefined,
pushTarget
pushTarget,
tuiAgent
)
const worktree = result.worktree
@ -1470,7 +1471,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
linkedWorkItem?.title,
parsedLinkedIssueNumber ?? undefined,
effectiveLinkedPR ?? undefined,
pushTarget
pushTarget,
agent ?? undefined
)
const worktree = result.worktree

View File

@ -245,6 +245,14 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const detectedIds = new Set(await detectedAgentsPromise)
effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
if (effectiveAgent) {
// Why: direct task launch creates and starts the workspace in separate
// steps so agent detection can overlap git worktree creation. Persist
// the chosen agent once known so empty-worktree reopen can recreate it.
void store.updateWorktreeMeta(worktreeId, { createdWithAgent: effectiveAgent }).catch(() => {
// Non-critical: activation still has the explicit startup below.
})
}
const draftContent = item.pasteContent ?? item.url
// Why: agents that gate first-launch behind a "Do you trust this folder?"

View File

@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../shared/types'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from './worktree-activation'
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
})
function makeWorktree(): Worktree {
return {
id: 'repo-1::/workspace/feature',
repoId: 'repo-1',
path: '/workspace/feature',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdWithAgent: 'codex'
}
}
describe('activateAndRevealWorktree created agent reopen', () => {
it('reopens an empty worktree with the agent selected at creation time', () => {
const worktree = makeWorktree()
useAppStore.setState({
repos: [
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
activeRepoId: 'repo-1',
activeView: 'terminal',
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
openFiles: [],
browserTabsByWorktree: {},
activeFileIdByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeTabTypeByWorktree: {},
activeTabIdByWorktree: {},
tabBarOrderByWorktree: {},
pendingStartupByTabId: {},
settings: {
agentCmdOverrides: {},
setupScriptLaunchMode: 'new-tab'
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
markWorktreeVisited: vi.fn(),
recordWorktreeVisit: vi.fn(),
refreshGitHubForWorktreeIfStale: vi.fn(),
revealWorktreeInSidebar: vi.fn()
})
const result = activateAndRevealWorktree(worktree.id)
const state = useAppStore.getState()
const reopenedTab = state.tabsByWorktree[worktree.id]?.[0]
expect(result).toEqual({ primaryTabId: reopenedTab?.id })
expect(reopenedTab).toBeDefined()
expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({
command: 'codex',
telemetry: {
agent_kind: 'codex',
launch_source: 'sidebar',
request_kind: 'resume'
}
})
})
})

View File

@ -1,13 +1,17 @@
import type { SetupSplitDirection, WorktreeSetupLaunch } from '../../../shared/types'
import type { SetupSplitDirection, Worktree, WorktreeSetupLaunch } from '../../../shared/types'
import type { EventProps } from '../../../shared/telemetry-events'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { buildSetupRunnerCommand } from './setup-runner'
import { buildAgentStartupPlan } from './tui-agent-startup'
import { CLIENT_PLATFORM } from './new-workspace'
import { tuiAgentToAgentKind } from './telemetry'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import {
setWorktreeNavActivator,
setWorktreeNavViewActivator
} from '@/store/slices/worktree-nav-history'
import { isTuiAgent } from '../../../shared/tui-agent-config'
/** Telemetry payload threaded from the launch site to `pty:spawn`. Main
* fires `agent_started` only after the spawn succeeds see
@ -72,6 +76,40 @@ export type ActivateAndRevealResult = {
primaryTabId: string | null
}
function buildCreatedAgentReopenStartup(worktree: Worktree):
| {
command: string
env?: Record<string, string>
telemetry: AgentStartedTelemetry
}
| undefined {
const agent = worktree.createdWithAgent
if (!isTuiAgent(agent)) {
return undefined
}
const startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
return undefined
}
return {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
telemetry: {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: 'sidebar',
request_kind: 'resume'
}
}
}
export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
@ -125,7 +163,7 @@ export function activateAndRevealWorktree(
const primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktreeId,
opts?.startup,
opts?.startup ?? buildCreatedAgentReopenStartup(wt),
opts?.setup,
opts?.issueCommand
)

View File

@ -3,6 +3,7 @@ import type {
CreateSparseCheckoutRequest,
GitPushTarget,
SetupDecision,
TuiAgent,
WorkspaceCreateTelemetrySource,
Worktree,
WorktreeBaseStatusEvent,
@ -74,7 +75,8 @@ export type WorktreeSlice = {
displayName?: string,
linkedIssue?: number,
linkedPR?: number,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
createdWithAgent?: TuiAgent
) => Promise<CreateWorktreeResult>
removeWorktree: (
worktreeId: string,

View File

@ -228,14 +228,15 @@ describe('createWorktree base status merge', () => {
vi.clearAllMocks()
})
it('passes linked issue and PR metadata through the create IPC payload', async () => {
it('passes linked work item and creation agent metadata through the create IPC payload', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
path: '/path/wt1',
linkedIssue: 123,
linkedPR: 456
linkedPR: 456,
createdWithAgent: 'codex'
})
mockApi.worktrees.create.mockResolvedValue({ worktree: wt })
@ -250,7 +251,9 @@ describe('createWorktree base status merge', () => {
'sidebar',
'Feature Title',
123,
456
456,
undefined,
'codex'
)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
@ -258,12 +261,14 @@ describe('createWorktree base status merge', () => {
repoId: 'repo1',
name: 'feature',
linkedIssue: 123,
linkedPR: 456
linkedPR: 456,
createdWithAgent: 'codex'
})
)
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
linkedIssue: 123,
linkedPR: 456
linkedPR: 456,
createdWithAgent: 'codex'
})
})

View File

@ -47,6 +47,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.isPinned === candidate.isPinned &&
worktree.sortOrder === candidate.sortOrder &&
worktree.lastActivityAt === candidate.lastActivityAt &&
worktree.createdWithAgent === candidate.createdWithAgent &&
worktree.baseRef === candidate.baseRef &&
worktree.pushTarget?.remoteName === candidate.pushTarget?.remoteName &&
worktree.pushTarget?.branchName === candidate.pushTarget?.branchName &&
@ -230,7 +231,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
displayName,
linkedIssue,
linkedPR,
pushTarget
pushTarget,
createdWithAgent
) => {
const retryableConflictPatterns = [
/already exists locally/i,
@ -254,7 +256,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(telemetrySource ? { telemetrySource } : {}),
...(linkedIssue !== undefined ? { linkedIssue } : {}),
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {})
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {})
})
// Why: a file watcher (worktrees.onChanged) can fire between the
// backend creating the worktree and this callback running, causing

View File

@ -228,3 +228,7 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
preflightTrust: 'copilot'
}
}
export function isTuiAgent(value: unknown): value is TuiAgent {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TUI_AGENT_CONFIG, value)
}

View File

@ -122,6 +122,10 @@ export type Worktree = {
* grant newly-created worktrees a short grace window at the top of Recent,
* immune to ambient PTY-bump reordering in other worktrees. */
createdAt?: number
/** Agent selected when Orca originally created the worktree. Used only to
* seed a replacement terminal if the user later reopens the worktree after
* closing every visible surface. */
createdWithAgent?: TuiAgent
sparseDirectories?: string[]
sparseBaseRef?: string
/** ID of the saved preset this worktree was created from, if any. Cleared
@ -154,6 +158,8 @@ export type WorktreeMeta = {
lastActivityAt: number
/** See {@link Worktree.createdAt}. Persisted to orca-data.json. */
createdAt?: number
/** See {@link Worktree.createdWithAgent}. Persisted to orca-data.json. */
createdWithAgent?: TuiAgent
sparseDirectories?: string[]
sparseBaseRef?: string
sparsePresetId?: string
@ -881,6 +887,8 @@ export type CreateWorktreeArgs = {
linkedIssue?: number
linkedPR?: number
pushTarget?: GitPushTarget
/** Agent selected in the create surface. Omitted for blank-shell creates. */
createdWithAgent?: TuiAgent
/** Telemetry-only: which UI surface initiated this create. Threaded from
* the renderer entry point so main can emit `workspace_created` with the
* correct `source`. `unknown` is a valid wire value an unrecognized