diff --git a/src/main/ipc/worktree-logic-created-agent.test.ts b/src/main/ipc/worktree-logic-created-agent.test.ts new file mode 100644 index 000000000..0854006d3 --- /dev/null +++ b/src/main/ipc/worktree-logic-created-agent.test.ts @@ -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') + }) +}) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 382ba20f6..f446b2d5e 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -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, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 91e5f2338..948297d00 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 } : {}) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 68a9793e5..d7c81e657 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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' }) }) }) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 70c1d6269..e833e8536 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -88,7 +88,11 @@ export function useAutomationDispatchEvents(): void { 'inherit', undefined, 'unknown', - run.title + run.title, + undefined, + undefined, + undefined, + automation.agentId ) ).worktree : automation.workspaceId diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3907dfdc7..05ab1c6fc 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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 diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 7177d432e..6a0ec8b3a 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -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?" diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts new file mode 100644 index 000000000..5647864a8 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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['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' + } + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index f78de0d2a..159390107 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 + 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 ) diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 66324ff6c..1c76c8a38 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -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 removeWorktree: ( worktreeId: string, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 999d81bb8..6e1242242 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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' }) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 5d5f98b75..2304f93ff 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -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 displayName, linkedIssue, linkedPR, - pushTarget + pushTarget, + createdWithAgent ) => { const retryableConflictPatterns = [ /already exists locally/i, @@ -254,7 +256,8 @@ export const createWorktreeSlice: StateCreator ...(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 diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 174fe7150..dd41fc17e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -228,3 +228,7 @@ export const TUI_AGENT_CONFIG: Record = { preflightTrust: 'copilot' } } + +export function isTuiAgent(value: unknown): value is TuiAgent { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TUI_AGENT_CONFIG, value) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 29bd2de65..46527010e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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