diff --git a/src/main/runtime/headless-tab-order-stability.test.ts b/src/main/runtime/headless-tab-order-stability.test.ts new file mode 100644 index 00000000000..d131a2c2b58 --- /dev/null +++ b/src/main/runtime/headless-tab-order-stability.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileSessionTerminalTab +} from '../../shared/runtime-types' + +const WT = 'repo-1::/home/orca/worktree' +const GROUP = `headless-terminals:${WT}` + +const terminalTab = (n: number, isActive = false): RuntimeMobileSessionTerminalTab => ({ + type: 'terminal', + id: `tab-${n}::leaf-${n}`, + title: `terminal ${n}`, + parentTabId: `tab-${n}`, + leafId: `leaf-${n}`, + ptyId: `pty-${n}`, + isActive +}) + +const snapshotOf = ( + tabs: RuntimeMobileSessionSnapshotTab[], + tabOrder: string[], + activeTabId: string | null +): RuntimeMobileSessionTabsSnapshot => ({ + worktree: WT, + publicationEpoch: 'headless:seed', + snapshotVersion: 1, + activeGroupId: GROUP, + activeTabId, + activeTabType: 'terminal', + tabGroups: [{ id: GROUP, activeTabId: activeTabId?.split('::')[0] ?? null, tabOrder }], + tabs +}) + +describe('headless tab order stability', () => { + it('retains order when activating a re-appended surface', () => { + const runtime = new OrcaRuntimeService(null) as unknown as { + mobileSessionTabsByWorktree: Map + activateHeadlessMobileSessionTerminalTab: ( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + activeTab: RuntimeMobileSessionTerminalTab + ) => void + emitMobileSessionTabsSnapshot: (snapshot: RuntimeMobileSessionTabsSnapshot) => void + persistHeadlessTerminalActiveLeaf: (...args: unknown[]) => void + } + runtime.emitMobileSessionTabsSnapshot = () => {} + runtime.persistHeadlessTerminalActiveLeaf = () => {} + + const tabs = [terminalTab(1), terminalTab(3), terminalTab(4), terminalTab(2, true)] + const snapshot = snapshotOf(tabs, ['tab-1', 'tab-2', 'tab-3', 'tab-4'], 'tab-2::leaf-2') + runtime.mobileSessionTabsByWorktree.set(WT, snapshot) + + runtime.activateHeadlessMobileSessionTerminalTab(WT, snapshot, tabs[3]!) + + expect(runtime.mobileSessionTabsByWorktree.get(WT)?.tabGroups?.[0]?.tabOrder).toEqual([ + 'tab-1', + 'tab-2', + 'tab-3', + 'tab-4' + ]) + }) + + it('retains stored order when a materialized surface is re-appended', () => { + const runtime = new OrcaRuntimeService(null) as unknown as { + mergeMobileSessionTabGroups: ( + worktreeId: string, + groups: { id: string; activeTabId: string | null; tabOrder: string[] }[], + terminalTabs: RuntimeMobileSessionTerminalTab[], + activeTab: RuntimeMobileSessionTerminalTab | null + ) => { id: string; tabOrder: string[] }[] + } + const reappended = [terminalTab(1), terminalTab(3), terminalTab(4), terminalTab(2, true)] + const merged = runtime.mergeMobileSessionTabGroups( + WT, + [{ id: GROUP, activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-2', 'tab-3', 'tab-4'] }], + reappended, + reappended[3]! + ) + expect(merged[0]!.tabOrder).toEqual(['tab-1', 'tab-2', 'tab-3', 'tab-4']) + }) + + it('appends only genuinely new tabs after retained order', () => { + const runtime = new OrcaRuntimeService(null) as unknown as { + mergeMobileSessionTabGroups: ( + worktreeId: string, + groups: { id: string; activeTabId: string | null; tabOrder: string[] }[], + terminalTabs: RuntimeMobileSessionTerminalTab[], + activeTab: RuntimeMobileSessionTerminalTab | null + ) => { id: string; tabOrder: string[] }[] + } + const tabs = [terminalTab(3), terminalTab(1), terminalTab(5, true)] + const merged = runtime.mergeMobileSessionTabGroups( + WT, + [{ id: GROUP, activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-2', 'tab-3'] }], + tabs, + tabs[2]! + ) + expect(merged[0]!.tabOrder).toEqual(['tab-1', 'tab-3', 'tab-5']) + }) + + it('retains order independently in split groups', () => { + const runtime = new OrcaRuntimeService(null) as unknown as { + buildHeadlessMobileSessionTabGroups: ( + worktreeId: string, + tabs: RuntimeMobileSessionSnapshotTab[], + activeTab: RuntimeMobileSessionSnapshotTab | null, + existingGroups?: RuntimeMobileSessionTabsSnapshot['tabGroups'] + ) => RuntimeMobileSessionTabsSnapshot['tabGroups'] + } + const tabs = [terminalTab(2), terminalTab(1), terminalTab(4), terminalTab(3)] + const groups = runtime.buildHeadlessMobileSessionTabGroups(WT, tabs, tabs[0]!, [ + { id: 'left', activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-2'] }, + { id: 'right', activeTabId: 'tab-3', tabOrder: ['tab-3', 'tab-4'] } + ]) + expect(groups?.find((group) => group.id === 'left')?.tabOrder).toEqual(['tab-1', 'tab-2']) + expect(groups?.find((group) => group.id === 'right')?.tabOrder).toEqual(['tab-3', 'tab-4']) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3073aeb84ba..737a5a1998d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -8566,9 +8566,25 @@ export class OrcaRuntimeService { const activeGroupId = (activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id const retainedOrder = new Map(nextGroups.map((group) => [group.id, []])) + // Why: tabOrder is the canonical user-visible order, so it must survive a republish. + // A materialized idle surface can move to the end of terminalTabs; retaining the + // stored order prevents activation from rotating the tab bar. + const placed = new Set() + for (const group of nextGroups) { + for (const tabId of group.tabOrder) { + if (liveTabIds.has(tabId) && !placed.has(tabId)) { + retainedOrder.get(group.id)?.push(tabId) + placed.add(tabId) + } + } + } for (const tabId of parentTabOrder) { + if (placed.has(tabId)) { + continue + } const groupId = ownerGroupId.get(tabId) ?? activeGroupId retainedOrder.get(groupId)?.push(tabId) + placed.add(tabId) } return nextGroups .map((group) => { @@ -9367,7 +9383,27 @@ export class OrcaRuntimeService { ): RuntimeMobileSessionTabGroup[] { // Why: order across terminals and browsers in their actual array order so a // tab opened after a browser tab lands to its right, not regrouped before it. - const tabOrder = this.collectHeadlessTopLevelTabOrder(tabs) + const arrivalOrder = this.collectHeadlessTopLevelTabOrder(tabs) + // Why: tabOrder is the user-visible order and must survive a republish. A + // materialized idle surface can move to the end of the incoming array, so + // retain stored positions and append only genuinely new ids. + const liveTopLevelIds = new Set(arrivalOrder) + const tabOrder: string[] = [] + const placed = new Set() + for (const group of existingGroups ?? []) { + for (const tabId of group.tabOrder) { + if (liveTopLevelIds.has(tabId) && !placed.has(tabId)) { + tabOrder.push(tabId) + placed.add(tabId) + } + } + } + for (const tabId of arrivalOrder) { + if (!placed.has(tabId)) { + tabOrder.push(tabId) + placed.add(tabId) + } + } const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string => tab.type === 'terminal' ? tab.parentTabId : tab.id const activeTopLevelId = diff --git a/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts index 91705cb5314..401a4300f9f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts @@ -540,6 +540,84 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.activeTabId).toBe(mirroredId) }) + it('retains a known title while a pending surface reports its placeholder', () => { + const mirroredId = toWebTerminalSurfaceTabId('host-tab-1') + const priorPtyId = 'remote:web-env-1@@terminal-1' + const existingTab: TerminalTab = { + id: mirroredId, + ptyId: priorPtyId, + worktreeId: WT, + title: 'pnpm dev', + defaultTitle: 'pnpm dev', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [existingTab] }, + ptyIdsByTabId: { [mirroredId]: [priorPtyId] } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Terminal', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'pending-handle', + terminal: null + } + ]), + ENV, + NOW + 1 + ) as Partial + + expect(patch.tabsByWorktree?.[WT]?.[0]?.title).toBe('pnpm dev') + }) + + it('adopts a real title after a pending surface becomes ready', () => { + const mirroredId = toWebTerminalSurfaceTabId('host-tab-1') + const priorPtyId = 'remote:web-env-1@@terminal-1' + const existingTab: TerminalTab = { + id: mirroredId, + ptyId: priorPtyId, + worktreeId: WT, + title: 'pnpm dev', + defaultTitle: 'pnpm dev', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [existingTab] }, + ptyIdsByTabId: { [mirroredId]: [priorPtyId] } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'gal@host: ~/dev', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]), + ENV, + NOW + 1 + ) as Partial + + expect(patch.tabsByWorktree?.[WT]?.[0]?.title).toBe('gal@host: ~/dev') + }) + it('retains the exact prior pane binding while a mirrored surface is pending', () => { const mirroredId = toWebTerminalSurfaceTabId('host-tab-1') const priorPtyId = 'remote:web-env-1@@terminal-1' diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 8df42a59935..4d7070c8368 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1480,17 +1480,23 @@ function buildMirroredTerminalTabs( siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus ?.agentType }) - const title = normalizeCompatibleAgentTitleForOwner( - activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal', - ownerRecord?.agent, - { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } - ) const existing = existingById.get(localTabId) ?? existingById.get(parentTabId) ?? surfaces .map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id))) .find((tab): tab is TerminalTab => Boolean(tab)) + // Why: a headless host publishes the literal "Terminal" while an idle pane + // has no live PTY. Keep the client's known title until a ready surface reports one. + const hostTitle = activeSurface.title.trim() || surfaces[0]?.title.trim() || '' + const hostTitleIsPlaceholder = + hostTitle === '' || (activeSurface.status === 'pending-handle' && hostTitle === 'Terminal') + const retainedTitle = existing?.title?.trim() || existing?.defaultTitle?.trim() || '' + const title = normalizeCompatibleAgentTitleForOwner( + (hostTitleIsPlaceholder ? retainedTitle || hostTitle : hostTitle) || 'Terminal', + ownerRecord?.agent, + { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } + ) const quickCommandLabel = activeSurface.quickCommandLabel?.trim() || surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() ||