mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Fix remote browser/editor tab props persistence (#5847)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user