Fix remote browser/editor tab props persistence (#5847)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-19 17:01:10 -07:00
committed by GitHub
co-authored by Orca
parent 0562240a92
commit 8ef8596ba1
9 changed files with 553 additions and 65 deletions
+102 -2
View File
@@ -9,6 +9,7 @@ import { ipcMain } from 'electron'
import type {
FolderWorkspace,
ProjectGroup,
Tab,
TerminalLayoutSnapshot,
WorktreeLineage,
WorktreeMeta,
@@ -10860,8 +10861,18 @@ describe('OrcaRuntimeService', () => {
},
tabGroups: {
[TEST_WORKTREE_ID]: [
{ id: 'group-left', worktreeId: TEST_WORKTREE_ID, activeTabId: 'host-tab', tabOrder: ['host-tab'] },
{ id: 'group-right', worktreeId: TEST_WORKTREE_ID, activeTabId: 'host-tab-2', tabOrder: ['host-tab-2'] }
{
id: 'group-left',
worktreeId: TEST_WORKTREE_ID,
activeTabId: 'host-tab',
tabOrder: ['host-tab']
},
{
id: 'group-right',
worktreeId: TEST_WORKTREE_ID,
activeTabId: 'host-tab-2',
tabOrder: ['host-tab-2']
}
]
},
tabGroupLayouts: {
@@ -10987,6 +10998,95 @@ describe('OrcaRuntimeService', () => {
expect(surface?.type === 'terminal' && surface.isPinned).toBe(true)
})
it('persists headless browser tab color + pin and surfaces them through a cold rehydrate', async () => {
const browserTab: Tab = {
id: 'browser-page-1',
entityId: 'browser-page-1',
groupId: 'group-1',
worktreeId: TEST_WORKTREE_ID,
contentType: 'browser',
label: 'Live Browser',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2,
isPreview: false,
isPinned: false
}
const session = makeWorkspaceSessionWithHeadlessTerminal({
unifiedTabs: { [TEST_WORKTREE_ID]: [browserTab] },
tabGroups: {
[TEST_WORKTREE_ID]: [
{
id: 'group-1',
worktreeId: TEST_WORKTREE_ID,
activeTabId: 'browser-page-1',
tabOrder: ['browser-page-1']
}
]
}
})
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
runtime.setAgentBrowserBridge({
tabList: vi.fn(() => ({
tabs: [
{
browserPageId: 'browser-page-1',
index: 0,
url: 'https://example.com/',
title: 'Live Browser',
active: true
}
]
}))
} as never)
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, {
tabId: 'browser-page-1',
color: '#3b82f6',
isPinned: true
})
const persisted = getSession().unifiedTabs?.[TEST_WORKTREE_ID]?.find(
(tab) => tab.id === 'browser-page-1'
)
expect(persisted?.color).toBe('#3b82f6')
expect(persisted?.isPinned).toBe(true)
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID)
const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const surface = rehydrated.tabs.find(
(tab) => tab.type === 'browser' && tab.id === 'browser-page-1'
)
expect(surface?.type === 'browser' && surface.color).toBe('#3b82f6')
expect(surface?.type === 'browser' && surface.isPinned).toBe(true)
await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, {
tabId: 'browser-page-1',
color: null,
isPinned: false
})
const cleared = getSession().unifiedTabs?.[TEST_WORKTREE_ID]?.find(
(tab) => tab.id === 'browser-page-1'
)
expect(cleared?.color).toBeNull()
expect(cleared?.isPinned).toBe(false)
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID)
const rehydratedCleared = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const clearedSurface = rehydratedCleared.tabs.find(
(tab) => tab.type === 'browser' && tab.id === 'browser-page-1'
)
expect(clearedSurface?.type === 'browser' && clearedSurface.color).toBeNull()
expect(clearedSurface?.type === 'browser' && clearedSurface.isPinned).toBe(false)
})
it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => {
// Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow,
// and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0.
+71 -29
View File
@@ -95,12 +95,14 @@ import type {
ProjectGroupImportMode,
ProjectGroupImportResult,
MemorySnapshot,
Tab,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalTab,
TuiAgent,
WorkspaceCreateTelemetrySource,
WorkspaceSessionState,
DirEntry
} from '../../shared/types'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
@@ -3174,20 +3176,38 @@ export class OrcaRuntimeService {
if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) {
return []
}
return this.agentBrowserBridge.tabList(worktreeId).tabs.map((tab) => ({
type: 'browser' as const,
// Why: an offscreen page has no separate workspace identity, so the page id
// is its own workspace id (matches the server's browserWorkspaceId fallback).
id: tab.browserPageId,
title: tab.title || tab.url || 'Browser',
browserWorkspaceId: tab.browserPageId,
browserPageId: tab.browserPageId,
url: tab.url || 'about:blank',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: tab.active === true
}))
return this.agentBrowserBridge.tabList(worktreeId).tabs.map((tab) => {
const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId)
return {
type: 'browser' as const,
// Why: an offscreen page has no separate workspace identity, so the page id
// is its own workspace id (matches the server's browserWorkspaceId fallback).
id: tab.browserPageId,
title: tab.title || tab.url || 'Browser',
browserWorkspaceId: tab.browserPageId,
browserPageId: tab.browserPageId,
url: tab.url || 'about:blank',
loading: false,
canGoBack: false,
canGoForward: false,
...(persistedProps ? { color: persistedProps.color } : {}),
...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}),
isActive: tab.active === true
}
})
}
private getPersistedUnifiedSessionTabProps(
worktreeId: string,
tabId: string
): Pick<Tab, 'color' | 'isPinned'> | null {
const tab =
this.store
?.getWorkspaceSession?.()
?.unifiedTabs?.[worktreeId]?.find(
(candidate) => candidate.id === tabId || candidate.entityId === tabId
) ?? null
return tab ? { color: tab.color, isPinned: tab.isPinned } : null
}
private collectPersistedTerminalLeafIds(layout: TerminalLayoutSnapshot | undefined): string[] {
@@ -3997,12 +4017,12 @@ export class OrcaRuntimeService {
const hostTabId = snapshot
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
: args.tabId
this.persistHeadlessTerminalTabProps(worktreeId, hostTabId, args)
this.applyHeadlessTerminalTabPropsToSnapshot(worktreeId, hostTabId, args)
this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args)
this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, args)
return { updated: true }
}
private persistHeadlessTerminalTabProps(
private persistHeadlessSessionTabProps(
worktreeId: string,
tabId: string,
props: { color?: string | null; isPinned?: boolean }
@@ -4012,12 +4032,11 @@ export class OrcaRuntimeService {
return
}
const tabs = session.tabsByWorktree[worktreeId]
if (!tabs?.some((tab) => tab.id === tabId)) {
return
}
this.store.setWorkspaceSession({
...session,
tabsByWorktree: {
const nextSession: WorkspaceSessionState = { ...session }
let changed = false
if (tabs?.some((tab) => tab.id === tabId)) {
changed = true
nextSession.tabsByWorktree = {
...session.tabsByWorktree,
[worktreeId]: tabs.map((tab) =>
tab.id === tabId
@@ -4029,10 +4048,32 @@ export class OrcaRuntimeService {
: tab
)
}
})
}
const unifiedTabs = session.unifiedTabs?.[worktreeId]
if (unifiedTabs?.some((tab) => tab.id === tabId || tab.entityId === tabId)) {
changed = true
nextSession.unifiedTabs = {
...session.unifiedTabs,
[worktreeId]: unifiedTabs.map((tab) =>
tab.id === tabId || tab.entityId === tabId
? {
...tab,
...(props.color !== undefined ? { color: props.color } : {}),
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
}
: tab
)
}
}
if (!changed) {
return
}
this.store.setWorkspaceSession(nextSession)
}
private applyHeadlessTerminalTabPropsToSnapshot(
private applyHeadlessSessionTabPropsToSnapshot(
worktreeId: string,
tabId: string,
props: { color?: string | null; isPinned?: boolean }
@@ -4041,12 +4082,9 @@ export class OrcaRuntimeService {
if (!snapshot) {
return
}
// Why: only terminal tabs persist color/pin today (browser/editor are
// tracked in #5729). Applying to a browser surface here would show a
// transient that reverts on the next rebuild — apply only what we persist.
let changed = false
const tabs = snapshot.tabs.map((tab) => {
if (tab.type !== 'terminal' || tab.parentTabId !== tabId) {
if (this.getMobileSessionTopLevelTabId(tab) !== tabId) {
return tab
}
changed = true
@@ -4069,6 +4107,10 @@ export class OrcaRuntimeService {
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
private getMobileSessionTopLevelTabId(tab: RuntimeMobileSessionSnapshotTab): string {
return tab.type === 'terminal' ? tab.parentTabId : tab.id
}
// Merge the client's pane structure into the persisted tab layout. PTY
// bindings and active leaf stay host-owned; only ratios/expand/titles change.
// terminalLayoutsByTabId is keyed by tab id (worktree-independent).
@@ -552,6 +552,121 @@ describe('getRuntimeMobileSessionSyncKey', () => {
})
describe('buildMobileSessionTabSnapshots', () => {
it('publishes browser and editor color + pin state from unified tabs', () => {
const fileId = '/repo/README.md'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'browser-tab-1',
tabOrder: ['browser-tab-1', 'editor-tab-1'],
recentTabIds: ['browser-tab-1']
}
]
},
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-tab-1',
entityId: 'browser-workspace-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'browser',
label: 'Browser',
customLabel: null,
color: '#3b82f6',
sortOrder: 0,
createdAt: 1,
isPreview: false,
isPinned: false
},
{
id: 'editor-tab-1',
entityId: fileId,
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'editor',
label: 'README.md',
customLabel: null,
color: '#16a34a',
sortOrder: 1,
createdAt: 2,
isPreview: false,
isPinned: false
}
]
},
browserTabsByWorktree: {
'wt-1': [
{
id: 'browser-workspace-1',
worktreeId: 'wt-1',
activePageId: 'browser-page-1',
pageIds: ['browser-page-1'],
url: 'https://example.com/',
title: 'Example Domain',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
browserPagesByWorkspace: {
'browser-workspace-1': [
{
id: 'browser-page-1',
workspaceId: 'browser-workspace-1',
worktreeId: 'wt-1',
url: 'https://example.com/',
title: 'Example Domain',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
openFiles: [
{
id: fileId,
filePath: fileId,
relativePath: 'README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit',
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'browser',
id: 'browser-tab-1',
color: '#3b82f6',
isPinned: false
}),
expect.objectContaining({
type: 'markdown',
id: 'editor-tab-1',
color: '#16a34a',
isPinned: false
})
])
)
})
it('preserves source-control diff metadata for mobile file tabs', () => {
const diffId = 'wt-1::diff::unstaged::src/app.ts'
const state = makeState({
+33 -9
View File
@@ -680,6 +680,9 @@ export function buildMobileSessionTabSnapshots(
workspace
])
)
const unifiedTabByIdForWorktree = new Map(
(state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
const editorIds = openFileIndexes.idsByWorktree.get(worktreeId) ?? []
const publishableTerminalIds = [...terminalTabByIdForWorktree.values()]
.filter((terminal) => !isWebOnlyMirroredTerminalTab(state, terminal))
@@ -721,12 +724,18 @@ export function buildMobileSessionTabSnapshots(
openFileIndexes.byWorktreeAndId,
editorDraftVersionByFileId,
file,
item.tabId
item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined
)
if (markdown) {
tabs.push(markdown)
} else {
tabs.push(buildMobileFileTab(state, file, item.tabId))
tabs.push(
buildMobileFileTab(
state,
file,
item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined
)
)
}
emittedEditorFileIds.add(file.id)
emittedEditorTabIds.add(item.tabId ?? item.id)
@@ -735,7 +744,13 @@ export function buildMobileSessionTabSnapshots(
if (!workspace) {
continue
}
tabs.push(buildMobileBrowserTab(state, workspace, item.tabId))
tabs.push(
buildMobileBrowserTab(
state,
workspace,
item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined
)
)
}
}
@@ -759,9 +774,9 @@ export function buildMobileSessionTabSnapshots(
openFileIndexes.byWorktreeAndId,
editorDraftVersionByFileId,
file,
unifiedTab.id
unifiedTab
)
const fallbackTab = markdown ?? buildMobileFileTab(state, file, unifiedTab.id)
const fallbackTab = markdown ?? buildMobileFileTab(state, file, unifiedTab)
tabs.push(fallbackTab)
fallbackEditorTabs.push({
tabId: fallbackTab.id,
@@ -1317,7 +1332,7 @@ function buildMobileMarkdownTab(
openFileByWorktreeAndId: OpenFileByWorktreeAndId,
editorDraftVersionByFileId: ReadonlyMap<string, string>,
file: AppState['openFiles'][number],
unifiedTabId?: string
unifiedTab?: Tab
): RuntimeMobileSessionMarkdownTab | null {
if (file.mode !== 'edit' && file.mode !== 'markdown-preview') {
return null
@@ -1333,6 +1348,7 @@ function buildMobileMarkdownTab(
: file
const draftVersion = editorDraftVersionByFileId.get(sourceFile.id)
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown'
const unifiedTabId = unifiedTab?.id
return {
type: 'markdown',
@@ -1349,17 +1365,20 @@ function buildMobileMarkdownTab(
sourceFileId: sourceFile.id,
sourceFilePath: sourceFile.filePath,
sourceRelativePath: sourceFile.relativePath,
documentVersion: draftVersion ?? `file:${sourceFile.id}`
documentVersion: draftVersion ?? `file:${sourceFile.id}`,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true
}
}
function buildMobileFileTab(
state: AppState,
file: AppState['openFiles'][number],
unifiedTabId?: string
unifiedTab?: Tab
): RuntimeMobileSessionFileTab {
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File'
const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined
const unifiedTabId = unifiedTab?.id
return {
type: 'file',
@@ -1371,6 +1390,8 @@ function buildMobileFileTab(
mode: file.mode === 'diff' ? 'diff' : 'edit',
...(diffSource ? { diffSource } : {}),
isDirty: file.isDirty,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
: isFileActiveEditorSurface(state, file)
@@ -1400,12 +1421,13 @@ function isMobileFileDiffSource(
function buildMobileBrowserTab(
state: AppState,
workspace: NonNullable<AppState['browserTabsByWorktree'][string]>[number],
unifiedTabId?: string
unifiedTab?: Tab
): RuntimeMobileSessionBrowserTab {
const pages = state.browserPagesByWorkspace[workspace.id] ?? []
const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null
const title =
activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser'
const unifiedTabId = unifiedTab?.id
return {
type: 'browser',
@@ -1417,6 +1439,8 @@ function buildMobileBrowserTab(
loading: activePage?.loading ?? workspace.loading,
canGoBack: activePage?.canGoBack ?? workspace.canGoBack,
canGoForward: activePage?.canGoForward ?? workspace.canGoForward,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(state, workspace.worktreeId, unifiedTabId)
: state.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspace.id
@@ -1028,6 +1028,38 @@ describe('setWebRuntimeTabProps', () => {
})
})
it('maps mirrored browser/editor unified ids before setting host tab props', async () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID)
mocks.getState.mockReturnValue({})
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
(_state, args: { tabId: string }) =>
args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null
)
const runtimeCall = vi.fn().mockResolvedValue({ id: 'p', ok: true, result: { updated: true } })
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
expect(
setWebRuntimeTabProps({
worktreeId: WORKTREE_ID,
tabId: 'local-browser-unified',
color: '#3b82f6'
})
).toBe(true)
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
expect(runtimeCall).toHaveBeenCalledWith({
selector: ENVIRONMENT_ID,
method: 'session.tabs.setTabProps',
params: {
worktree: `id:${WORKTREE_ID}`,
tabId: 'host-browser-unified',
color: '#3b82f6'
},
timeoutMs: 15_000
})
})
it('no-ops for a worktree with no runtime environment (local tab)', () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null)
+20 -14
View File
@@ -680,20 +680,26 @@ export function setWebRuntimeTabProps(args: {
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
const hostTabId = isWebTerminalSurfaceTabId(args.tabId)
? toHostSessionTabId(args.tabId)
: args.tabId
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'session.tabs.setTabProps',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: hostTabId,
...(args.color !== undefined ? { color: args.color } : {}),
...(args.isPinned !== undefined ? { isPinned: args.isPinned } : {})
},
timeoutMs: 15_000
const state = useAppStore.getState()
void import('./web-session-tabs-sync')
.then(({ resolveHostSessionTabIdForWebSessionTab }) => {
const hostTabId =
resolveHostSessionTabIdForWebSessionTab(state, {
environmentId,
worktreeId: args.worktreeId,
tabId: args.tabId
}) ?? (isWebTerminalSurfaceTabId(args.tabId) ? toHostSessionTabId(args.tabId) : args.tabId)
return window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'session.tabs.setTabProps',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: hostTabId,
...(args.color !== undefined ? { color: args.color } : {}),
...(args.isPinned !== undefined ? { isPinned: args.isPinned } : {})
},
timeoutMs: 15_000
})
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ updated: true }>)
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: these tests cover one reconciliation boundary
* across ready, pending, split, and batched session snapshots. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { posix as pathPosix } from 'path'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
@@ -150,7 +151,12 @@ describe('applyWebSessionTabsSnapshot', () => {
}
// Client closed host-tab-1; an in-flight pre-close snapshot still lists it.
recordWebSessionCloseIntent(WT, 'host-tab-1', NOW)
const stalePreClose = applyWebSessionTabsSnapshot(makeState(), makeSnapshot([surface]), ENV, NOW)
const stalePreClose = applyWebSessionTabsSnapshot(
makeState(),
makeSnapshot([surface]),
ENV,
NOW
)
expect((stalePreClose.tabsByWorktree?.[WT] ?? []).map((tab) => tab.id)).not.toContain(
toWebTerminalSurfaceTabId('host-tab-1')
)
@@ -1867,6 +1873,8 @@ describe('applyWebSessionTabsSnapshot', () => {
loading: false,
canGoBack: true,
canGoForward: false,
color: '#3b82f6',
isPinned: true,
isActive: true
}
],
@@ -1914,7 +1922,9 @@ describe('applyWebSessionTabsSnapshot', () => {
id: 'host-browser-unified',
entityId: 'host-browser-workspace',
contentType: 'browser',
label: 'Example Domain'
label: 'Example Domain',
color: '#3b82f6',
isPinned: true
})
])
)
@@ -2269,7 +2279,9 @@ describe('applyWebSessionTabsSnapshot', () => {
sourceFileId: '/repo/README.md',
sourceFilePath: '/repo/README.md',
sourceRelativePath: 'README.md',
documentVersion: 'draft:1'
documentVersion: 'draft:1',
color: '#16a34a',
isPinned: true
}
],
{ activeTabId: 'host-readme-unified', activeTabType: 'markdown' }
@@ -2297,7 +2309,9 @@ describe('applyWebSessionTabsSnapshot', () => {
id: 'host-readme-unified',
entityId: '/repo/README.md',
contentType: 'editor',
label: 'README.md'
label: 'README.md',
color: '#16a34a',
isPinned: true
})
])
)
@@ -2311,6 +2325,142 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.activeTabTypeByWorktree?.[WT]).toBe('editor')
})
it('applies host-cleared browser and editor tab props over existing mirrored state', () => {
const workspace: BrowserWorkspace = {
id: 'local-browser-workspace',
worktreeId: WT,
activePageId: 'local-browser-page',
pageIds: ['local-browser-page'],
url: 'https://example.com/',
title: 'Example Domain',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: NOW - 10
}
const page: BrowserPage = {
id: 'local-browser-page',
workspaceId: workspace.id,
worktreeId: WT,
url: 'https://example.com/',
title: 'Example Domain',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: NOW - 10
}
const readmePath = pathPosix.join('/repo', 'README.md')
const file: OpenFile = {
id: readmePath,
filePath: readmePath,
relativePath: 'README.md',
worktreeId: WT,
language: 'markdown',
isDirty: false,
runtimeEnvironmentId: ENV,
mode: 'edit'
}
const existingTabs: Tab[] = [
{
id: 'local-browser-unified',
entityId: workspace.id,
groupId: 'host-group-1',
worktreeId: WT,
contentType: 'browser',
label: 'Example Domain',
customLabel: null,
color: '#3b82f6',
sortOrder: 0,
createdAt: NOW - 10,
isPreview: false,
isPinned: true
},
{
id: 'host-readme-unified',
entityId: file.id,
groupId: 'host-group-1',
worktreeId: WT,
contentType: 'editor',
label: 'README.md',
customLabel: null,
color: '#16a34a',
sortOrder: 1,
createdAt: NOW - 9,
isPreview: false,
isPinned: true
}
]
const patch = applyWebSessionTabsSnapshot(
makeState({
browserTabsByWorktree: { [WT]: [workspace] },
browserPagesByWorkspace: { [workspace.id]: [page] },
remoteBrowserPageHandlesByPageId: {
[page.id]: { environmentId: ENV, remotePageId: 'host-browser-page' }
},
openFiles: [file],
unifiedTabsByWorktree: { [WT]: existingTabs }
}),
makeSnapshot(
[
{
type: 'browser',
id: 'host-browser-unified',
title: 'Example Domain',
browserWorkspaceId: 'host-browser-workspace',
browserPageId: 'host-browser-page',
url: 'https://example.com/',
loading: false,
canGoBack: false,
canGoForward: false,
color: null,
isPinned: false,
isActive: false
},
{
type: 'markdown',
id: 'host-readme-unified',
title: 'README.md',
filePath: readmePath,
relativePath: 'README.md',
language: 'markdown',
mode: 'edit',
isDirty: false,
isActive: true,
sourceFileId: readmePath,
sourceFilePath: readmePath,
sourceRelativePath: 'README.md',
documentVersion: `file:${readmePath}`,
color: null,
isPinned: false
}
],
{ activeTabId: 'host-readme-unified', activeTabType: 'markdown' }
),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
expect(patch.unifiedTabsByWorktree?.[WT]).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'local-browser-unified',
color: null,
isPinned: false
}),
expect.objectContaining({
id: 'host-readme-unified',
color: null,
isPinned: false
})
])
)
})
it('uses local markdown preview file ids while preserving the host unified tab id', () => {
const patch = applyWebSessionTabsSnapshot(
makeState(),
@@ -673,26 +673,36 @@ function buildTerminalUnifiedTab(tab: TerminalTab, groupId: string): Tab {
}
}
function buildBrowserUnifiedTab(tab: BrowserWorkspace, unifiedTabId: string, groupId: string): Tab {
function buildBrowserUnifiedTab(
tab: BrowserWorkspace,
hostTab: RuntimeMobileSessionBrowserTab,
existingUnifiedTab: Tab | null,
groupId: string
): Tab {
return {
id: unifiedTabId,
id: existingUnifiedTab?.id ?? hostTab.id,
entityId: tab.id,
groupId,
worktreeId: tab.worktreeId,
contentType: 'browser',
label: tab.title,
customLabel: null,
color: null,
color: hostTab.color !== undefined ? hostTab.color : (existingUnifiedTab?.color ?? null),
sortOrder: tab.createdAt,
createdAt: tab.createdAt,
isPreview: false,
isPinned: false
isPinned:
hostTab.isPinned !== undefined
? hostTab.isPinned === true
: existingUnifiedTab?.isPinned === true
}
}
function buildEditorUnifiedTab(
file: OpenFile,
tab: ReadyEditorSurface,
hostTabId: string,
existingUnifiedTab: Tab | null,
label: string,
groupId: string,
sortOrder: number,
@@ -706,11 +716,12 @@ function buildEditorUnifiedTab(
contentType: 'editor',
label,
customLabel: null,
color: null,
color: tab.color !== undefined ? tab.color : (existingUnifiedTab?.color ?? null),
sortOrder,
createdAt,
isPreview: false,
isPinned: false
isPinned:
tab.isPinned !== undefined ? tab.isPinned === true : existingUnifiedTab?.isPinned === true
}
}
@@ -769,7 +780,9 @@ function buildMirroredEditorTabs(
hostTabId: tab.id,
unifiedTab: buildEditorUnifiedTab(
file,
tab,
tab.id,
existingUnifiedTab,
tab.title.trim() || tab.relativePath || 'File',
groupId,
sortOffset + index,
@@ -871,7 +884,7 @@ function buildMirroredBrowserTabs(
workspace,
page,
remotePageId: tab.browserPageId,
unifiedTab: buildBrowserUnifiedTab(workspace, existing?.unifiedTab?.id ?? tab.id, groupId),
unifiedTab: buildBrowserUnifiedTab(workspace, tab, existing?.unifiedTab ?? null, groupId),
hostTabId: tab.id
}
})
+6
View File
@@ -152,6 +152,9 @@ export type RuntimeMobileSessionMarkdownTab = {
sourceFilePath: string
sourceRelativePath: string
documentVersion: string
/** Tab-level color/pin, host-persisted for remote servers. */
color?: string | null
isPinned?: boolean
}
export type RuntimeMobileSessionFileTab = {
@@ -164,6 +167,9 @@ export type RuntimeMobileSessionFileTab = {
mode?: 'edit' | 'diff'
diffSource?: 'staged' | 'unstaged'
isDirty: boolean
/** Tab-level color/pin, host-persisted for remote servers. */
color?: string | null
isPinned?: boolean
isActive: boolean
}