Files
orca/src/main/runtime/runtime-mobile-session-projection.ts
T

325 lines
15 KiB
TypeScript

import {
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../shared/agent-title-owner'
import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner'
import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types'
import type {
RuntimeMobileSessionClientTab,
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../shared/runtime-types'
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
import { resolveTerminalTabTitle } from '../../shared/tab-title-resolution'
import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index'
import {
renewRuntimeMobileAgentStatusFromPtyTitle,
selectRuntimeHookAgentRowForPane
} from './runtime-mobile-agent-status-projection'
import { finalizeRuntimeMobileSessionTabsResult } from './runtime-mobile-session-result-finalization'
import { withStructuredChatStatus } from './runtime-mobile-structured-tab-status'
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
import {
getLatestAgentCandidateTitle,
getLeafDisplayRecord,
getPtyDisplayRecord,
terminalTitleBlocksExplicitAgentStatus
} from './runtime-worktree-status-projection'
export function projectRuntimeMobileSessionTabs(
snapshot: RuntimeMobileSessionTabsSnapshot,
host: RuntimeMobileSessionProjectionHost
): RuntimeMobileSessionTabsResult {
const tabs: RuntimeMobileSessionClientTab[] = []
const liveBrowserTabsByPageId = host.getLiveBrowserTabs(snapshot.worktree)
// Production reads hook rows by pane; the snapshot fallback remains for tests
// and embedders that have not adopted the narrow getter.
let hookRowsByPaneKey: Map<string, AgentStatusIpcPayload[]> | null = null
const hookRowsForPane = new Map<string, AgentStatusIpcPayload[]>()
const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => {
const cached = hookRowsForPane.get(paneKey)
if (cached) {
return cached
}
const direct = host.getProviderSessionRows(paneKey)
if (direct) {
hookRowsForPane.set(paneKey, direct)
return direct
}
hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot())
const rows = hookRowsByPaneKey.get(paneKey) ?? []
hookRowsForPane.set(paneKey, rows)
return rows
}
let statusRowsByPaneKey: Map<string, AgentStatusIpcPayload[]> | null = null
let statusRowsByTerminalHandle: Map<string, AgentStatusIpcPayload[]> | null = null
const getStatusRows = (
paneKey: string,
terminalHandle: string | null
): AgentStatusIpcPayload[] => {
if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) {
statusRowsByPaneKey = new Map()
statusRowsByTerminalHandle = new Map()
for (const row of host.getStatusSnapshot()) {
const paneRows = statusRowsByPaneKey.get(row.paneKey)
if (paneRows) {
paneRows.push(row)
} else {
statusRowsByPaneKey.set(row.paneKey, [row])
}
if (row.terminalHandle) {
const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle)
if (handleRows) {
handleRows.push(row)
} else {
statusRowsByTerminalHandle.set(row.terminalHandle, [row])
}
}
}
}
const paneRows = statusRowsByPaneKey.get(paneKey) ?? []
if (!terminalHandle) {
return paneRows
}
const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? []
if (paneRows.length === 0) {
return handleRows
}
return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))]
}
// Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client.
const claimedLivePtyIds = new Set<string>()
for (const tab of snapshot.tabs) {
if (tab.type === 'browser') {
const liveTab = tab.browserPageId ? liveBrowserTabsByPageId.get(tab.browserPageId) : undefined
if (!liveTab) {
continue
}
// Why: renderer snapshots lag BrowserView teardown/process swaps; only surface pages the browser bridge can still route to.
tabs.push({
...tab,
title: liveTab.title || tab.title,
url: liveTab.url || tab.url,
// Why: bridge "active" means active BrowserView/webContents, not active Orca tab; preserve the renderer's session focus.
isActive: tab.isActive
})
continue
}
if (tab.type === 'markdown' || tab.type === 'file') {
tabs.push(tab)
continue
}
if (tab.type === 'agent-session') {
tabs.push(withStructuredChatStatus(tab, snapshot.worktree, getHookRowsForPane))
continue
}
const syncedTab = host.tabs.get(tab.parentTabId)
const leaf = host.leaves.get(host.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null
const liveLeafPtyId = liveLeaf?.ptyId ?? null
const liveLeafPty = liveLeafPtyId ? (host.ptysById.get(liveLeafPtyId) ?? null) : null
const pty = liveLeaf
? null
: host.findPty(snapshot.worktree, tab, {
allowWorktreeOnlyMatch: !snapshot.publicationEpoch.startsWith('headless')
})
const livePty = pty?.connected ? pty : null
// Why: enforce one-live-PTY-per-tab; drop a later tab resolving to an already-claimed PTY so no two tabs share a handle.
const resolvedLivePtyId = liveLeafPtyId ?? livePty?.ptyId ?? null
if (resolvedLivePtyId !== null) {
if (claimedLivePtyIds.has(resolvedLivePtyId)) {
continue
}
claimedLivePtyIds.add(resolvedLivePtyId)
}
const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null
const paneKey = isTerminalLeafId(tab.leafId)
? makePaneKey(tab.parentTabId, tab.leafId)
: `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}`
const mobileStatusPty = livePty ?? pty
// Why: headless hooks live in main's status store; reuse this lookup
// for both title ownership and status publication so the two cannot diverge.
const retainedAgentStatus = tab.agentStatus
? null
: host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab, getStatusRows)
const hookAgentStatus = tab.agentStatus
? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey))
: null
// Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned
// null, because persisted ids can collide with an unrelated pane after restart — reading
// that pane's tracker would publish its title here, ahead of every other source.
const trackerOnlyTitle = host.getTrackedTitle(liveLeafPtyId ?? pty?.ptyId ?? null)
const displayLeaf = leaf
? getLeafDisplayRecord(leaf, host.getTitleDisplayClear(leaf.ptyId))
: null
const displayPty = pty ? getPtyDisplayRecord(pty, host.getTitleDisplayClear(pty.ptyId)) : null
const leafTitle = displayLeaf
? getLatestAgentCandidateTitle(
{ title: displayLeaf.paneTitle, updatedAt: displayLeaf.paneTitleUpdatedAt },
{ title: displayLeaf.lastOscTitle, updatedAt: displayLeaf.lastOscTitleAt }
)
: null
const ptyTitle = displayPty
? getLatestAgentCandidateTitle(
{ title: displayPty.title, updatedAt: displayPty.titleUpdatedAt },
{ title: displayPty.lastOscTitle, updatedAt: displayPty.lastOscTitleAt }
)
: null
// Renderer omission is authoritative: PTY launch provenance outlives agent exit.
const launchAgent = tab.launchAgent ?? null
const launchOwnerAgent = launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null
// Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi.
const ownerRecord = resolvePaneAgentOwnerRecord({
launchAgent: launchOwnerAgent,
hookAgent:
tab.agentStatus?.agentType ??
hookAgentStatus?.agentType ??
retainedAgentStatus?.payload.agentType ??
null
})
const ownerAgent =
ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null
const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true }
const liveTitle = trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
const agentTitle = normalizeCompatibleAgentTitleForOwner(liveTitle, ownerAgent, ownerOptions)
// Why: the desktop's ranking over main's fresher live title; the window sends a generated
// name only while the desktop shows it, so it is passed as enabled.
const title = resolveTerminalTabTitle({ ...tab, title: agentTitle }, true, agentTitle)
const liveTitleEvidence = leafTitle ?? ptyTitle
// Why: renderer status can precede hook session identity, leaving native chat with no transcript address.
const rendererStatusAgent =
resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent, ownerOptions) ??
ownerAgent ??
undefined
const hookSessionAgent = resolveCompatibleAgentTypeForOwner(
hookAgentStatus?.providerSessionAgentType,
ownerAgent,
ownerOptions
)
const hookSessionMatchesRenderer =
!rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent
const hookProviderSession =
hookAgentStatus?.providerSession &&
hookSessionMatchesRenderer &&
(!tab.agentStatus?.providerSession ||
(hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt)
? hookAgentStatus.providerSession
: tab.agentStatus?.providerSession
const nativeStatusPty = liveLeafPty ?? mobileStatusPty
const statusPty = nativeStatusPty
? getPtyDisplayRecord(nativeStatusPty, host.getTitleDisplayClear(nativeStatusPty.ptyId))
: null
const normalizedTabAgentStatus = renewRuntimeMobileAgentStatusFromPtyTitle(
tab.agentStatus
? normalizeCompatibleAgentStatusEntryForOwner(
{
...tab.agentStatus,
...(hookProviderSession ? { providerSession: hookProviderSession } : {})
},
ownerAgent,
ownerOptions
)
: null,
statusPty,
{ preserveQuestionUnderShellTitle: true }
)
// Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title.
const hasLiveAgentSignal =
normalizedTabAgentStatus?.interactivePrompt != null ||
normalizedTabAgentStatus?.toolName != null
// Why: only shell/management evidence proves the agent released the pane
// (same predicate as the terminal-status API). A merely neutral live title
// — 'Terminal', an editor, a cwd — proves nothing, and treating it as
// completion published a synthetic `done` that fought the client's own
// live status on every republication.
const keepFullAgentStatus =
normalizedTabAgentStatus &&
(!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal)
const agentStatus = keepFullAgentStatus
? { agentStatus: normalizedTabAgentStatus }
: // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript.
normalizedTabAgentStatus?.agentType != null
? {
agentStatus: {
state: 'done' as const,
prompt: '',
updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt,
stateStartedAt:
statusPty?.lastAgentStatusStartedAtEpochMs ??
normalizedTabAgentStatus.stateStartedAt,
paneKey: normalizedTabAgentStatus.paneKey,
stateHistory: [],
agentType: normalizedTabAgentStatus.agentType,
...(normalizedTabAgentStatus.providerSession
? { providerSession: normalizedTabAgentStatus.providerSession }
: {})
}
}
: null
// Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable.
const terminalPty = liveLeafPtyId
? host.recordPty(liveLeafPtyId, snapshot.worktree, {
tabId: tab.parentTabId,
paneKey,
connected: true
})
: livePty
const terminalHandle = terminalPty ? host.issuePtyHandle(terminalPty) : null
const projectedAgentStatus =
agentStatus ??
host.buildPtyStatus(
mobileStatusPty,
tab,
terminalHandle,
retainedAgentStatus,
getHookRowsForPane
)
const projectedStatusEntry = projectedAgentStatus.agentStatus as
| (AgentStatusEntry & { turnCompletedAt?: number })
| undefined
const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } =
projectedStatusEntry ?? {}
const rawTurnCompletedAt =
hookAgentStatus?.live?.payload.turnCompletedAt ??
selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ??
projectedTurnCompletedAt
const turnCompletedAt =
typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt)
? rawTurnCompletedAt
: undefined
const clientAgentStatus: { agentStatus?: AgentStatusEntry } = projectedStatusEntry
? { agentStatus: clientStatusFields as AgentStatusEntry }
: {}
tabs.push({
type: 'terminal',
id: tab.id,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
title,
...(tab.customTitle !== undefined ? { customTitle: tab.customTitle } : {}),
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
// Bind identity to the handle's live owner, never a stale persisted surface.
...(terminalPty?.incarnationId ? { incarnationId: terminalPty.incarnationId } : {}),
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
...(launchAgent ? { launchAgent } : {}),
...clientAgentStatus,
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}),
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}),
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}),
...(tab.launchDraftCreatedAt !== undefined
? { launchDraftCreatedAt: tab.launchDraftCreatedAt }
: {}),
isActive: tab.isActive,
...(terminalHandle
? { status: 'ready' as const, terminal: terminalHandle }
: { status: 'pending-handle' as const, terminal: null })
})
}
return finalizeRuntimeMobileSessionTabsResult({ snapshot, tabs }, host)
}