mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user