mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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
This commit is contained in:
@@ -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)
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
title?: string
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
}) => void
|
||||
callback: (data: RuntimeTerminalCreateRequestPayload) => void
|
||||
) => () => void
|
||||
replyTerminalCreate: (reply: {
|
||||
requestId: string
|
||||
|
||||
+3
-30
@@ -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<string, string>
|
||||
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<string, string>
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<NonNullable<OrcaHooks['environmentRecipes']>>(
|
||||
[]
|
||||
)
|
||||
const [ephemeralVmRecipes, setEphemeralVmRecipes] = useState<
|
||||
NonNullable<OrcaHooks['environmentRecipes']>
|
||||
>([])
|
||||
const [selectedEphemeralVmRecipeId, setSelectedEphemeralVmRecipeId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AppState, 'activeGroupIdByWorktree' | 'openFile'>
|
||||
@@ -28,20 +24,9 @@ export async function createFloatingWorkspaceTerminalTab(
|
||||
shellOverride?: string
|
||||
): Promise<TerminalTab | null> {
|
||||
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<BrowserTab | null> {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WebSessionTabsSyncState> {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user