diff --git a/src/renderer/src/lib/director-worktree-shell.test.ts b/src/renderer/src/lib/director-worktree-shell.test.ts index 0347f8ac0d2..4b34154e63c 100644 --- a/src/renderer/src/lib/director-worktree-shell.test.ts +++ b/src/renderer/src/lib/director-worktree-shell.test.ts @@ -49,6 +49,9 @@ describe('createDirectorWorktreeShell', () => { // ...and CRUCIALLY no createdWithAgent — otherwise activation would relaunch an // LLM in the director pane, breaking the token-free invariant. expect(args[CREATED_WITH_AGENT_ARG_INDEX]).toBeUndefined() + // ...and the trailing options suppress host activation so an SSH create + // reveals the director without switching the user's active tab to it. + expect(args.at(-1)).toEqual({ suppressActivation: true }) }) it('returns null and toasts when the project has no repo', async () => { diff --git a/src/renderer/src/lib/director-worktree-shell.ts b/src/renderer/src/lib/director-worktree-shell.ts index b0bf43f3282..6016a01bf3f 100644 --- a/src/renderer/src/lib/director-worktree-shell.ts +++ b/src/renderer/src/lib/director-worktree-shell.ts @@ -54,7 +54,30 @@ export async function createDirectorWorktreeShell( 'skip', undefined, undefined, - `${ORCASTRATOR_DISPLAY_PREFIX}${options.label}` + `${ORCASTRATOR_DISPLAY_PREFIX}${options.label}`, + // Why: positional args up to compareBaseRef are unused for a director + // shell; the trailing options carries suppressActivation so a remote + // (SSH) create reveals the worktree without switching the user's active + // tab — matching the renderer-side activation suppression at the call site. + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { suppressActivation: true } ) return { worktreeId: result.worktree.id, setup: result.setup } } catch (error) { diff --git a/src/renderer/src/lib/orchestrator-launch.test.ts b/src/renderer/src/lib/orchestrator-launch.test.ts new file mode 100644 index 00000000000..f2b55b66481 --- /dev/null +++ b/src/renderer/src/lib/orchestrator-launch.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project } from '../../../shared/types' + +// A mutable harness the mocked modules read, reset per test. +const harness = vi.hoisted(() => ({ + registerOrchestrator: vi.fn(), + settings: { defaultTuiAgent: 'claude' } as { defaultTuiAgent?: string }, + activate: vi.fn(), + pasteDraft: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(harness), { + getState: () => ({ + registerOrchestrator: harness.registerOrchestrator, + settings: harness.settings + }) + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: (...args: unknown[]) => harness.activate(...args) +})) + +vi.mock('@/lib/director-worktree-shell', () => ({ + createDirectorWorktreeShell: vi.fn(async () => ({ worktreeId: 'wt_director', setup: undefined })) +})) + +vi.mock('@/lib/tui-agent-startup', () => ({ + buildAgentStartupPlan: () => ({ + launchCommand: 'claude', + env: undefined, + launchConfig: undefined + }) +})) + +vi.mock('../../../shared/tui-agent-launch-defaults', () => ({ + resolveTuiAgentLaunchArgs: () => undefined, + resolveTuiAgentLaunchEnv: () => undefined +})) + +vi.mock('@/lib/launch-work-item-direct-agent', () => ({ + buildDirectWorkItemStartupOpts: () => ({ startup: { command: 'claude' } }) +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: (...args: unknown[]) => harness.pasteDraft(...args) +})) + +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' })) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +vi.mock('sonner', () => ({ + toast: { error: (...args: unknown[]) => harness.toastError(...args) } +})) + +import { launchOrchestratorForProject } from './orchestrator-launch' + +const PROJECT: Project = { + id: 'proj_1', + displayName: 'Demo', + sourceRepoIds: ['repo_1'] +} as unknown as Project + +beforeEach(() => { + vi.clearAllMocks() + harness.settings = { defaultTuiAgent: 'claude' } + harness.activate.mockReturnValue({ primaryTabId: 'tab_1' }) +}) + +describe('launchOrchestratorForProject', () => { + it('reveals the director worktree but suppresses the active-tab switch', async () => { + const ok = await launchOrchestratorForProject(PROJECT) + expect(ok).toBe(true) + + expect(harness.activate).toHaveBeenCalledTimes(1) + const [worktreeId, opts] = harness.activate.mock.calls[0] + expect(worktreeId).toBe('wt_director') + // The programmatic launch must NOT yank the user's active tab. + expect(opts.suppressActivation).toBe(true) + // ...while still revealing it in the sidebar tree + Mission Control DAG. + expect(opts.sidebarRevealBehavior).toBe('auto') + }) +}) diff --git a/src/renderer/src/lib/orchestrator-launch.ts b/src/renderer/src/lib/orchestrator-launch.ts index a92cb213648..0c501658569 100644 --- a/src/renderer/src/lib/orchestrator-launch.ts +++ b/src/renderer/src/lib/orchestrator-launch.ts @@ -74,9 +74,13 @@ export async function launchOrchestratorForProject( allowEmptyPromptLaunch: true }) + // Why: a director is launched programmatically — reveal it in the sidebar + + // Mission Control DAG, but suppress the active-tab switch so the user is not + // yanked off their current worktree when the Orcastrator boots. const activation = activateAndRevealWorktree(worktreeId, { sidebarRevealBehavior: 'auto', setup, + suppressActivation: true, ...buildDirectWorkItemStartupOpts(agent, startupPlan, 'sidebar') }) if (!activation) { diff --git a/src/renderer/src/lib/recipe-director-launch.test.ts b/src/renderer/src/lib/recipe-director-launch.test.ts index 5c807a18eee..d1612a9ae0a 100644 --- a/src/renderer/src/lib/recipe-director-launch.test.ts +++ b/src/renderer/src/lib/recipe-director-launch.test.ts @@ -118,6 +118,8 @@ describe('launchRecipeDirector', () => { expect(worktreeId).toBe('wt_director') expect(opts.startup).toBeUndefined() expect(opts.issueCommand).toBeUndefined() + // Programmatic launch reveals the shell without yanking the user's active tab. + expect(opts.suppressActivation).toBe(true) }) it('falls back to claude when the default agent is a blank shell', async () => { diff --git a/src/renderer/src/lib/recipe-director-launch.ts b/src/renderer/src/lib/recipe-director-launch.ts index 1ea100fb542..2893d243e35 100644 --- a/src/renderer/src/lib/recipe-director-launch.ts +++ b/src/renderer/src/lib/recipe-director-launch.ts @@ -90,9 +90,13 @@ export async function launchRecipeDirector( // no /orcastrate. This is the token-free invariant: nothing here seeds an LLM // into the director shell. It still gives the shell a focusable surface (and a // terminal handle for pane-anchoring the run). + // Why: a recipe director is launched programmatically — reveal it (sidebar + + // Mission Control DAG) but suppress the active-tab switch so spawning the + // director does not yank the user off their current worktree. const activation = activateAndRevealWorktree(shell.worktreeId, { sidebarRevealBehavior: 'auto', - setup: shell.setup + setup: shell.setup, + suppressActivation: true }) if (!activation) { toast.error( diff --git a/src/renderer/src/lib/worktree-activation-suppress.test.ts b/src/renderer/src/lib/worktree-activation-suppress.test.ts new file mode 100644 index 00000000000..0f00970cbc6 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-suppress.test.ts @@ -0,0 +1,99 @@ +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/orcastrator', + repoId: 'repo-1', + path: '/workspace/orcastrator', + head: 'abc123', + branch: 'refs/heads/orcastrator', + isBare: false, + isMainWorktree: false, + displayName: 'orcastrator', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +function seedStore(worktree: Worktree, overrides: Record): void { + useAppStore.setState({ + repos: [ + { + id: worktree.repoId, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + // Why: a DIFFERENT worktree is active so a real activation would visibly + // switch the user's tab — that switch is what suppression must prevent. + activeRepoId: worktree.repoId, + activeView: 'terminal', + activeWorktreeId: 'repo-1::/workspace/other', + activeTabId: 'tab-other', + activeTabType: 'terminal', + // Why: a non-empty renderable model so activation skips creating an initial + // terminal — the test asserts on activation/reveal, not terminal seeding. + tabsByWorktree: { [worktree.id]: [] }, + ptyIdsByTabId: {}, + everActivatedWorktreeIds: new Set([worktree.id]), + setActiveWorktree: vi.fn(), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 1, + activeRenderableTabId: null + })), + ...overrides + }) +} + +describe('activateAndRevealWorktree suppressActivation', () => { + it('reveals in the sidebar but does NOT switch the active worktree when suppressed', () => { + const worktree = makeWorktree() + const setActiveWorktree = vi.fn() + const revealWorktreeInSidebar = vi.fn() + seedStore(worktree, { setActiveWorktree, revealWorktreeInSidebar }) + + const result = activateAndRevealWorktree(worktree.id, { suppressActivation: true }) + + expect(result).not.toBe(false) + // The reveal (sidebar tree + Mission Control DAG) still happens... + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) + // ...but the user's active tab is NOT yanked to the new worktree. + expect(setActiveWorktree).not.toHaveBeenCalled() + expect(useAppStore.getState().activeWorktreeId).toBe('repo-1::/workspace/other') + }) + + it('switches the active worktree (default behavior) when not suppressed', () => { + const worktree = makeWorktree() + const setActiveWorktree = vi.fn() + const revealWorktreeInSidebar = vi.fn() + seedStore(worktree, { setActiveWorktree, revealWorktreeInSidebar }) + + const result = activateAndRevealWorktree(worktree.id) + + expect(result).not.toBe(false) + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) + expect(setActiveWorktree).toHaveBeenCalledWith(worktree.id) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 492c92913f0..73541ccf1d3 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -271,6 +271,12 @@ export function activateAndRevealWorktree( sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] notifyHostRuntime?: boolean revealInSidebar?: boolean + /** When true, reveal the worktree (sidebar tree + Mission Control DAG) and + * seed its terminal surface WITHOUT switching the user's active tab/view to + * it. Programmatic Orcastrator/coordinator creates set this so an + * automated worktree never yanks the user away from their current work; + * manual (+-button) creates leave it unset so they still jump to it. */ + suppressActivation?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -278,6 +284,7 @@ export function activateAndRevealWorktree( if (!wt) { return false } + const suppressActivation = opts?.suppressActivation === true const hasActivationWork = Boolean( opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand ) @@ -289,48 +296,54 @@ export function activateAndRevealWorktree( state.activeWorktreeId === worktreeId && state.activeView === 'terminal' - // 1. Set activeRepoId if crossing repos - if (wt.repoId !== state.activeRepoId) { - state.setActiveRepo(wt.repoId) - } + // Why: `suppressActivation` keeps the user where they are — a programmatic + // (director/coordinator) create reveals the new worktree but must NOT switch + // the active repo/view/worktree, restamp focus recency, or pull the paired + // host onto it. The reveal + terminal-surface seeding below still run. + if (!suppressActivation) { + // 1. Set activeRepoId if crossing repos + if (wt.repoId !== state.activeRepoId) { + state.setActiveRepo(wt.repoId) + } - // 2. Switch any non-terminal view back to terminal - if (state.activeView !== 'terminal') { - state.setActiveView('terminal') - } + // 2. Switch any non-terminal view back to terminal + if (state.activeView !== 'terminal') { + state.setActiveView('terminal') + } - // 3. Core activation: sets activeWorktreeId, restores per-worktree state, - // clears unread, bumps dead PTY generations, triggers GitHub refresh - state.setActiveWorktree(worktreeId) - const postActivationState = useAppStore.getState() - const ownerRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(postActivationState, wt.id) - if (opts?.notifyHostRuntime !== false && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId)) { - // Why: paired web clients own only local selection state. The desktop host - // must also activate the worktree so hidden renderer-owned terminal panes - // mount and publish session surfaces back to the web client. - void activateWebRuntimeSessionWorktree({ - worktreeId, - environmentId: ownerRuntimeEnvironmentId - }) - } + // 3. Core activation: sets activeWorktreeId, restores per-worktree state, + // clears unread, bumps dead PTY generations, triggers GitHub refresh + state.setActiveWorktree(worktreeId) + const postActivationState = useAppStore.getState() + const ownerRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(postActivationState, wt.id) + if (opts?.notifyHostRuntime !== false && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId)) { + // Why: paired web clients own only local selection state. The desktop host + // must also activate the worktree so hidden renderer-owned terminal panes + // mount and publish session surfaces back to the web client. + void activateWebRuntimeSessionWorktree({ + worktreeId, + environmentId: ownerRuntimeEnvironmentId + }) + } - // Why: record focus recency for Cmd+J's empty-query ordering BEFORE any - // later async step (initial terminal / reveal) could throw — the user - // already perceives the switch as successful the instant activeWorktreeId - // flips, so the recency stamp must land with the same guarantee. Separate - // from recordWorktreeVisit (nav-history) and from worktree.lastActivityAt - // (background signal) on purpose — see docs/cmd-j-empty-query-ordering.md. - if (!isPlainAlreadyActiveTerminal) { - state.markWorktreeVisited(worktreeId) - } + // Why: record focus recency for Cmd+J's empty-query ordering BEFORE any + // later async step (initial terminal / reveal) could throw — the user + // already perceives the switch as successful the instant activeWorktreeId + // flips, so the recency stamp must land with the same guarantee. Separate + // from recordWorktreeVisit (nav-history) and from worktree.lastActivityAt + // (background signal) on purpose — see docs/cmd-j-empty-query-ordering.md. + if (!isPlainAlreadyActiveTerminal) { + state.markWorktreeVisited(worktreeId) + } - // Why: activateAndRevealWorktree always ends in 'terminal' view (step 2), - // and Settings/Tasks transitions do not pass through this function, so no - // view-guard is needed here. The guard skips re-recording when the caller - // is goBackWorktree/goForwardWorktree, which mutate the history index - // directly instead of treating the target as a new visit. - if (!isPlainAlreadyActiveTerminal && !state.isNavigatingHistory) { - state.recordWorktreeVisit(worktreeId) + // Why: activateAndRevealWorktree always ends in 'terminal' view (step 2), + // and Settings/Tasks transitions do not pass through this function, so no + // view-guard is needed here. The guard skips re-recording when the caller + // is goBackWorktree/goForwardWorktree, which mutate the history index + // directly instead of treating the target as a new visit. + if (!isPlainAlreadyActiveTerminal && !state.isNavigatingHistory) { + state.recordWorktreeVisit(worktreeId) + } } // Why: sleeping an agent destroys the local PTY but preserves the provider @@ -371,7 +384,9 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false) { + // Why: gated on activation too — waking the paired host's terminal pulls the + // host onto this worktree, which a suppressed (background) reveal must avoid. + if (!suppressActivation && opts?.notifyHostRuntime !== false) { ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index f46eebbe5dc..b7ae0c1805f 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -152,7 +152,13 @@ export type WorktreeSlice = { compareBaseRef?: string, // Why: reserved for automation-dispatch flows so host-side provenance can // be minted securely; regular create callers should omit this. - options?: { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] } + options?: { + automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] + /** When true, a startup-bearing remote create does NOT ask the host to + * activate the new worktree — programmatic (director/coordinator) creates + * reveal it without yanking the user's active tab. Manual creates omit it. */ + suppressActivation?: boolean + } ) => Promise /** Register an in-flight background creation and make it the active surface. */ beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index ce36f4b4c9c..9ae6ff1a725 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1977,7 +1977,11 @@ export const createWorktreeSlice: StateCreator ...(startup.startupCommandDelivery ? { startupCommandDelivery: startup.startupCommandDelivery } : {}), - activate: true + // Why: programmatic (director/coordinator) creates pass + // suppressActivation so the host reveals the worktree + // without switching the user's active tab to it; manual + // creates leave it unset and still activate. + activate: options?.suppressActivation !== true } : {}) },