From 62ab2d470ee3400a495c1e22e37601aa75a14c24 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Tue, 30 Jun 2026 12:48:26 -0700 Subject: [PATCH] fix: keep floating tabs local with active runtime Keep the synthetic floating workspace local while a remote runtime is active, including terminal/browser creation, activation, close, and remote snapshot handling. Maintainer follow-ups: - require worktreeId for runtime-session terminal create payloads - add renderer-backed terminal create reply sender regression coverage - merge current main and keep the WSL readDir breadcrumb test aligned with main's Windows-only handler coverage --- src/main/runtime/orca-runtime-browser.test.ts | 13 +- src/main/runtime/orca-runtime-browser.ts | 4 +- src/main/runtime/orca-runtime.test.ts | 110 +++++++++++++-- src/main/runtime/orca-runtime.ts | 9 +- .../attach-main-window-services.test.ts | 39 ++++++ .../window/attach-main-window-services.ts | 6 +- src/preload/api-types.ts | 17 +-- src/preload/index.ts | 33 +---- .../FloatingTerminalPanel.test.tsx | 80 ++++++++--- .../FloatingTerminalPanel.tsx | 126 ++++-------------- src/renderer/src/hooks/useComposerState.ts | 6 +- src/renderer/src/hooks/useIpcEvents.test.ts | 40 +++++- src/renderer/src/hooks/useIpcEvents.ts | 4 +- .../lib/floating-workspace-tab-creation.ts | 40 ++---- ...loating-workspace-terminal-actions.test.ts | 112 +++++++++++----- .../floating-workspace-terminal-actions.ts | 20 --- .../src/lib/worktree-runtime-owner.test.ts | 8 ++ .../src/lib/worktree-runtime-owner.ts | 7 + .../src/runtime/web-session-tabs-sync.test.ts | 60 +++++++++ .../src/runtime/web-session-tabs-sync.ts | 11 ++ .../store/slices/cmd-j-create-actions.test.ts | 32 +++++ src/shared/runtime-types.ts | 26 ++++ 22 files changed, 535 insertions(+), 268 deletions(-) diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 9a2f611cfaf..7e060928d66 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -151,6 +151,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { it('creates the first explicit-worktree browser tab without waiting for an existing registration', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const webContents = { send: vi.fn() } const send = vi.fn((channel: string, data: { requestId: string }) => { expect(channel).toBe('browser:requestTabCreate') const handler = ipcMainOnMock.mock.calls.find( @@ -161,8 +162,16 @@ describe('RuntimeBrowserCommands browser screencast', () => { reply: { requestId: string; browserPageId?: string; error?: string } ) => void) | undefined - handler?.({} as never, { requestId: data.requestId, browserPageId: 'page-new' }) + handler?.({ sender: { send: vi.fn() } } as never, { + requestId: data.requestId, + error: 'spoofed renderer reply' + }) + handler?.({ sender: webContents } as never, { + requestId: data.requestId, + browserPageId: 'page-new' + }) }) + webContents.send = send const bridge = { getRegisteredTabs: vi.fn(() => new Map([['page-new', 101]])), getActivePageId: vi.fn(() => 'page-new'), @@ -173,7 +182,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { createHost({ getAgentBrowserBridge: () => bridge, getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never), - getAuthoritativeWindow: vi.fn(() => ({ webContents: { send } }) as never) + getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never) }) ) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 196419311bf..dad2ae6f9ef 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1795,10 +1795,10 @@ export class RuntimeBrowserCommands { }, 10_000) const handler = ( - _event: Electron.IpcMainEvent, + event: Electron.IpcMainEvent, reply: { requestId: string; browserPageId?: string; error?: string } ): void => { - if (reply.requestId !== requestId) { + if (event.sender !== win.webContents || reply.requestId !== requestId) { return } clearTimeout(timer) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ee9ddafdcd9..a5523e8a496 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7305,6 +7305,78 @@ describe('OrcaRuntimeService', () => { ) }) + it('accepts renderer-backed terminal create replies only from the target renderer', async () => { + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: { send: vi.fn() } }, + { requestId: payload.requestId, error: 'spoofed renderer reply' } + ) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + title: 'Renderer Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-renderer', title: 'Renderer Terminal' } + ) + }) + webContents.send = send + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'codex', + rendererBacked: true, + title: 'Renderer Terminal' + }) + ).resolves.toMatchObject({ + handle: expect.stringMatching(/^term_/), + tabId: 'tab-renderer', + title: 'Renderer Terminal', + worktreeId: TEST_WORKTREE_ID, + surface: 'visible' + }) + expect(send).toHaveBeenCalledWith( + 'terminal:requestTabCreate', + expect.objectContaining({ + requestId: expect.any(String), + worktreeId: TEST_WORKTREE_ID, + command: 'codex', + title: 'Renderer Terminal' + }) + ) + expect(electronMocks.ipcMain.removeListener).toHaveBeenCalledWith( + 'terminal:tabCreateReply', + expect.any(Function) + ) + }) + it('splits visible pty-backed terminal sessions through the parent renderer tab', async () => { const spawn = vi .fn() @@ -15888,7 +15960,13 @@ describe('OrcaRuntimeService', () => { terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() }) + const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string; activate?: boolean }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: { send: vi.fn() } }, + { requestId: payload.requestId, error: 'spoofed renderer reply' } + ) runtime.syncWindowGraph(1, { tabs: [], leaves: [ @@ -15924,7 +16002,7 @@ describe('OrcaRuntimeService', () => { }) ipcMain.emit( 'terminal:tabCreateReply', - {}, + { sender: webContents }, { requestId: payload.requestId, tabId: 'tab-renderer', @@ -15932,11 +16010,12 @@ describe('OrcaRuntimeService', () => { } ) }) + webContents.send = send runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false, - webContents: { send } + webContents }) const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { @@ -15947,7 +16026,8 @@ describe('OrcaRuntimeService', () => { 'terminal:requestTabCreate', expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, - activate: false + activate: false, + source: 'runtime-session' }) ) expect(focusTerminal).not.toHaveBeenCalled() @@ -15971,6 +16051,7 @@ describe('OrcaRuntimeService', () => { terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() }) + const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string }) => { runtime.syncWindowGraph(1, { tabs: [], @@ -16007,15 +16088,16 @@ describe('OrcaRuntimeService', () => { }) ipcMain.emit( 'terminal:tabCreateReply', - {}, + { sender: webContents }, { requestId: payload.requestId, tabId: 'tab-renderer', title: 'Terminal' } ) }) + webContents.send = send runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false, - webContents: { send } + webContents }) const [first, second] = await Promise.all([ @@ -16072,20 +16154,22 @@ describe('OrcaRuntimeService', () => { terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() }) + const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string; worktreeId: string }) => { const parentTabId = payload.worktreeId === TEST_WORKTREE_ID ? 'tab-renderer-a' : 'tab-renderer-b' ipcMain.emit( 'terminal:tabCreateReply', - {}, + { sender: webContents }, { requestId: payload.requestId, tabId: parentTabId, title: 'Terminal' } ) }) + webContents.send = send runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false, - webContents: { send } + webContents }) const firstCreate = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { @@ -16200,18 +16284,20 @@ describe('OrcaRuntimeService', () => { terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() }) + const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string }) => { ipcMain.emit( 'terminal:tabCreateReply', - {}, + { sender: webContents }, { requestId: payload.requestId, tabId: 'tab-pending', title: 'Terminal' } ) }) + webContents.send = send runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false, - webContents: { send } + webContents }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { @@ -16309,18 +16395,20 @@ describe('OrcaRuntimeService', () => { }) // Why: reply with a tabId but never sync a matching surface graph, so // waitForMobileTerminalSurface times out and the rollback path runs. + const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string }) => { ipcMain.emit( 'terminal:tabCreateReply', - {}, + { sender: webContents }, { requestId: payload.requestId, tabId: 'tab-ghost', title: 'Terminal' } ) }) + webContents.send = send runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false, - webContents: { send } + webContents }) const pending = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5b706c2cf9f..819fb34ea69 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15373,10 +15373,10 @@ export class OrcaRuntimeService { }, 10_000) const handler = ( - _event: Electron.IpcMainEvent, + event: Electron.IpcMainEvent, r: { requestId: string; tabId?: string; title?: string; error?: string } ): void => { - if (r.requestId !== requestId) { + if (event.sender !== win.webContents || r.requestId !== requestId) { return } clearTimeout(timer) @@ -15535,10 +15535,10 @@ export class OrcaRuntimeService { }, 10_000) const handler = ( - _event: Electron.IpcMainEvent, + event: Electron.IpcMainEvent, r: { requestId: string; tabId?: string; title?: string; error?: string } ): void => { - if (r.requestId !== requestId) { + if (event.sender !== win.webContents || r.requestId !== requestId) { return } clearTimeout(timer) @@ -15560,6 +15560,7 @@ export class OrcaRuntimeService { ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, + source: 'runtime-session', activate: opts.activate }) }) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index f69ca694c4a..323342c1805 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -603,4 +603,43 @@ describe('attachMainWindowServices', () => { sendMock.mock.invocationCallOrder[0] ) }) + + it('accepts terminal reveal replies only from the main window renderer', async () => { + const sendMock = vi.fn() + const mainWindow = createMainWindow({ send: sendMock }) + const runtime = createRuntime() + + attachMainWindowServices(mainWindow as never, createStore(), runtime as never) + + const notifier = runtime.setNotifier.mock.calls[0][0] as { + revealTerminalSession: ( + worktreeId: string, + opts: { ptyId: string; title?: string; activate?: boolean } + ) => Promise<{ tabId: string; title?: string }> + } + const revealPromise = notifier.revealTerminalSession('wt-1', { + ptyId: 'pty-1', + title: 'SSH tmux' + }) + const sentPayload = sendMock.mock.calls.find( + ([channel]) => channel === 'ui:createTerminal' + )?.[1] + const handler = onMock.mock.calls.find( + ([channel]) => channel === 'terminal:tabCreateReply' + )?.[1] + + handler?.( + { sender: { send: vi.fn() } }, + { requestId: sentPayload.requestId, error: 'spoofed renderer reply' } + ) + expect(removeListenerMock).not.toHaveBeenCalledWith('terminal:tabCreateReply', handler) + + handler?.( + { sender: mainWindow.webContents }, + { requestId: sentPayload.requestId, tabId: 'tab-1', title: 'SSH tmux' } + ) + + await expect(revealPromise).resolves.toEqual({ tabId: 'tab-1', title: 'SSH tmux' }) + expect(removeListenerMock).toHaveBeenCalledWith('terminal:tabCreateReply', handler) + }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 1c1f40cdccb..42924392358 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -264,10 +264,12 @@ function registerRuntimeWindowLifecycle( reject(new Error('Terminal reveal timed out')) }, 10_000) const handler = ( - _event: Electron.IpcMainEvent, + event: Electron.IpcMainEvent, reply: { requestId: string; tabId?: string; title?: string; error?: string } ): void => { - if (reply.requestId !== requestId) { + // Why: requestId is renderer-supplied; only the targeted main window + // may satisfy the reveal and provide the tab handle. + if (event.sender !== mainWindow.webContents || reply.requestId !== requestId) { return } clearTimeout(timer) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 19a84e49864..e134ed2de0f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -268,6 +268,7 @@ import type { RuntimeStatus, RuntimeSyncWindowGraphResult, RuntimeSyncWindowGraph, + RuntimeTerminalCreateRequestPayload, RuntimeTerminalDriverState, RuntimeTerminalPresentation } from '../shared/runtime-types' @@ -2591,21 +2592,7 @@ export type PreloadApi = { }) => void ) => () => void onRequestTerminalCreate: ( - callback: (data: { - requestId: string - worktreeId?: string - afterTabId?: string - targetGroupId?: string - command?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - title?: string - activate?: boolean - presentation?: RuntimeTerminalPresentation - }) => void + callback: (data: RuntimeTerminalCreateRequestPayload) => void ) => () => void replyTerminalCreate: (reply: { requestId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d415e0efdfa..b1f83b4aa60 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -65,6 +65,7 @@ import type { RuntimeStatus, RuntimeSyncWindowGraphResult, RuntimeSyncWindowGraph, + RuntimeTerminalCreateRequestPayload, RuntimeTerminalDriverState, RuntimeTerminalPresentation } from '../shared/runtime-types' @@ -3228,39 +3229,11 @@ const api = { return () => ipcRenderer.removeListener('ui:createTerminal', listener) }, onRequestTerminalCreate: ( - callback: (data: { - requestId: string - worktreeId?: string - afterTabId?: string - targetGroupId?: string - command?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - title?: string - activate?: boolean - presentation?: RuntimeTerminalPresentation - }) => void + callback: (data: RuntimeTerminalCreateRequestPayload) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { - requestId: string - worktreeId?: string - afterTabId?: string - targetGroupId?: string - command?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - title?: string - activate?: boolean - presentation?: RuntimeTerminalPresentation - } + data: RuntimeTerminalCreateRequestPayload ) => callback(data) ipcRenderer.on('terminal:requestTabCreate', listener) return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index c571d145808..4665a34cc91 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -1225,6 +1225,59 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') }) + it('keeps floating browser create and duplicate local during active web runtime sessions', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + ;(storeBox.state as FloatingPanelStoreState).settings.activeRuntimeEnvironmentId = 'runtime-1' + ;(storeBox.state as FloatingPanelStoreState).browserTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'browser-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + sessionProfileId: 'profile-1', + createdAt: 1 + } + ] + } + mocks.isWebRuntimeSessionActive.mockReturnValue(true) + mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(true) + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onNewBrowserTab as () => void)() + ;(tabBar.props.onDuplicateBrowserTab as (browserTabId: string) => void)('browser-1') + + expect(mocks.createWebRuntimeSessionBrowserTab).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).toHaveBeenNthCalledWith( + 1, + FLOATING_TERMINAL_WORKTREE_ID, + 'about:blank', + { + title: 'New Browser Tab', + focusAddressBar: true, + targetGroupId: 'floating-group', + browserRuntimeEnvironmentId: null + } + ) + expect(mocks.createBrowserTab).toHaveBeenNthCalledWith( + 2, + FLOATING_TERMINAL_WORKTREE_ID, + 'https://example.com', + { + title: 'Example', + sessionProfileId: 'profile-1', + targetGroupId: 'floating-group', + browserRuntimeEnvironmentId: null + } + ) + }) + it('hides the active terminal pane from the renderer while the panel is closed', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) @@ -2251,7 +2304,7 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.closeUnifiedTab).not.toHaveBeenCalledWith(simulatorTab.id) }) - it('routes floating terminal create and close through active web runtime sessions', async () => { + it('keeps floating terminal create and close local during active web runtime sessions', async () => { const onOpenChange = vi.fn() setFloatingTabs([makeTab({ id: 'tab-1' })]) ;(storeBox.state as FloatingPanelStoreState).settings.activeRuntimeEnvironmentId = 'runtime-1' @@ -2263,22 +2316,19 @@ describe('FloatingTerminalPanel close behavior', () => { ;(tabBar.props.onNewTerminalTab as () => void)() await flushAsyncWork() - expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - targetGroupId: 'floating-group', - command: undefined, - activate: true, - selectWorktree: false - }) - expect(mocks.createTab).not.toHaveBeenCalled() + expect(mocks.createWebRuntimeSessionTerminal).not.toHaveBeenCalled() + expect(mocks.createTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + 'floating-group', + undefined, + { activate: false } + ) + expect(mocks.activateTab).toHaveBeenCalledWith('created-tab') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') - expect(mocks.closeWebRuntimeSessionTab).toHaveBeenCalledWith({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId: 'tab-1', - environmentId: 'runtime-1' - }) - expect(mocks.closeTab).not.toHaveBeenCalled() + expect(mocks.closeWebRuntimeSessionTab).not.toHaveBeenCalled() + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') expect(onOpenChange).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 0366cdbb105..61f8ea3dd5b 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -47,13 +47,6 @@ import { import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup' -import { - activateWebRuntimeSessionTab, - closeWebRuntimeSessionTab, - createWebRuntimeSessionBrowserTab, - createWebRuntimeSessionTerminal, - isWebRuntimeSessionActive -} from '@/runtime/web-runtime-session' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, @@ -658,27 +651,10 @@ export function FloatingTerminalPanel({ return } activateTab(item.id) - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() if (item.contentType === 'terminal') { - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { - void activateWebRuntimeSessionTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId: item.entityId, - environmentId: runtimeEnvironmentId - }) - } setActiveTab(item.entityId) focusTerminalTabSurface(item.entityId) } else if (item.contentType === 'browser') { - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { - void activateWebRuntimeSessionTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId: item.id, - environmentId: runtimeEnvironmentId - }) - } const workspace = useAppStore .getState() .browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( @@ -694,50 +670,28 @@ export function FloatingTerminalPanel({ const createFloatingTerminalTab = useCallback( (shellOverride?: string) => { - void (async () => { - if ( - await createWebRuntimeSessionTerminal({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - targetGroupId: activeGroup?.id, - command: shellOverride, - activate: true, - selectWorktree: false - }) - ) { - return - } - const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride, { - activate: false - }) - activateTab(tab.id) - focusTerminalTabSurface(tab.id) - })() + // Why: the floating workspace is a local scratchpad; a focused remote + // runtime must not own tabs users keep there for manual SSH/tmux work. + const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride, { + activate: false + }) + activateTab(tab.id) + focusTerminalTabSurface(tab.id) }, [activateTab, activeGroup, createTab] ) const createFloatingBrowserTab = useCallback(() => { - void (async () => { - const url = browserDefaultUrl ?? 'about:blank' - if ( - await createWebRuntimeSessionBrowserTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - url, - targetGroupId: activeGroup?.id, - selectWorktree: false - }) - ) { - return - } - createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { - title: translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17', - 'New Browser Tab' - ), - focusAddressBar: true, - targetGroupId: activeGroup?.id - }) - })() + const url = browserDefaultUrl ?? 'about:blank' + createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { + title: translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17', + 'New Browser Tab' + ), + focusAddressBar: true, + targetGroupId: activeGroup?.id, + browserRuntimeEnvironmentId: null + }) }, [activeGroup, browserDefaultUrl, createBrowserTab]) const createFloatingMarkdownTab = useCallback(() => { @@ -809,21 +763,7 @@ export function FloatingTerminalPanel({ return } const dirtyEditorFileIds: string[] = [] - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() for (const item of items) { - if ( - (item.contentType === 'terminal' || item.contentType === 'browser') && - isWebRuntimeSessionActive(runtimeEnvironmentId) - ) { - // Why: paired web clients mirror host-owned tabs; ask the runtime to - // close the host tab instead of deleting the local mirror directly. - void closeWebRuntimeSessionTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId: item.contentType === 'browser' ? item.id : item.entityId, - environmentId: runtimeEnvironmentId - }) - continue - } if (item.contentType === 'terminal') { closeTab(item.entityId) } else if (item.contentType === 'browser') { @@ -1545,28 +1485,16 @@ export function FloatingTerminalPanel({ onActivateBrowserTab={activateFloatingItem} onCloseBrowserTab={closeFloatingItem} onDuplicateBrowserTab={(browserTabId) => { - void (async () => { - const source = browserTabs.find((tab) => tab.id === browserTabId) - if (!source) { - return - } - if ( - await createWebRuntimeSessionBrowserTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - url: source.url, - profileId: source.sessionProfileId, - targetGroupId: activeGroup?.id, - selectWorktree: false - }) - ) { - return - } - createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { - title: source.title, - sessionProfileId: source.sessionProfileId, - targetGroupId: activeGroup?.id - }) - })() + const source = browserTabs.find((tab) => tab.id === browserTabId) + if (!source) { + return + } + createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { + title: source.title, + sessionProfileId: source.sessionProfileId, + targetGroupId: activeGroup?.id, + browserRuntimeEnvironmentId: null + }) }} onCloseAllFiles={closeAllFiles} onMakePreviewFilePermanent={makePreviewFilePermanent} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index e901bf3bb12..d9ff5ccd20f 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -710,9 +710,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false - const [ephemeralVmRecipes, setEphemeralVmRecipes] = useState>( - [] - ) + const [ephemeralVmRecipes, setEphemeralVmRecipes] = useState< + NonNullable + >([]) const [selectedEphemeralVmRecipeId, setSelectedEphemeralVmRecipeId] = useState( null ) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index ccd0f0eddc7..6a71b6e9108 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1698,7 +1698,8 @@ describe('useIpcEvents updater integration', () => { settings: { terminalFontSize: 13, experimentalNativeChat: false, - openAgentTabsInChatByDefault: false + openAgentTabsInChatByDefault: false, + activeRuntimeEnvironmentId: undefined as string | undefined } } const createTerminalListenerRef: { @@ -1739,6 +1740,7 @@ describe('useIpcEvents updater integration', () => { title?: string activate?: boolean presentation?: 'background' | 'focused' + source?: 'runtime-session' }) => void) | null } = { current: null } @@ -2184,6 +2186,42 @@ describe('useIpcEvents updater integration', () => { title: 'Codex' }) + createTab.mockClear() + replyTerminalCreate.mockClear() + storeState.settings.activeRuntimeEnvironmentId = 'focused-runtime' + requestTerminalCreateListenerRef.current({ + requestId: 'req-runtime-session', + worktreeId: 'wt-2', + targetGroupId: 'group-left', + title: 'Runtime Terminal', + command: 'codex', + activate: true, + source: 'runtime-session' + }) + + expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, undefined) + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-runtime-session', + tabId: 'tab-new', + title: 'Runtime Terminal' + }) + + createTab.mockClear() + replyTerminalCreate.mockClear() + storeState.settings.activeRuntimeEnvironmentId = 'focused-runtime' + requestTerminalCreateListenerRef.current({ + requestId: 'req-runtime-blocked', + worktreeId: 'wt-2', + title: 'Blocked Local Terminal' + }) + + expect(createTab).not.toHaveBeenCalled() + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-runtime-blocked', + error: 'Local terminal creation is unavailable while a remote runtime is active' + }) + storeState.settings.activeRuntimeEnvironmentId = undefined + if (typeof focusTerminalListenerRef.current !== 'function') { throw new Error('Expected focus-terminal listener to be registered') } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 140e716b98f..15964b6b2cc 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1556,7 +1556,9 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTerminalCreate((data) => { try { - if (isRuntimeEnvironmentActive()) { + // Why: runtime-session requests are host-owned tabs materialized by this + // renderer, not ordinary local creates that bypass remote runtime mode. + if (isRuntimeEnvironmentActive() && data.source !== 'runtime-session') { window.api.ui.replyTerminalCreate({ requestId: data.requestId, error: translate( diff --git a/src/renderer/src/lib/floating-workspace-tab-creation.ts b/src/renderer/src/lib/floating-workspace-tab-creation.ts index 12814ca7813..d9b6e51b69b 100644 --- a/src/renderer/src/lib/floating-workspace-tab-creation.ts +++ b/src/renderer/src/lib/floating-workspace-tab-creation.ts @@ -4,21 +4,17 @@ import { createUntitledMarkdownFileWithTemplateSelection } from './create-untitl import { getConnectionId } from './connection-context' import { detectLanguage } from './language-detect' import type { AppState } from '@/store/types' -import { - createWebRuntimeSessionBrowserTab, - createWebRuntimeSessionTerminal -} from '@/runtime/web-runtime-session' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' import { translate } from '@/i18n/i18n' type FloatingWorkspaceTerminalStore = Pick< AppState, - 'activeGroupIdByWorktree' | 'createTab' | 'activateTab' | 'settings' + 'activeGroupIdByWorktree' | 'createTab' | 'activateTab' > type FloatingWorkspaceBrowserStore = Pick< AppState, - 'activeGroupIdByWorktree' | 'browserDefaultUrl' | 'createBrowserTab' | 'settings' + 'activeGroupIdByWorktree' | 'browserDefaultUrl' | 'createBrowserTab' > type FloatingWorkspaceMarkdownStore = Pick @@ -28,20 +24,9 @@ export async function createFloatingWorkspaceTerminalTab( shellOverride?: string ): Promise { const targetGroupId = store.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() - if ( - await createWebRuntimeSessionTerminal({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - environmentId: runtimeEnvironmentId, - targetGroupId, - command: shellOverride, - activate: true, - selectWorktree: false - }) - ) { - return null - } + // Why: the floating workspace is a local scratchpad; a focused remote runtime + // must not own its SSH/tmux terminals or prune them via session snapshots. const tab = store.createTab(FLOATING_TERMINAL_WORKTREE_ID, targetGroupId, shellOverride, { activate: false }) @@ -54,24 +39,15 @@ export async function createFloatingWorkspaceBrowserTab( store: FloatingWorkspaceBrowserStore ): Promise { const targetGroupId = store.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() const url = store.browserDefaultUrl ?? 'about:blank' - if ( - await createWebRuntimeSessionBrowserTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - environmentId: runtimeEnvironmentId, - url, - targetGroupId, - selectWorktree: false - }) - ) { - return null - } + // Why: browser tabs in the floating workspace share the same local-only + // ownership rule as floating terminals. return store.createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { title: translate('auto.lib.floating.workspace.tab.creation.f3785eddc2', 'New Browser Tab'), focusAddressBar: true, - targetGroupId + targetGroupId, + browserRuntimeEnvironmentId: null }) } diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts index f9a7cedfccc..166b7df7e4e 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts @@ -354,14 +354,7 @@ describe('createFloatingWorkspaceTerminalTab', () => { await expect(createFloatingWorkspaceTerminalTab(store as never)).resolves.toBe(tab) - expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - environmentId: undefined, - targetGroupId: 'floating-group', - command: undefined, - activate: true, - selectWorktree: false - }) + expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled() expect(store.createTab).toHaveBeenCalledWith( FLOATING_TERMINAL_WORKTREE_ID, 'floating-group', @@ -372,28 +365,27 @@ describe('createFloatingWorkspaceTerminalTab', () => { expect(focusTerminalTabSurfaceMock).toHaveBeenCalledWith('floating-tab-1') }) - it('leaves local tabs untouched when the web runtime accepts the floating terminal', async () => { + it('ignores the active runtime and keeps floating workspace terminals local', async () => { + const tab = makeTab('floating-tab-runtime') const store = { - activeGroupIdByWorktree: {}, + activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-group' }, settings: { activeRuntimeEnvironmentId: 'env-1' }, - createTab: vi.fn(), + createTab: vi.fn().mockReturnValue(tab), activateTab: vi.fn() } createWebRuntimeSessionTerminalMock.mockResolvedValue(true) - await expect(createFloatingWorkspaceTerminalTab(store as never, 'pwsh')).resolves.toBeNull() + await expect(createFloatingWorkspaceTerminalTab(store as never, 'pwsh')).resolves.toBe(tab) - expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - environmentId: 'env-1', - command: 'pwsh', - selectWorktree: false - }) + expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled() + expect(store.createTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + 'floating-group', + 'pwsh', + { activate: false } ) - expect(store.createTab).not.toHaveBeenCalled() - expect(store.activateTab).not.toHaveBeenCalled() - expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() + expect(store.activateTab).toHaveBeenCalledWith('floating-tab-runtime') + expect(focusTerminalTabSurfaceMock).toHaveBeenCalledWith('floating-tab-runtime') }) }) @@ -414,20 +406,15 @@ describe('createFloatingWorkspaceBrowserTab', () => { await expect(createFloatingWorkspaceBrowserTab(store as never)).resolves.toBe(browserTab) - expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - environmentId: undefined, - url: 'about:blank', - targetGroupId: 'floating-group', - selectWorktree: false - }) + expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() expect(store.createBrowserTab).toHaveBeenCalledWith( FLOATING_TERMINAL_WORKTREE_ID, 'about:blank', { title: 'New Browser Tab', focusAddressBar: true, - targetGroupId: 'floating-group' + targetGroupId: 'floating-group', + browserRuntimeEnvironmentId: null } ) }) @@ -514,7 +501,6 @@ describe('switchFloatingWorkspaceTab', () => { }, openFiles: [], setActiveTab: vi.fn(), - settings: { activeRuntimeEnvironmentId: null }, tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [makeTab('tab-1'), makeTab('tab-2')] }, @@ -533,6 +519,70 @@ describe('switchFloatingWorkspaceTab', () => { expect(focusTerminalTabSurfaceMock).toHaveBeenCalledWith('tab-2') expect(activateWebRuntimeSessionTabMock).not.toHaveBeenCalled() }) + + it('cycles browser tabs locally while a web runtime is active', () => { + const notifyActiveTabChanged = vi.fn() + vi.stubGlobal('window', { api: { browser: { notifyActiveTabChanged } } }) + isWebRuntimeSessionActiveMock.mockReturnValue(true) + const browserTab = { + id: 'browser-2', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: 'https://example.com', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0, + activePageId: 'page-2' + } + const store = { + activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-group' }, + activateTab: vi.fn(), + browserPagesByWorkspace: {}, + browserTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] }, + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'floating-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: 'tab-1', + tabOrder: ['tab-1', 'tab-browser-2'], + recentTabIds: ['tab-1'] + } + ] + }, + openFiles: [], + setActiveTab: vi.fn(), + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [makeTab('tab-1')] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeUnifiedTerminalTab('tab-1'), + { + id: 'tab-browser-2', + entityId: 'browser-2', + groupId: 'floating-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } satisfies Tab + ] + } + } + + expect(switchFloatingWorkspaceTab(store as never, 1, 'all-types')).toBe(true) + + expect(store.activateTab).toHaveBeenCalledWith('tab-browser-2') + expect(activateWebRuntimeSessionTabMock).not.toHaveBeenCalled() + expect(notifyActiveTabChanged).toHaveBeenCalledWith({ browserPageId: 'page-2' }) + }) }) describe('shouldMinimizeFloatingWorkspacePanelOnCloseShortcut', () => { diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.ts index 584a2c4c593..35c312b0b93 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.ts @@ -9,10 +9,6 @@ import { } from '@/components/terminal/tab-type-cycle' import type { AppState } from '@/store/types' import { TOGGLE_FLOATING_TERMINAL_EVENT } from './floating-terminal' -import { - activateWebRuntimeSessionTab, - isWebRuntimeSessionActive -} from '@/runtime/web-runtime-session' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' import { keybindingMatchesAction, type KeybindingOverrides } from '../../../shared/keybindings' export { @@ -35,7 +31,6 @@ type FloatingWorkspaceTabSwitchStore = Pick< | 'groupsByWorktree' | 'openFiles' | 'setActiveTab' - | 'settings' | 'tabsByWorktree' | 'unifiedTabsByWorktree' > @@ -136,28 +131,13 @@ function activateFloatingWorkspaceCyclableTab( store.activateTab(next.tabId) } - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() if (next.type === 'terminal') { - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { - void activateWebRuntimeSessionTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId: next.id, - environmentId: runtimeEnvironmentId - }) - } store.setActiveTab(next.id) focusTerminalTabSurface(next.id) return } if (next.type === 'browser') { - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { - void activateWebRuntimeSessionTab({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId: next.tabId ?? next.id, - environmentId: runtimeEnvironmentId - }) - } const workspace = getFloatingWorkspaceBrowserTab(store, next.id) if (workspace?.activePageId && typeof window !== 'undefined' && window.api?.browser) { void window.api.browser.notifyActiveTabChanged({ browserPageId: workspace.activePageId }) diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index ddd7cd19a5d..1e8407bff48 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getExplicitRuntimeEnvironmentIdForWorktree, getExecutionHostIdForWorktree, @@ -47,6 +48,13 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { }) }) + it('keeps the synthetic floating workspace local while a runtime is focused', () => { + expect(getSettingsForWorktreeRuntimeOwner(state, FLOATING_TERMINAL_WORKTREE_ID)).toEqual({ + activeRuntimeEnvironmentId: null + }) + expect(getExecutionHostIdForWorktree(state, FLOATING_TERMINAL_WORKTREE_ID)).toBe('local') + }) + it('routes folder workspaces to their project group runtime owner', () => { expect(getSettingsForWorktreeRuntimeOwner(state, 'folder:runtime-folder')).toEqual({ activeRuntimeEnvironmentId: 'folder-env' diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 7537826849e..859aeaaab0b 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -12,6 +12,7 @@ import type { Worktree } from '../../../shared/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' export type WorktreeRuntimeOwnerState = { @@ -130,6 +131,9 @@ export function getRuntimeEnvironmentIdForWorktree( if (!worktreeId) { return null } + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return null + } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { return getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) @@ -215,6 +219,9 @@ export function getExecutionHostIdForWorktree( if (!worktreeId) { return 'local' } + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return 'local' + } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { return getExecutionHostIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index dc117a2e066..b567654eae4 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -5,6 +5,7 @@ import { posix as pathPosix } from 'node:path' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { recordWebSessionFocusIntent, resetWebSessionFocusIntentForTests @@ -144,6 +145,65 @@ describe('applyWebSessionTabsSnapshot', () => { expect(shouldApplyWebSessionTabsSnapshot(sameEpochOlder, ENV)).toBe(false) }) + it('ignores remote snapshots for the local floating workspace', () => { + const floatingTab: TerminalTab = { + id: 'floating-tab-1', + ptyId: 'pty-floating-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + title: 'VPS tmux', + defaultTitle: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + const floatingUnifiedTab: Tab = { + id: floatingTab.id, + entityId: floatingTab.id, + groupId: 'floating-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: floatingTab.title, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false + } + const state = makeState({ + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + ptyIdsByTabId: { [floatingTab.id]: ['pty-floating-1'] }, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingUnifiedTab] }, + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'floating-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: floatingTab.id, + tabOrder: [floatingTab.id], + recentTabIds: [floatingTab.id] + } + ] + }, + activeTabId: floatingTab.id, + activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: floatingTab.id } + }) + + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([], { + worktree: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + activeTabType: null + }), + ENV, + NOW + ) + + expect(patch).toBe(state) + }) + it('suppresses a tab the client is closing until the host confirms removal (no close flash)', () => { const surface = { type: 'terminal' as const, diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 19d39a5f8c7..5c6b4677ce1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -5,6 +5,7 @@ import { useEffect } from 'react' import type { AppState } from '../store' import { useAppStore } from '../store' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry @@ -183,6 +184,13 @@ export function shouldApplyWebSessionTabsSnapshot( clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) return true } + if (snapshot.worktree === FLOATING_TERMINAL_WORKTREE_ID) { + // Why: the floating workspace is a local synthetic terminal. A focused + // remote runtime can publish an empty same-id snapshot while the user has a + // local ssh/tmux tab open; treating that as authoritative deletes the local + // floating tabs. + return false + } rememberHostTerminalTabCount(environmentId, snapshot) const current = latestSessionTabsSnapshotByWorktree.get(key) // Why: snapshotVersion is monotonic only WITHIN one host generation; it resets @@ -1589,6 +1597,9 @@ export function applyWebSessionTabsSnapshot( now = Date.now() ): WebSessionTabsSyncState | Partial { const worktreeId = rawSnapshot.worktree + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return state + } // Why: a remote close prunes the local mirror immediately, but an in-flight // pre-close snapshot can still list the tab and flash it back. Drop any tab // the client is closing until the host confirms removal; reconcile the intents diff --git a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts index 186df6755a2..ecb716a3c57 100644 --- a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts +++ b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { AppState } from '../types' import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers' @@ -97,6 +98,37 @@ describe('Cmd+J lifted creation actions', () => { expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) }) + it('creates local browser and terminal tabs for the synthetic floating workspace while a runtime is focused', async () => { + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) + createWebRuntimeSessionTerminalMock.mockResolvedValue(false) + const store = createTestStore() + seedStore(store, { + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + settings: { activeRuntimeEnvironmentId: 'runtime-1' } as AppState['settings'], + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabOrder: [] + } + ] + }, + activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'group-1' } + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + await store.getState().openNewTerminalTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() + expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled() + expect( + store.getState().browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + ).toHaveLength(1) + expect(store.getState().tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).toHaveLength(1) + }) + it('does not fall back to a local terminal tab when paired-web creation fails', async () => { createWebRuntimeSessionTerminalMock.mockResolvedValue(false) const store = createTestStore() diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 35157401972..bcd916c01d5 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -29,6 +29,8 @@ import type { } from './mobile-markdown-document' import type { RuntimeCapability } from './protocol-version' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { StartupCommandDelivery } from './codex-startup-delivery' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -455,6 +457,30 @@ export type RuntimeTerminalAgentStatus = { } export type RuntimeTerminalPresentation = 'background' | 'focused' +type RuntimeTerminalCreateBaseRequestPayload = { + requestId: string + worktreeId?: string + afterTabId?: string + targetGroupId?: string + command?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + title?: string + activate?: boolean + presentation?: RuntimeTerminalPresentation +} + +export type RuntimeTerminalCreateRequestPayload = + | (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined }) + | (RuntimeTerminalCreateBaseRequestPayload & { + worktreeId: string + // Why: only the host-owned runtime-session bridge may bypass the renderer's + // active-runtime local terminal guard; ordinary UI requests must omit this. + source: 'runtime-session' + }) export type RuntimeTerminalCreate = { handle: string