Revert "+" new-tab agent launches to the window route (reverts #26489) (#27173)

#26489 started no-prompt "+" launches through the host lane that is built for
terminals no window shows, into a pane the window already owns. That lane
differs from the window lane in start size, env, telemetry, failure handling
and Windows runtime resolution. Restore main's window route until the two
terminal lanes are converged, then move "+" onto the single lane.

This reverts commit 7634bbfffe.
This commit is contained in:
Brennan Benson
2026-10-11 14:15:02 -07:00
committed by GitHub
parent c07b045934
commit 05458416af
13 changed files with 18 additions and 305 deletions
@@ -43,8 +43,7 @@ describe('launchDashboardAgent', () => {
requestId: expect.any(String),
agent: 'codex',
worktreeId: 'folder:docs',
launchSource: 'unknown',
freshNewTab: true
launchSource: 'unknown'
})
})
})
@@ -19,8 +19,7 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA
requestId: newAgentLaunchRequestId(),
agent,
worktreeId,
launchSource: 'unknown',
freshNewTab: true
launchSource: 'unknown'
}) !== null
)
}
@@ -182,8 +182,7 @@ describe('FloatingTerminalWindowControls default-agent launch', () => {
agent: 'claude',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
launchSource: 'shortcut',
activate: false,
freshNewTab: true
activate: false
})
// 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,8 +77,7 @@ 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,
freshNewTab: true
activate: false
})
if (!result) {
toast.error(
@@ -149,8 +149,7 @@ 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 } : {}),
freshNewTab: true
...(onPromptHandedOff ? { promptKeptByCaller: true as const } : {})
})
if (!result) {
toast.error(
@@ -228,8 +228,7 @@ export function useTabBarCreateMenuController({
agent,
worktreeId,
groupId: resolvedGroupId,
launchSource: 'tab_bar_quick_launch',
freshNewTab: true
launchSource: 'tab_bar_quick_launch'
})
if (!result) {
toast.error(
@@ -76,8 +76,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr
agent,
worktreeId: activeWorktreeId,
groupId: targetGroupId,
launchSource: 'shortcut',
freshNewTab: true
launchSource: 'shortcut'
})
if (!result) {
toast.error(
@@ -132,23 +132,6 @@ 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,8 +46,6 @@ 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. */
@@ -104,7 +102,7 @@ function launchParams(args: HostAgentLaunchArgs) {
...stringSessionOptions(args.sessionOptions),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.groupId ? { placement: { groupId: args.groupId } } : {}),
presentation: args.activate === false ? 'background' : 'focused'
presentation: 'focused'
}
}
@@ -166,7 +164,6 @@ 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)
@@ -1,101 +0,0 @@
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()
})
})
@@ -1,102 +0,0 @@
// 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,8 +6,6 @@ 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'
@@ -73,9 +71,6 @@ 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. */
@@ -270,21 +265,6 @@ 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,11 +8,6 @@ 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
@@ -29,37 +24,7 @@ export function newTabPromptLaunchesThroughHost(args: {
)
}
/**
* 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. */
/** The tab is gone, so the pane's own words go in a notice, with its prompt to copy. */
function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: string): void {
if (outcome.kind !== 'not-started') {
return
@@ -70,17 +35,15 @@ function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: str
? { kind: 'unconfirmed' }
: { kind: 'not-started', code: outcome.code ?? '' }
),
prompt
? {
action: {
label: translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt',
'Copy prompt'
),
onClick: () => void window.api.ui.writeClipboardText(prompt)
}
}
: undefined
{
action: {
label: translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt',
'Copy prompt'
),
onClick: () => void window.api.ui.writeClipboardText(prompt)
}
}
)
}