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:
Wolfie
2026-06-30 12:48:26 -07:00
committed by GitHub
parent 01070198fa
commit 62ab2d470e
22 changed files with 535 additions and 268 deletions
@@ -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}
+3 -3
View File
@@ -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
)
+39 -1
View File
@@ -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')
}
+3 -1
View File
@@ -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()