mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
A server-hosted browser tab opened in a server workspace drawn from frames survives other changes to that workspace (switch-on builds only) (#31920)
* fix(L273-core): tabs this desktop created in a paired server's frame-drawn workspace survive the server's frames (switch-on) A tab the server's layout never lists (a client-hosted browser tab today; any kind recorded the same way) used to vanish on the server's next frame rebuild. This desktop now records such tabs by server and workspace, in its localStorage only (never a session field nor sent), and the server's frame builds keep them as drawn, inside the server's own groups: - A held tab drawn in a group the server lacks moves to the nearest server group; it shows there only if the user was on it, and the user's group follows it. The group tree stays the server's. - The record forgets an id once the server's frame lists it, on close, and on worktree removal, and prunes ids the restored session no longer has before the first frame after a reload. - A held tab's drag moves it here only (no session.tabs.move); it offers no new split, and a server tab sharing its group counts only the server's tabs for split no-ops. - Client-hosted staging records its tab (both ids) in layout-owner builds; restage takes the worktree from its caller instead of scanning the store. * refactor(L273-core): the desktop-created record is inert with the switch off; clearer names - With the switch off the record is never read, kept or written (a single gate in the record). - The frame-drawn check is isDesktopCreatedTabInFrameDrawnServerWorkspace in frame-drawn-desktop-created-tabs.ts: "held" stays the frame hold's word. - The held-tab placement drops its unread activeGroupId; the group follow derives it. * fix(L282): a client-hosted browser tab this desktop creates in a frame-drawn server workspace completes once the server's snapshot lists it, instead of undoing itself after 8 s (switch-on) * fix(L284): a server-hosted browser tab this desktop creates in a frame-drawn server workspace completes when the server's frames take it over, instead of reading as the user's close (switch-on) * chore(L282/L284): adopt only staged client-hosted handles, and name the frame build's handover note (review nits) * fix(L284): a staged tab the server's frames took over completes the create though its pane, unmounted, has no handle yet (layout-owner build only) * fix(browser): a switch-on host answers a paired client's browser create with the new page's id, not its workspace's, before main's frame draws it * fix(browser): the reply names the page id the create asked for first, then the new record's own page (L291) * fix(L284): the frame build scans dropped pages for a handover only when a staged create's page is drawn, so a large close stays one pass * fix(L285): a staged server-hosted browser tab stays through the server's frames until the server lists its page, then hands over to the server's own tab (layout-owner build only) * fix(L285): release a held browser tab by the server listing its page, staged or adopted, in one scan per frame (review)
This commit is contained in:
@@ -20,6 +20,10 @@ import { createStoreSessionMockApi } from '../store/slices/store-session-test-ha
|
||||
import { _resetLayoutFramePositionForTests } from './layout-frame-position'
|
||||
import { useAppStore } from '@/store'
|
||||
import { stageWebRuntimeBrowserTab } from './web-runtime-browser-tab-staging'
|
||||
import {
|
||||
_resetStagedBrowserFrameHandoversForTests,
|
||||
wasStagedBrowserTabHandedToFrames
|
||||
} from '@/lib/staged-browser-frame-handover'
|
||||
import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import { commitTabDragDrop } from '@/components/tab-group/tab-drag-drop-commit'
|
||||
@@ -546,3 +550,70 @@ describe('a tab in this window’s own frame-drawn workspace', () => {
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a staged server-hosted browser tab (L285)', () => {
|
||||
/** The server's layout once it published the page: its own tab and record for it. */
|
||||
const WITH_PAGE = (() => {
|
||||
const layout = serverLayout([
|
||||
serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1'),
|
||||
{ ...serverTerminalTab('server-browser-tab', LEAF_ID, null, 2), kind: 'browser' as const }
|
||||
])
|
||||
const browserTab = { ...layout.tabs[1]!, entityId: 'server-browser-1' }
|
||||
delete browserTab.terminal
|
||||
return {
|
||||
...layout,
|
||||
tabs: [layout.tabs[0]!, browserTab],
|
||||
browserTabs: [{ id: 'server-browser-1', pageIds: ['server-page-1'] }]
|
||||
}
|
||||
})()
|
||||
|
||||
it('stays through a frame that does not list its page, and hands over to the one that does', async () => {
|
||||
_resetStagedBrowserFrameHandoversForTests()
|
||||
const { store, stream, frames } = await appStoreFrameDrawn()
|
||||
const staged = stageWebRuntimeBrowserTab({
|
||||
environmentId: ENV,
|
||||
worktreeId: WT,
|
||||
remotePageId: 'server-page-1',
|
||||
activate: true,
|
||||
targetGroupId: HOST_GROUP
|
||||
})!
|
||||
|
||||
// Another client's change rebuilds the workspace before the server publishes the page.
|
||||
streams.send(stream, frames.records(WT, WITH_TAB_2))
|
||||
await settle()
|
||||
expect(check.workspaceBuilt).toContain(WT)
|
||||
expect(tabIds(store)).toContain(staged.tabId)
|
||||
|
||||
check.forgetBuilds()
|
||||
streams.send(stream, frames.records(WT, WITH_PAGE))
|
||||
await settle()
|
||||
expect(check.workspaceBuilt).toContain(WT)
|
||||
expect(tabIds(store)).not.toContain(staged.tabId)
|
||||
expect(tabIds(store)).toContain('server-browser-tab')
|
||||
expect(wasStagedBrowserTabHandedToFrames(staged.workspaceId, 'server-page-1')).toBe(true)
|
||||
expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, staged.workspaceId)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves no hold on a tab the mirror adopted before the frames drew the workspace', async () => {
|
||||
const { store, stream, frames } = await appStoreFrameDrawn()
|
||||
const staged = stageWebRuntimeBrowserTab({
|
||||
environmentId: ENV,
|
||||
worktreeId: WT,
|
||||
remotePageId: 'server-page-1',
|
||||
activate: true,
|
||||
targetGroupId: HOST_GROUP
|
||||
})!
|
||||
// The mirror's adoption: same rows, the handle no longer staged.
|
||||
store.getState().setRemoteBrowserPageHandle(staged.pageId, {
|
||||
environmentId: ENV,
|
||||
remotePageId: 'server-page-1'
|
||||
})
|
||||
|
||||
streams.send(stream, frames.records(WT, WITH_PAGE))
|
||||
await settle()
|
||||
expect(check.workspaceBuilt).toContain(WT)
|
||||
expect(tabIds(store)).not.toContain(staged.tabId)
|
||||
expect(tabIds(store)).toContain('server-browser-tab')
|
||||
expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, staged.tabId!)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { serverHeldPanes, showServerLayoutHeldPanes } from './server-held-panes'
|
||||
import { createServerViewReport, onServerControlConnected } from './server-layout-view-report'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
import { releaseHeldBrowserTabsTheServerLists } from './web-runtime-browser-tab-staging'
|
||||
import { provideServerLayoutApplyRoutes } from './server-layout-apply-routes'
|
||||
import { mirroredRuntimeEnvironmentIdForWorkspace } from './web-session-tabs-sync/mirrored-workspace-owner'
|
||||
import {
|
||||
@@ -153,12 +154,19 @@ function openServerFrames(
|
||||
translate: (frame) => {
|
||||
const translated = translator.translate(frame)
|
||||
// A tab this desktop created that the server now lists is the server's from here on.
|
||||
const serverPageIds = new Map<string, ReadonlySet<string>>()
|
||||
for (const key of translator.keys()) {
|
||||
const layout = translator.serverLayout(key)
|
||||
forgetPublishedServerTabs(
|
||||
key,
|
||||
(translator.serverLayout(key)?.tabs ?? []).flatMap((tab) => [tab.id, tab.entityId])
|
||||
(layout?.tabs ?? []).flatMap((tab) => [tab.id, tab.entityId])
|
||||
)
|
||||
serverPageIds.set(
|
||||
key,
|
||||
new Set((layout?.browserTabs ?? []).flatMap((tab) => tab.pageIds ?? []))
|
||||
)
|
||||
}
|
||||
releaseHeldBrowserTabsTheServerLists(environmentId, serverPageIds)
|
||||
live ||= frame.type === 'snapshot'
|
||||
if (frame.type === 'snapshot') {
|
||||
viewReport.reset()
|
||||
|
||||
@@ -71,7 +71,7 @@ export function stageWebRuntimeBrowserTab(args: {
|
||||
clientHosted: args.clientHosted === true,
|
||||
...(tabId ? { tabId } : {})
|
||||
}
|
||||
noteClientHosting(args.environmentId, args.worktreeId, staged)
|
||||
holdStagedBrowserTab(args.environmentId, args.worktreeId, staged)
|
||||
return staged
|
||||
} catch (error) {
|
||||
// Why: staging is an optimization; a store-side refusal must not fail the create itself.
|
||||
@@ -189,13 +189,19 @@ export function restageWebRuntimeBrowserTabHostingIntent(
|
||||
staged: true,
|
||||
...(args.clientHosted ? { stagedClientHosted: true } : {})
|
||||
})
|
||||
const restaged = { ...staged, clientHosted: args.clientHosted }
|
||||
noteClientHosting(args.environmentId, args.worktreeId, restaged)
|
||||
return restaged
|
||||
return { ...staged, clientHosted: args.clientHosted }
|
||||
}
|
||||
|
||||
/** A client-hosted page never enters its server's layout, so that server's frames keep the tab (L274). */
|
||||
function noteClientHosting(
|
||||
const stagedTabIds = (staged: Pick<StagedWebRuntimeBrowserTab, 'workspaceId' | 'tabId'>) => [
|
||||
staged.workspaceId,
|
||||
...(staged.tabId ? [staged.tabId] : [])
|
||||
]
|
||||
|
||||
/**
|
||||
* The server's frames keep a staged tab: a client-hosted page never enters its server's layout
|
||||
* (L274), and a server-hosted one is not there until the server publishes it (L285).
|
||||
*/
|
||||
function holdStagedBrowserTab(
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
staged: StagedWebRuntimeBrowserTab
|
||||
@@ -205,11 +211,43 @@ function noteClientHosting(
|
||||
return
|
||||
}
|
||||
// By both ids: its browser workspace and its tab-bar entry.
|
||||
for (const id of [staged.workspaceId, ...(staged.tabId ? [staged.tabId] : [])]) {
|
||||
if (staged.clientHosted) {
|
||||
noteDesktopCreatedServerTab(environmentId, worktreeId, id)
|
||||
} else {
|
||||
forgetDesktopCreatedServerTab(id)
|
||||
for (const id of stagedTabIds(staged)) {
|
||||
noteDesktopCreatedServerTab(environmentId, worktreeId, id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A held browser tab whose page the server's layout now lists is the server's: its frames draw the
|
||||
* page in their own tab and drop this one, which a create still in flight reads as the frames taking
|
||||
* it over (L284). Also a tab the mirror adopted before the frames drew the workspace.
|
||||
*/
|
||||
export function releaseHeldBrowserTabsTheServerLists(
|
||||
environmentId: string,
|
||||
serverPageIdsByWorkspace: ReadonlyMap<string, ReadonlySet<string>>
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
const listed = [...serverPageIdsByWorkspace.values()]
|
||||
const pageIds = new Set(
|
||||
Object.entries(state.remoteBrowserPageHandlesByPageId).flatMap(([pageId, handle]) =>
|
||||
handle?.environmentId === environmentId && listed.some((ids) => ids.has(handle.remotePageId))
|
||||
? [pageId]
|
||||
: []
|
||||
)
|
||||
)
|
||||
if (pageIds.size === 0) {
|
||||
return
|
||||
}
|
||||
for (const worktreeId of serverPageIdsByWorkspace.keys()) {
|
||||
for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) {
|
||||
if (!workspace.pageIds?.some((pageId) => pageIds.has(pageId))) {
|
||||
continue
|
||||
}
|
||||
const tabId = state.unifiedTabsByWorktree[worktreeId]?.find(
|
||||
(tab) => tab.contentType === 'browser' && tab.entityId === workspace.id
|
||||
)?.id
|
||||
for (const id of stagedTabIds({ workspaceId: workspace.id, ...(tabId ? { tabId } : {}) })) {
|
||||
forgetDesktopCreatedServerTab(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user