mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(tabs): retain headless order and placeholder titles
This commit is contained in:
@@ -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<string, RuntimeMobileSessionTabsSnapshot>
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -8566,9 +8566,25 @@ export class OrcaRuntimeService {
|
||||
const activeGroupId =
|
||||
(activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id
|
||||
const retainedOrder = new Map<string, string[]>(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<string>()
|
||||
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<string>()
|
||||
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 =
|
||||
|
||||
@@ -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<WebSessionTabsSyncState>
|
||||
|
||||
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<WebSessionTabsSyncState>
|
||||
|
||||
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'
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
Reference in New Issue
Block a user