fix: remember worktree creation agent (#1814)
This commit is contained in:
parent
de04b7497f
commit
812831a18e
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ export function useAutomationDispatchEvents(): void {
|
|||
'inherit',
|
||||
undefined,
|
||||
'unknown',
|
||||
run.title
|
||||
run.title,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
automation.agentId
|
||||
)
|
||||
).worktree
|
||||
: automation.workspaceId
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue