Start plain new-tab agent launches through the host's agent.launch (#26489)

* Start plain new-tab agent launches through the host's agent.launch

The tab bar's + menu, Quick Launch, the floating panel and its shortcut, the
new-agent shortcut and dashboard spawn now start a no-prompt agent through
the host, the way main's AI-button launches already do, wherever that is
main's own launch: chat default off, enabled agent, not an SSH workspace.
Everything else keeps the window's startup queue.

* Type the new-tab host route tests without assertions

* Keep a new tab the window launches on another platform on main's path

On a Windows client a WSL-path workspace launches as Linux from the window,
which the host does not infer, so it keeps the window's own launch.
This commit is contained in:
Brennan Benson
2026-10-09 16:55:28 -07:00
committed by GitHub
parent 7450f40662
commit 7634bbfffe
13 changed files with 305 additions and 18 deletions
@@ -43,7 +43,8 @@ describe('launchDashboardAgent', () => {
requestId: expect.any(String),
agent: 'codex',
worktreeId: 'folder:docs',
launchSource: 'unknown'
launchSource: 'unknown',
freshNewTab: true
})
})
})
@@ -19,7 +19,8 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA
requestId: newAgentLaunchRequestId(),
agent,
worktreeId,
launchSource: 'unknown'
launchSource: 'unknown',
freshNewTab: true
}) !== null
)
}
@@ -182,7 +182,8 @@ describe('FloatingTerminalWindowControls default-agent launch', () => {
agent: 'claude',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
launchSource: 'shortcut',
activate: false
activate: false,
freshNewTab: true
})
// Why: the whole point of the migration. The shared launcher owns the startup plan and the
// tab it lands in, so this button must not reach past it into the tab store.
@@ -77,7 +77,8 @@ export function FloatingTerminalWindowControls({
// Why: `agent-auto-ack-targets` relies on the floating panel's active tab never becoming the
// global `activeTabId`; activating here would also flip the main view off an open editor.
// This selects within the floating group below instead.
activate: false
activate: false,
freshNewTab: true
})
if (!result) {
toast.error(
@@ -148,7 +148,8 @@ function QuickLaunchAgentMenuItemsInner({
...(launchSource !== undefined ? { launchSource } : {}),
...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}),
// Notes keep their text until it goes out, so the new chat's composer never gets a copy.
...(onPromptHandedOff ? { promptKeptByCaller: true as const } : {})
...(onPromptHandedOff ? { promptKeptByCaller: true as const } : {}),
freshNewTab: true
})
if (!result) {
toast.error(
@@ -228,7 +228,8 @@ export function useTabBarCreateMenuController({
agent,
worktreeId,
groupId: resolvedGroupId,
launchSource: 'tab_bar_quick_launch'
launchSource: 'tab_bar_quick_launch',
freshNewTab: true
})
if (!result) {
toast.error(
@@ -101,7 +101,8 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr
agent,
worktreeId: activeWorktreeId,
groupId: targetGroupId,
launchSource: 'shortcut'
launchSource: 'shortcut',
freshNewTab: true
})
if (!result) {
toast.error(
@@ -132,6 +132,23 @@ describe('a desktop launch through the host', () => {
})
})
// Why: a floating launch must leave the main window's selection alone, as main's own tab does.
it('keeps a launch that must not take the selection in the background', () => {
callRuntimeRpc.mockReturnValue(new Promise(() => {}))
const before = store.getState().activeTabId
const { tabId } = launchAgentThroughHost({
agent: 'claude',
worktreeId: WT,
prompt: '',
activate: false
})
expect(launchTab(tabId)).toBeDefined()
expect(store.getState().activeTabId).toBe(before)
expect(lastParams().presentation).toBe('background')
})
it('names every click as its own operation', () => {
callRuntimeRpc.mockReturnValue(new Promise(() => {}))
launch()
@@ -46,6 +46,8 @@ export type HostAgentLaunchArgs = {
pendingActivationSpawn?: boolean
/** The view the tab opens in, decided as for any new agent tab. */
viewMode?: Tab['viewMode']
/** False keeps a floating launch from taking the main window's selection, as main's tab does. */
activate?: boolean
}
/** What became of the launch, as this window must tell it. */
@@ -102,7 +104,7 @@ function launchParams(args: HostAgentLaunchArgs) {
...stringSessionOptions(args.sessionOptions),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.groupId ? { placement: { groupId: args.groupId } } : {}),
presentation: 'focused'
presentation: args.activate === false ? 'background' : 'focused'
}
}
@@ -164,6 +166,7 @@ export function launchAgentThroughHost(args: HostAgentLaunchArgs): {
launchAgent: args.agent,
quickCommandLabel: args.quickCommandLabel,
...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}),
...(args.activate === false ? { activate: false } : {}),
...(args.viewMode ? { viewMode: args.viewMode } : {})
})
rememberAgentLaunchPanePrompt(tabId, args.prompt)
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostAgentLaunchOutcome } from './agent-launch-through-host'
const host = vi.hoisted(() => ({
launchAgentThroughHost: vi.fn(),
windowMakesHostLaunchTab: vi.fn(() => true)
}))
vi.mock('@/lib/agent-launch-through-host', () => host)
vi.mock('@/lib/launch-agent-tab-prompt-paste', () => ({
pasteAgentLaunchPromptOnceReady: vi.fn()
}))
const state = vi.hoisted(() => {
const initial: {
settings: { disabledTuiAgents: string[] }
connectionId: string | null | undefined
} = { settings: { disabledTuiAgents: [] }, connectionId: null }
return initial
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ settings: state.settings }) } }))
vi.mock('@/lib/connection-context', () => ({
getConnectionIdFromState: () => state.connectionId
}))
vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' }))
const toast = vi.hoisted(() => ({ error: vi.fn() }))
vi.mock('sonner', () => ({ toast }))
const { freshNewTabLaunchesThroughHost, launchFreshNewTabThroughHost } =
await import('./launch-agent-new-tab-host-route')
beforeEach(() => {
vi.clearAllMocks()
host.windowMakesHostLaunchTab.mockReturnValue(true)
state.settings = { disabledTuiAgents: [] }
state.connectionId = null
})
describe('which plain new-tab launches start through the host', () => {
function startsThroughHost(
overrides: { prompt?: string; freshNewTab?: true } = {},
launchPlatform: NodeJS.Platform = 'win32'
): boolean {
return freshNewTabLaunchesThroughHost(
{ freshNewTab: true, worktreeId: 'wt-1', agent: 'claude', ...overrides },
launchPlatform
)
}
it('a local workspace with an enabled agent, no prompt and the chat default off', () => {
expect(startsThroughHost()).toBe(true)
})
it('only for the plain new-tab callers, and only with no prompt', () => {
expect(startsThroughHost({ freshNewTab: undefined })).toBe(false)
expect(startsThroughHost({ prompt: 'fix it' })).toBe(false)
})
it('keeps main launch where the host could open a chat', () => {
host.windowMakesHostLaunchTab.mockReturnValue(false)
expect(startsThroughHost()).toBe(false)
})
// Why: the host refuses a disabled agent; main's window never read the disabled list here.
it('keeps main launch for a disabled agent', () => {
state.settings = { disabledTuiAgents: ['claude'] }
expect(startsThroughHost()).toBe(false)
})
// Why: main's pane connects an SSH target first and waits for the remote shell before typing.
it.each([['ssh-1'], [undefined]])('keeps main launch on SSH or an unknown host (%s)', (id) => {
state.connectionId = id
expect(startsThroughHost()).toBe(false)
})
// Why: the window launches a WSL path as Linux; the host would quote it for Windows.
it('keeps main launch where the window launches on another platform', () => {
expect(startsThroughHost({}, 'linux')).toBe(false)
})
})
describe('a plain new tab started through the host', () => {
function launch(outcome: HostAgentLaunchOutcome): string {
host.launchAgentThroughHost.mockReturnValue({
tabId: 'tab-1',
outcome: Promise.resolve(outcome)
})
return launchFreshNewTabThroughHost({ agent: 'claude', worktreeId: 'wt-1', prompt: '' })
}
it('says nothing once the agent started', async () => {
expect(launch({ kind: 'started' })).toBe('tab-1')
await Promise.resolve()
await Promise.resolve()
expect(toast.error).not.toHaveBeenCalled()
})
it('says a launch that never started, with no copy action for a prompt it never had', async () => {
launch({ kind: 'not-started', unconfirmed: false, code: 'worktree_not_found' })
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledOnce())
expect(toast.error.mock.calls[0]?.[1]).toBeUndefined()
})
})
@@ -0,0 +1,102 @@
// Real-store coverage: which plain new-tab launches start through the host's agent.launch.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers'
import { createStoreCascadesMockApi } from '../store/slices/store-cascades-test-harness'
const storeBox = vi.hoisted(() => {
const box: { store: unknown } = { store: null }
return box
})
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), message: vi.fn() }
}))
vi.mock('@/store', () => ({
get useAppStore() {
return storeBox.store
}
}))
const callRuntimeRpc = vi.hoisted(() =>
vi.fn<(target: unknown, method: string, params: Record<string, unknown>) => Promise<unknown>>()
)
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
...(await importOriginal<object>()),
callRuntimeRpc
}))
createStoreCascadesMockApi()
const WT = 'repo1::/path/wt1'
type Settings = Partial<ReturnType<typeof getDefaultSettings>>
function seed(settings: Settings = {}) {
const store = createTestStore()
storeBox.store = store
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), ...settings },
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
activeWorktreeId: WT
})
return store
}
function launchCalls() {
return callRuntimeRpc.mock.calls.filter(([, method]) => method === 'agent.launchReplay')
}
beforeEach(() => {
vi.clearAllMocks()
callRuntimeRpc.mockReturnValue(new Promise(() => {}))
})
describe('a plain new-tab launch', () => {
it('starts through the host with no prompt and queues no startup command of its own', async () => {
const store = seed()
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
requestId: 'request-1',
agent: 'claude',
worktreeId: WT,
freshNewTab: true
})
const tabId = result?.surface.kind === 'local-terminal' ? result.surface.tabId : ''
expect(launchCalls()).toHaveLength(1)
expect(launchCalls()[0]?.[2]).toMatchObject({
agent: 'claude',
target: { kind: 'existing', worktree: `id:${WT}` },
launchSource: 'tab_bar_quick_launch',
presentation: 'focused'
})
expect(launchCalls()[0]?.[2]).not.toHaveProperty('prompt')
expect(store.getState().pendingStartupByTabId[tabId]).toBeUndefined()
expect(store.getState().activeTabId).toBe(tabId)
})
const kept: [string, { prompt?: string; freshNewTab?: true }, Settings][] = [
['a prompt', { prompt: 'fix it' }, {}],
['no opt-in', { freshNewTab: undefined }, {}],
['a disabled agent', {}, { disabledTuiAgents: ['claude'] }],
['the chat default on', {}, { experimentalNativeChat: true }]
]
it.each(kept)('keeps main launch with %s', async (_label, extra, settings) => {
const store = seed(settings)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
requestId: 'request-2',
agent: 'claude',
worktreeId: WT,
freshNewTab: true,
...extra
})
expect(launchCalls()).toHaveLength(0)
if (!('experimentalNativeChat' in settings)) {
expect(Object.keys(store.getState().pendingStartupByTabId)).toHaveLength(1)
}
})
})
@@ -6,6 +6,8 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery'
import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste'
import {
freshNewTabLaunchesThroughHost,
launchFreshNewTabThroughHost,
launchNewTabPromptThroughHost,
newTabPromptLaunchesThroughHost
} from '@/lib/launch-agent-new-tab-host-route'
@@ -71,6 +73,9 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & {
onPromptDeliveryUnconfirmed?: () => void
/** Keep terminal launches in a floating workspace from taking global selection. */
activate?: boolean
/** A plain new-tab pick ("+", Quick Launch, floating panel, dashboard): with no prompt it starts
* through the host where that matches main's own launch. */
freshNewTab?: true
/** The launch seeds a workspace being opened, so its PTY spawn must not reshuffle Recent. */
pendingActivationSpawn?: boolean
/** Lets a workspace reveal itself before the selected surface opens. */
@@ -267,6 +272,21 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
promptDeliveryResult: launched.promptDeliveryResult
}
}
if (args.freshNewTab && freshNewTabLaunchesThroughHost(args, resolvedLaunchPlatform)) {
const tabId = launchFreshNewTabThroughHost({
agent,
worktreeId,
...(groupId ? { groupId } : {}),
prompt: '',
...(agentArgs !== undefined ? { agentArgs } : {}),
...(initialCwd?.trim() ? { cwd: initialCwd } : {}),
launchSource: launchSource ?? 'tab_bar_quick_launch',
quickCommandLabel,
...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}),
...(activate === false ? { activate: false } : {})
})
return { surface: { kind: 'local-terminal', tabId }, startupPlan, pasteDraftAfterLaunch: false }
}
// Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render.
const tab = store.createTab(worktreeId, groupId, undefined, {
launchAgent: agent,
@@ -8,6 +8,11 @@ import {
type HostAgentLaunchOutcome
} from '@/lib/agent-launch-through-host'
import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste'
import { useAppStore } from '@/store'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab'
/**
* Whether a new agent tab starts through the host's `agent.launch`: an AI button's launch, whose
@@ -24,7 +29,37 @@ export function newTabPromptLaunchesThroughHost(args: {
)
}
/** The tab is gone, so the pane's own words go in a notice, with its prompt to copy. */
/**
* Whether a plain new-tab launch with no prompt ("+", Quick Launch, the floating panel, the
* dashboard) starts through the host. Only where the host's launch is main's own: the window
* decides the terminal, the agent is enabled (main's window never read the disabled list), the
* workspace is not on SSH (main's pane connects first and waits for the remote shell), and the
* window launches it on this machine's own platform (a WSL path launches as Linux here, which the
* host does not infer). Temporary: the rest keeps main's launch until the host matches it.
*/
export function freshNewTabLaunchesThroughHost(
args: Pick<LaunchAgentInNewTabArgs, 'freshNewTab' | 'prompt' | 'worktreeId' | 'agent'>,
launchPlatform: NodeJS.Platform
): boolean {
const state = useAppStore.getState()
return (
args.freshNewTab === true &&
!args.prompt?.trim() &&
launchPlatform === CLIENT_PLATFORM &&
windowMakesHostLaunchTab() &&
isTuiAgentEnabled(args.agent, state.settings?.disabledTuiAgents) &&
getConnectionIdFromState(state, args.worktreeId) === null
)
}
/** Starts a no-prompt new tab through the host; a launch that never started says so in a notice. */
export function launchFreshNewTabThroughHost(args: HostAgentLaunchArgs): string {
const { tabId, outcome } = launchAgentThroughHost(args)
void outcome.then((launched) => showLaunchNotStartedNotice(launched, args.prompt))
return tabId
}
/** The tab is gone, so the pane's own words go in a notice, with its prompt to copy if it had one. */
function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: string): void {
if (outcome.kind !== 'not-started') {
return
@@ -35,15 +70,17 @@ function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: str
? { kind: 'unconfirmed' }
: { kind: 'not-started', code: outcome.code ?? '' }
),
{
action: {
label: translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt',
'Copy prompt'
),
onClick: () => void window.api.ui.writeClipboardText(prompt)
}
}
prompt
? {
action: {
label: translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt',
'Copy prompt'
),
onClick: () => void window.api.ui.writeClipboardText(prompt)
}
}
: undefined
)
}