diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1c5f6e66a84..5f9e0258775 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7e5a7db8c87..56591eb938f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 | 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). diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 6068766a724..08509c65b5d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -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({ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 17e4df7e8f4..d4ffd666fdb 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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, 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[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 diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 833cfdbce1f..429412c52ce 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -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) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index de901114666..d6098debe5b 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -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 }>) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index c6c334c5e25..bd7b9e7c92b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -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 + + 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(), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index c34ab7f5f7c..b0833485067 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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 } }) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 4782f673d8d..a3b534470f2 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 }