diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts index f72a249b9c7..204c4678475 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -43,7 +43,8 @@ describe('launchDashboardAgent', () => { requestId: expect.any(String), agent: 'codex', worktreeId: 'folder:docs', - launchSource: 'unknown' + launchSource: 'unknown', + freshNewTab: true }) }) }) diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts index 43ff278bd7c..88f65bd2ba2 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -19,7 +19,8 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA requestId: newAgentLaunchRequestId(), agent, worktreeId, - launchSource: 'unknown' + launchSource: 'unknown', + freshNewTab: true }) !== null ) } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx index ac4bb5eed80..482279ac756 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -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. diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 8536d4e5912..6a1b6288008 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -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( diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 861e239d95c..11958d71d15 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -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( diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index dcbc45df788..0cfdba3c75a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -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( diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 43b0cada390..2af2f068a3f 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -101,7 +101,8 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr agent, worktreeId: activeWorktreeId, groupId: targetGroupId, - launchSource: 'shortcut' + launchSource: 'shortcut', + freshNewTab: true }) if (!result) { toast.error( diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts index abccb529cc4..e8bbc762e2e 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -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() diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 59b8e202cd4..2d15f7a99e7 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -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) diff --git a/src/renderer/src/lib/launch-agent-fresh-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-fresh-new-tab-host-route.test.ts new file mode 100644 index 00000000000..982dd6b102b --- /dev/null +++ b/src/renderer/src/lib/launch-agent-fresh-new-tab-host-route.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-fresh-host.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-fresh-host.test.ts new file mode 100644 index 00000000000..633a9eb9d1c --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-fresh-host.test.ts @@ -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) => Promise>() +) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc +})) + +createStoreCascadesMockApi() + +const WT = 'repo1::/path/wt1' + +type Settings = Partial> + +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) + } + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 16047919930..827469cb7cc 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -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, diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts index 47a7f2675e2..b6e493ee9e1 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -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, + 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 ) }