Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/renderer/src/lib/director-worktree-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
25 changes: 24 additions & 1 deletion src/renderer/src/lib/director-worktree-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
86 changes: 86 additions & 0 deletions src/renderer/src/lib/orchestrator-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
4 changes: 4 additions & 0 deletions src/renderer/src/lib/orchestrator-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/lib/recipe-director-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/lib/recipe-director-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
99 changes: 99 additions & 0 deletions src/renderer/src/lib/worktree-activation-suppress.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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)
})
})
93 changes: 54 additions & 39 deletions src/renderer/src/lib/worktree-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,20 @@ 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()
const wt = state.getKnownWorktreeById(worktreeId)
if (!wt) {
return false
}
const suppressActivation = opts?.suppressActivation === true
const hasActivationWork = Boolean(
opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand
)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
Loading