Fix serve-created mobile terminals on desktop
* Fix serve-created mobile terminals on desktop Co-authored-by: Orca <help@stably.ai> * Address headless mobile terminal merge review Co-authored-by: Orca <help@stably.ai> * Document headless mobile merge invariants Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3d013e48e8
commit
e35deb9720
|
|
@ -7020,6 +7020,270 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
})
|
||||
runtime.syncWindowGraph(0, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer-empty',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listed.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
id: created.tab.id,
|
||||
parentTabId: created.tab.parentTabId,
|
||||
leafId: created.tab.leafId,
|
||||
ptyId: 'serve-mobile-pty',
|
||||
status: 'ready'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.syncWindowGraph(0, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'headless:split-siblings',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'headless-group',
|
||||
activeTabId: 'host-tab::pane:2',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'headless-group',
|
||||
activeTabId: 'host-tab',
|
||||
tabOrder: ['host-tab']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'host-tab::pane:1',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'pane:1',
|
||||
title: 'left',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'host-tab::pane:2',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'pane:2',
|
||||
title: 'right',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
runtime.syncWindowGraph(0, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer-empty',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listed.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
id: 'host-tab::pane:1',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'pane:1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
id: 'host-tab::pane:2',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'pane:2'
|
||||
})
|
||||
])
|
||||
expect(listed.activeTabId).toBe('host-tab::pane:2')
|
||||
})
|
||||
|
||||
it('keeps preserved headless mobile session publication epochs idempotent', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.syncWindowGraph(0, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'headless:stable-epoch',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'host-tab::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'host-tab::pane:1',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
const firstMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch)
|
||||
expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hydrates persisted serve-owned mobile session terminals while a renderer is attached', async () => {
|
||||
const focusTerminal = vi.fn()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'serve-persisted-pty', isReattach: true })
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: 'serve-persisted-pty',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Persisted Mobile Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-persisted-pty' })
|
||||
}
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal,
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer-empty',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(listed.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
id: `host-tab::${HEADLESS_LEAF_ID}`,
|
||||
parentTabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
ptyId: 'serve-persisted-pty',
|
||||
status: 'pending-handle'
|
||||
})
|
||||
])
|
||||
expect(listed.tabGroups?.[0]).toMatchObject({
|
||||
activeTabId: 'host-tab',
|
||||
tabOrder: ['host-tab']
|
||||
})
|
||||
|
||||
const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
sessionId: 'serve-persisted-pty',
|
||||
persistHostSessionBinding: true,
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
)
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
expect(activated.tabs[0]).toMatchObject({
|
||||
type: 'terminal',
|
||||
parentTabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
status: 'ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates legacy persisted terminal tabs without layout entries', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ import type {
|
|||
RuntimeMobileSessionTabMove,
|
||||
RuntimeMobileSessionTabMoveResult,
|
||||
RuntimeMobileSessionTabGroup,
|
||||
RuntimeMobileSessionSnapshotTab,
|
||||
RuntimeMobileSessionTerminalTab,
|
||||
RuntimeMobileSessionTabsRemovedResult,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
|
|
@ -2127,9 +2128,13 @@ export class OrcaRuntimeService {
|
|||
|
||||
private hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
|
||||
worktreeId?: string,
|
||||
options: { force?: boolean } = {}
|
||||
options: {
|
||||
force?: boolean
|
||||
allowAttachedWindow?: boolean
|
||||
onlyServeOwnedTerminals?: boolean
|
||||
} = {}
|
||||
): void {
|
||||
if (this.getAvailableAuthoritativeWindow()) {
|
||||
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
|
||||
return
|
||||
}
|
||||
const session = this.store?.getWorkspaceSession?.()
|
||||
|
|
@ -2142,35 +2147,150 @@ export class OrcaRuntimeService {
|
|||
: Object.entries(session.tabsByWorktree ?? {})
|
||||
for (const [entryWorktreeId, persistedTabs] of entries) {
|
||||
const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId)
|
||||
if (existing && existing.tabs.length > 0 && options.force !== true) {
|
||||
if (
|
||||
existing &&
|
||||
existing.tabs.length > 0 &&
|
||||
options.force !== true &&
|
||||
options.onlyServeOwnedTerminals !== true
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const tabs = this.buildHeadlessMobileSessionTerminalTabs(entryWorktreeId, persistedTabs)
|
||||
const tabs = this.buildHeadlessMobileSessionTerminalTabs(
|
||||
entryWorktreeId,
|
||||
persistedTabs
|
||||
).filter(
|
||||
(tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab)
|
||||
)
|
||||
if (tabs.length === 0) {
|
||||
continue
|
||||
}
|
||||
const activeTab = this.pickHeadlessActiveTerminalTab(tabs)
|
||||
const tabOrder = this.collectHeadlessParentTabOrder(tabs)
|
||||
const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId)
|
||||
const mergedTabs =
|
||||
options.onlyServeOwnedTerminals === true && existing
|
||||
? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs)
|
||||
: tabs
|
||||
const mergedActiveTab =
|
||||
existing?.tabs.find((tab) => tab.id === existing.activeTabId) ??
|
||||
activeTab ??
|
||||
(mergedTabs[0]?.type === 'terminal' ? mergedTabs[0] : null)
|
||||
const mergedTerminalTabs = mergedTabs.filter(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
|
||||
)
|
||||
this.mobileSessionTabsByWorktree.set(entryWorktreeId, {
|
||||
worktree: entryWorktreeId,
|
||||
worktree: existing?.worktree ?? entryWorktreeId,
|
||||
publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`,
|
||||
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
|
||||
activeGroupId: groupId,
|
||||
activeTabId: activeTab?.id ?? null,
|
||||
activeTabType: activeTab ? 'terminal' : null,
|
||||
tabGroups: [
|
||||
{
|
||||
id: groupId,
|
||||
activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null,
|
||||
tabOrder
|
||||
}
|
||||
],
|
||||
tabs
|
||||
activeGroupId: existing?.activeGroupId ?? groupId,
|
||||
activeTabId: mergedActiveTab?.id ?? null,
|
||||
activeTabType: mergedActiveTab?.type ?? null,
|
||||
tabGroups:
|
||||
options.onlyServeOwnedTerminals === true && existing?.tabGroups
|
||||
? this.mergeMobileSessionTabGroups(
|
||||
entryWorktreeId,
|
||||
existing.tabGroups,
|
||||
mergedTerminalTabs,
|
||||
mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null
|
||||
)
|
||||
: [
|
||||
{
|
||||
id: groupId,
|
||||
activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null,
|
||||
tabOrder
|
||||
}
|
||||
],
|
||||
tabs: mergedTabs
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private isServeOwnedPtyId(ptyId: string | null | undefined): boolean {
|
||||
return typeof ptyId === 'string' && ptyId.startsWith('serve-')
|
||||
}
|
||||
|
||||
private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean {
|
||||
if (this.isServeOwnedPtyId(tab.ptyId)) {
|
||||
return true
|
||||
}
|
||||
return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) =>
|
||||
this.isServeOwnedPtyId(ptyId)
|
||||
)
|
||||
}
|
||||
|
||||
private mergeMobileSessionSnapshotTabs(
|
||||
baseTabs: readonly RuntimeMobileSessionSnapshotTab[],
|
||||
extraTabs: readonly RuntimeMobileSessionSnapshotTab[]
|
||||
): RuntimeMobileSessionSnapshotTab[] {
|
||||
const seenIds = new Set<string>()
|
||||
const merged: RuntimeMobileSessionSnapshotTab[] = []
|
||||
const add = (tab: RuntimeMobileSessionSnapshotTab): void => {
|
||||
const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab)
|
||||
if (ids.some((id) => seenIds.has(id))) {
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
seenIds.add(id)
|
||||
}
|
||||
merged.push(tab)
|
||||
}
|
||||
for (const tab of baseTabs) {
|
||||
add(tab)
|
||||
}
|
||||
for (const tab of extraTabs) {
|
||||
add(tab)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] {
|
||||
if (tab.type === 'terminal') {
|
||||
// Why: split terminal leaves share one parent tab; merge dedup must stay
|
||||
// leaf-scoped or preserved siblings collapse into a single surface.
|
||||
return [tab.id, `${tab.parentTabId}::${tab.leafId}`]
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
return [tab.id, tab.browserWorkspaceId]
|
||||
}
|
||||
return [tab.id]
|
||||
}
|
||||
|
||||
private mergeMobileSessionTabGroups(
|
||||
worktreeId: string,
|
||||
groups: readonly RuntimeMobileSessionTabGroup[],
|
||||
terminalTabs: readonly RuntimeMobileSessionTerminalTab[],
|
||||
activeTab: RuntimeMobileSessionTerminalTab | null
|
||||
): RuntimeMobileSessionTabGroup[] {
|
||||
const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs)
|
||||
if (parentTabOrder.length === 0) {
|
||||
return [...groups]
|
||||
}
|
||||
const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId)
|
||||
const nextGroups =
|
||||
groups.length > 0
|
||||
? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
|
||||
: [
|
||||
{
|
||||
id: targetGroupId,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
}
|
||||
]
|
||||
const target = nextGroups[0]!
|
||||
for (const tabId of parentTabOrder) {
|
||||
if (!target.tabOrder.includes(tabId)) {
|
||||
target.tabOrder.push(tabId)
|
||||
}
|
||||
}
|
||||
const activeParentId =
|
||||
activeTab?.parentTabId ?? target.activeTabId ?? target.tabOrder[0] ?? null
|
||||
target.activeTabId =
|
||||
activeParentId && target.tabOrder.includes(activeParentId)
|
||||
? activeParentId
|
||||
: (target.tabOrder[0] ?? null)
|
||||
return nextGroups
|
||||
}
|
||||
|
||||
private buildHeadlessMobileSessionTerminalTabs(
|
||||
worktreeId: string,
|
||||
persistedTabs: readonly TerminalTab[]
|
||||
|
|
@ -2468,11 +2588,14 @@ export class OrcaRuntimeService {
|
|||
const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.id === tab.id
|
||||
)
|
||||
if (
|
||||
// Why: serve-created tabs can be visible before any renderer has adopted
|
||||
// their tab id, so focusing the renderer would silently no-op.
|
||||
const shouldMaterializePendingTerminal =
|
||||
publicTab?.type === 'terminal' &&
|
||||
publicTab.status !== 'ready' &&
|
||||
!this.notifier?.focusTerminal
|
||||
) {
|
||||
(!this.notifier?.focusTerminal ||
|
||||
this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))
|
||||
if (shouldMaterializePendingTerminal) {
|
||||
const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined
|
||||
try {
|
||||
await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, {
|
||||
|
|
@ -2511,6 +2634,16 @@ export class OrcaRuntimeService {
|
|||
return this.getMobileSessionTabsForWorktree(worktreeId)
|
||||
}
|
||||
|
||||
private shouldMaterializeHeadlessMobileSessionTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
return (
|
||||
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
|
||||
this.hasServeOwnedPtyBinding(tab)
|
||||
)
|
||||
}
|
||||
|
||||
async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> {
|
||||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
|
|
@ -12287,26 +12420,177 @@ export class OrcaRuntimeService {
|
|||
if (snapshots === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: renderer graphs are authoritative for renderer tabs, but headless
|
||||
// serve terminals never enter that graph unless we preserve their bindings.
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, {
|
||||
allowAttachedWindow: true,
|
||||
onlyServeOwnedTerminals: true
|
||||
})
|
||||
const nextWorktrees = new Set<string>()
|
||||
for (const snapshot of snapshots) {
|
||||
nextWorktrees.add(snapshot.worktree)
|
||||
const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree)
|
||||
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing)
|
||||
if (
|
||||
!existing ||
|
||||
snapshot.publicationEpoch !== existing.publicationEpoch ||
|
||||
snapshot.snapshotVersion >= existing.snapshotVersion
|
||||
nextSnapshot.publicationEpoch !== existing.publicationEpoch ||
|
||||
nextSnapshot.snapshotVersion >= existing.snapshotVersion
|
||||
) {
|
||||
this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
|
||||
this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot)
|
||||
}
|
||||
}
|
||||
for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) {
|
||||
for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) {
|
||||
if (!nextWorktrees.has(worktreeId)) {
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.notifyMobileSessionTabsRemoved(worktreeId)
|
||||
const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing)
|
||||
if (preserved) {
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, preserved)
|
||||
nextWorktrees.add(worktreeId)
|
||||
} else {
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.notifyMobileSessionTabsRemoved(worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergePreservedHeadlessMobileSessionTabs(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
existing: RuntimeMobileSessionTabsSnapshot | undefined
|
||||
): RuntimeMobileSessionTabsSnapshot {
|
||||
if (!existing) {
|
||||
return snapshot
|
||||
}
|
||||
const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot)
|
||||
if (preservedTabs.length === 0) {
|
||||
return snapshot
|
||||
}
|
||||
const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive)
|
||||
const normalizedPreservedTabs = preservedTabs.map((tab) =>
|
||||
hasIncomingActiveTab ? { ...tab, isActive: false } : tab
|
||||
)
|
||||
const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs)
|
||||
if (tabs.length === snapshot.tabs.length) {
|
||||
return snapshot
|
||||
}
|
||||
const activeTab =
|
||||
snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ??
|
||||
tabs.find((tab) => tab.id === existing.activeTabId) ??
|
||||
tabs.find((tab) => tab.isActive) ??
|
||||
tabs[0] ??
|
||||
null
|
||||
const terminalTabs = tabs.filter(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
|
||||
)
|
||||
return {
|
||||
...snapshot,
|
||||
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(
|
||||
snapshot,
|
||||
normalizedPreservedTabs
|
||||
),
|
||||
snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion),
|
||||
activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId,
|
||||
activeTabId: activeTab?.id ?? null,
|
||||
activeTabType: activeTab?.type ?? null,
|
||||
tabGroups: this.mergeMobileSessionTabGroups(
|
||||
snapshot.worktree,
|
||||
snapshot.tabGroups ?? existing.tabGroups ?? [],
|
||||
terminalTabs,
|
||||
activeTab?.type === 'terminal' ? activeTab : null
|
||||
),
|
||||
tabs
|
||||
}
|
||||
}
|
||||
|
||||
private buildPreservedHeadlessMobileSessionSnapshot(
|
||||
existing: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionTabsSnapshot | null {
|
||||
const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing)
|
||||
if (tabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
const activeTab =
|
||||
tabs.find((tab) => tab.id === existing.activeTabId) ??
|
||||
tabs.find((tab) => tab.isActive) ??
|
||||
tabs[0] ??
|
||||
null
|
||||
const terminalTabs = tabs.filter(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
|
||||
)
|
||||
return {
|
||||
...existing,
|
||||
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs),
|
||||
activeGroupId:
|
||||
existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree),
|
||||
activeTabId: activeTab?.id ?? null,
|
||||
activeTabType: activeTab?.type ?? null,
|
||||
tabGroups: this.mergeMobileSessionTabGroups(
|
||||
existing.worktree,
|
||||
existing.tabGroups ?? [],
|
||||
terminalTabs,
|
||||
activeTab?.type === 'terminal' ? activeTab : null
|
||||
),
|
||||
tabs
|
||||
}
|
||||
}
|
||||
|
||||
private collectPreservedHeadlessMobileSessionTabs(
|
||||
existing: RuntimeMobileSessionTabsSnapshot,
|
||||
incoming?: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionSnapshotTab[] {
|
||||
const incomingIds = new Set(
|
||||
incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? []
|
||||
)
|
||||
return existing.tabs.filter((tab) => {
|
||||
if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) {
|
||||
return false
|
||||
}
|
||||
return this.shouldPreserveHeadlessMobileSessionTab(existing, tab)
|
||||
})
|
||||
}
|
||||
|
||||
private shouldPreserveHeadlessMobileSessionTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionSnapshotTab
|
||||
): boolean {
|
||||
if (tab.type !== 'terminal') {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
|
||||
this.hasServeOwnedPtyBinding(tab)
|
||||
)
|
||||
}
|
||||
|
||||
private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
|
||||
return (
|
||||
publicationEpoch.startsWith('headless:') ||
|
||||
publicationEpoch.startsWith('headless-hydrated:') ||
|
||||
publicationEpoch.includes(':headless-merge:')
|
||||
)
|
||||
}
|
||||
|
||||
private getMergedMobileSessionPublicationEpoch(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
preservedTabs: readonly RuntimeMobileSessionSnapshotTab[]
|
||||
): string {
|
||||
// Why: preserved snapshots can be merged repeatedly; normalize the prior
|
||||
// merge suffix before recomputing so the publication epoch is idempotent.
|
||||
const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0]
|
||||
const signature = createHash('sha1')
|
||||
.update(
|
||||
preservedTabs
|
||||
.map((tab) =>
|
||||
tab.type === 'terminal'
|
||||
? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}`
|
||||
: tab.id
|
||||
)
|
||||
.join('|')
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
return `${normalizedPublicationEpoch}:headless-merge:${signature}`
|
||||
}
|
||||
|
||||
private notifyMobileSessionTabsRemoved(worktreeId: string): void {
|
||||
const removed: RuntimeMobileSessionTabsRemovedResult = {
|
||||
worktree: worktreeId,
|
||||
|
|
|
|||
Loading…
Reference in New Issue