fix(activity): bound Activity portal readiness oscillation (React #185)

React error #185 is "Maximum update depth exceeded" -- an unbounded render loop,
not setState-after-unmount as the crash reports superficially suggest. Verified in
react-dom@19.2.7's production bundle: getRootForUpdatedFiber throws
Error(formatProdErrorMessage(185)) when 50 < nestedUpdateCount. That frame is the
top of all 14 crash stacks across shipped 1.4.156 / 1.4.158 / 1.4.159.

nestedUpdateCount and rootWithNestedUpdates are module-level globals keyed on the
ROOT, not on a component. One runaway loop saturates the counter and the throw
lands on whichever fiber calls setState next, so SortableTab (terminal.workbench),
the Radix Presence/MenuPortal (page.settings) and the sidebar.worktrees button are
innocent bystanders with misleading component stacks -- four boundaries, one bug.

The driver is Activity's portal readiness pass. When 'ready' is unreachable while
'unavailable' stays reachable, updateReadiness flips loading<->unavailable from
inside useLayoutEffect -- React's sync lane, the lane that increments
nestedUpdateCount -- so it saturates and throws rather than merely being slow. A
prior investigation captured this live on Windows via CDP at 239 renders in 847ms.

Bound the oscillation: after ACTIVITY_PORTAL_READINESS_MAX_FLIPS consecutive
loading<->unavailable transitions the latch parks on 'unavailable' until a real
'ready' arrives. The latch rewrites only the hook's output, never the DOM probe,
so a terminal that churns during a slow attach and then genuinely comes up still
releases it. Because the counter is global per root, this also protects every
innocent bystander in the app -- most of the user-visible value.

Extract reconcileActivityPortalThreads and resolveActivityPortalSwap so the portal
loop is testable against the real collaborators. Note the reconciliation keeps its
worktree+tab comparison deliberately: Terminal mounts one TerminalPane per
(worktree, tab) and routes it by worktree+tab, so staging a same-tab pane would
leave the staged slot empty and its readiness stuck on 'loading' forever. Same-tab
switches swap in place via isolatedPaneKey instead.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-29 00:58:35 -07:00
co-authored by Orca
parent adc10cd21a
commit 92bb375400
5 changed files with 608 additions and 29 deletions
@@ -51,6 +51,11 @@ import {
setActivityTerminalPortals,
type ActivityTerminalPortalTarget
} from './activity-terminal-portal'
import {
reconcileActivityPortalThreads,
resolveActivityPortalSwap
} from './activity-portal-thread-reconciliation'
import { createActivityPortalReadinessLatch } from './activity-portal-readiness-oscillation'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
@@ -284,7 +289,7 @@ function getSelectedActivityTerminalPortalStatus(
}
}
function useActivityTerminalPortalStatus(
export function useActivityTerminalPortalStatus(
target: HTMLElement | null,
paneKey: string | null,
forceUnavailable = false
@@ -316,12 +321,15 @@ function useActivityTerminalPortalStatus(
let disposed = false
let readyFrame: number | null = null
let sawUnreadySelectedRoot = false
// Why: scoped to this subscription, so it resets whenever target/paneKey change.
const readinessLatch = createActivityPortalReadinessLatch()
const updateReadiness = (status: ActivityTerminalPortalReadiness['status']): void => {
const latched = readinessLatch.next(status)
setReadiness((prev) =>
prev.target === target && prev.paneKey === paneKey && prev.status === status
prev.target === target && prev.paneKey === paneKey && prev.status === latched
? prev
: { target, paneKey, status }
: { target, paneKey, status: latched }
)
}
@@ -1512,27 +1520,12 @@ export default function ActivityPrototypePage(): React.JSX.Element {
(tab) => tab.id === displayedTabId
)
: false
const displayedIsSelectedTerminal =
selectedThread &&
displayedThread &&
displayedThread.worktree.id === selectedThread.worktree.id &&
displayedThread.tab.id === selectedThread.tab.id
const visibleThread =
selectedThread && selectedHasLiveTab
? displayedThread && displayedHasLiveTab && displayedThread.paneKey !== selectedThread.paneKey
? displayedIsSelectedTerminal
? selectedThread
: displayedThread
: selectedThread
: null
const stagedThread =
selectedThread &&
selectedHasLiveTab &&
visibleThread &&
visibleThread.paneKey !== selectedThread.paneKey &&
!displayedIsSelectedTerminal
? selectedThread
: null
const { visibleThread, stagedThread } = reconcileActivityPortalThreads({
selectedThread,
displayedThread,
selectedHasLiveTab: Boolean(selectedHasLiveTab),
displayedHasLiveTab: Boolean(displayedHasLiveTab)
})
const inactivePortalSlotId = otherActivityTerminalSlot(activePortalSlotId)
const portalTargetBySlot = {
primary: primaryPortalTargetEl,
@@ -1605,18 +1598,27 @@ export default function ActivityPrototypePage(): React.JSX.Element {
])
useLayoutEffect(() => {
if (!selectedThread || !selectedHasLiveTab) {
const swap = resolveActivityPortalSwap({
selectedThread,
selectedHasLiveTab: Boolean(selectedHasLiveTab),
visibleThread,
stagedThread,
visiblePortalReady,
stagedPortalReady,
stagedPortalUnavailable
})
if (swap?.kind === 'clear') {
setDisplayedPaneKey(null)
return
}
if (stagedThread && (stagedPortalReady || stagedPortalUnavailable)) {
if (swap?.kind === 'swap-staged') {
// Why: a stale selected pane must swap to the unavailable state, not leave the previous pane visible under the new row.
setActivePortalSlotId(inactivePortalSlotId)
setDisplayedPaneKey(stagedThread.paneKey)
setDisplayedPaneKey(swap.paneKey)
return
}
if (!stagedThread && visibleThread?.paneKey === selectedThread.paneKey && visiblePortalReady) {
setDisplayedPaneKey(selectedThread.paneKey)
if (swap?.kind === 'settle-visible') {
setDisplayedPaneKey(swap.paneKey)
}
}, [
inactivePortalSlotId,
@@ -0,0 +1,365 @@
/** @vitest-environment happy-dom */
import { act, useLayoutEffect, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
import { useActivityTerminalPortalStatus } from './ActivityPrototypePage'
import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from './activity-terminal-portal'
import {
reconcileActivityPortalThreads,
resolveActivityPortalSwap,
type ActivityPortalThreadRef
} from './activity-portal-thread-reconciliation'
import type { ActivityPortalReadinessStatus } from './activity-portal-readiness-oscillation'
const WORKTREE_ID = 'wt-1'
const TAB_ID = 'tab-react185'
const OTHER_TAB_ID = 'tab-react185-other'
const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'
const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1'
const LEAF_C = 'cccccccc-cccc-4ccc-8ccc-ccccccccccc1'
const thread = (tabId: string, leafId: string): ActivityPortalThreadRef => ({
paneKey: `${tabId}:${leafId}`,
worktree: { id: WORKTREE_ID },
tab: { id: tabId }
})
// Two panes of the SAME tab: one TerminalPane, swapped in place by isolatedPaneKey.
const PANE_A = thread(TAB_ID, LEAF_A)
const PANE_B = thread(TAB_ID, LEAF_B)
// A different tab: a genuinely separate TerminalPane, so staging applies.
const PANE_C = thread(OTHER_TAB_ID, LEAF_C)
let root: Root
afterEach(() => {
act(() => {
root?.unmount()
})
document.body.replaceChildren()
})
/**
* Models the DOM one portaled TerminalPane emits: a tab root holding every leaf
* of the tab, with `applyExpandedLayoutTo` hiding the non-isolated siblings
* inline. Readiness only reports 'ready' when the wanted leaf is the unhidden one.
*/
function renderPortaledTerminalPane(target: HTMLElement, tabId: string, leafIds: string[]): void {
const isolatedLeafId = leafIds[0]
const tabRoot = document.createElement('div')
tabRoot.dataset.terminalTabId = tabId
for (const leafId of leafIds) {
const pane = document.createElement('div')
pane.dataset.leafId = leafId
pane.setAttribute('data-pty-id', `pty-${leafId}`)
pane.appendChild(Object.assign(document.createElement('div'), { className: 'xterm-screen' }))
if (leafId !== isolatedLeafId) {
pane.style.display = 'none'
}
Object.defineProperty(pane, 'getClientRects', {
value: () => (leafId === isolatedLeafId ? [{}] : []),
configurable: true
})
tabRoot.appendChild(pane)
}
target.replaceChildren(tabRoot)
}
/**
* Drives the whole Activity portal loop against the real collaborators:
* reconcile -> publish descriptors -> Terminal's routing -> the readiness hook
* -> swap. Everything runs in useLayoutEffect, React's SYNC lane, which is the
* lane that increments nestedUpdateCount toward the #185 throw at 50.
*/
function runActivityPortalPage(args: {
selectedThread: ActivityPortalThreadRef
initialDisplayed: ActivityPortalThreadRef
leafIdsByTabId: Record<string, string[]>
}): { displayedPaneKey: string | null; renders: number } {
const { selectedThread, initialDisplayed, leafIdsByTabId } = args
const slotEls = {
primary: document.createElement('div'),
secondary: document.createElement('div')
}
document.body.append(slotEls.primary, slotEls.secondary)
const threadsByPaneKey = new Map(
[selectedThread, initialDisplayed].map((entry) => [entry.paneKey, entry])
)
let renders = 0
let displayedPaneKey: string | null = initialDisplayed.paneKey
function ActivityPortalPage(): null {
renders += 1
const [displayed, setDisplayed] = useState<string | null>(initialDisplayed.paneKey)
const [activeSlotId, setActiveSlotId] = useState<'primary' | 'secondary'>('primary')
displayedPaneKey = displayed
const inactiveSlotId = activeSlotId === 'primary' ? 'secondary' : 'primary'
const { visibleThread, stagedThread } = reconcileActivityPortalThreads({
selectedThread,
displayedThread: displayed ? (threadsByPaneKey.get(displayed) ?? null) : null,
selectedHasLiveTab: true,
displayedHasLiveTab: true
})
const descriptors: ActivityTerminalPortalTarget[] = []
if (visibleThread) {
descriptors.push({
slotId: activeSlotId,
requestToken: `${activeSlotId}:${visibleThread.paneKey}`,
target: slotEls[activeSlotId],
worktreeId: WORKTREE_ID,
tabId: visibleThread.tab.id,
paneKey: visibleThread.paneKey,
active: true
})
}
if (stagedThread) {
descriptors.push({
slotId: inactiveSlotId,
requestToken: `${inactiveSlotId}:${stagedThread.paneKey}`,
target: slotEls[inactiveSlotId],
worktreeId: WORKTREE_ID,
tabId: stagedThread.tab.id,
paneKey: stagedThread.paneKey,
active: false
})
}
// Why: Terminal mounts one TerminalPane per (worktree, tab) and resolves its
// portal target with worktree+tab only -- reproduced here so the test feels
// the same routing the crash did.
useLayoutEffect(() => {
slotEls.primary.replaceChildren()
slotEls.secondary.replaceChildren()
for (const tabId of Object.keys(leafIdsByTabId)) {
const routed = findActivityTerminalPortal(descriptors, { worktreeId: WORKTREE_ID, tabId })
if (!routed) {
continue
}
const isolatedLeafId = routed.paneKey.slice(routed.paneKey.indexOf(':') + 1)
const leafIds = leafIdsByTabId[tabId]
renderPortaledTerminalPane(routed.target, tabId, [
isolatedLeafId,
...leafIds.filter((leafId) => leafId !== isolatedLeafId)
])
}
})
const visibleStatus = useActivityTerminalPortalStatus(
slotEls[activeSlotId],
visibleThread?.paneKey ?? null
)
const stagedStatus = useActivityTerminalPortalStatus(
slotEls[inactiveSlotId],
stagedThread?.paneKey ?? null
)
useLayoutEffect(() => {
const swap = resolveActivityPortalSwap({
selectedThread,
selectedHasLiveTab: true,
visibleThread,
stagedThread,
visiblePortalReady: visibleStatus === 'ready',
stagedPortalReady: stagedStatus === 'ready',
stagedPortalUnavailable: stagedStatus === 'unavailable'
})
if (swap?.kind === 'clear') {
setDisplayed(null)
return
}
if (swap?.kind === 'swap-staged') {
setActiveSlotId(inactiveSlotId)
setDisplayed(swap.paneKey)
return
}
if (swap?.kind === 'settle-visible') {
setDisplayed(swap.paneKey)
}
// Why these deps: mirror ActivityPrototypePage's swap effect, so a
// convergence failure here means the page fails to converge too.
}, [inactiveSlotId, stagedStatus, stagedThread, visibleStatus, visibleThread])
return null
}
root = createRoot(document.createElement('div'))
act(() => {
root.render(<ActivityPortalPage />)
})
return { displayedPaneKey, renders }
}
describe('Activity portal pane switching', () => {
it('converges on a newly selected pane of the tab already on screen', () => {
// Why this shape: staging a same-tab pane would need a second TerminalPane
// for one (worktree, tab), which Terminal never mounts -- the staged slot
// would stay empty, its readiness would stay 'loading', no swap arm would
// fire, and Activity would show the old pane under the new row forever.
const run = (): { displayedPaneKey: string | null; renders: number } =>
runActivityPortalPage({
selectedThread: PANE_B,
initialDisplayed: PANE_A,
leafIdsByTabId: { [TAB_ID]: [LEAF_A, LEAF_B] }
})
let result: { displayedPaneKey: string | null; renders: number } | null = null
expect(() => {
result = run()
}).not.toThrow()
expect(result!.displayedPaneKey).toBe(PANE_B.paneKey)
expect(result!.renders).toBeLessThan(50)
})
it('stages and swaps when the selected pane belongs to a different tab', () => {
const result = runActivityPortalPage({
selectedThread: PANE_C,
initialDisplayed: PANE_A,
leafIdsByTabId: { [TAB_ID]: [LEAF_A, LEAF_B], [OTHER_TAB_ID]: [LEAF_C] }
})
expect(result.displayedPaneKey).toBe(PANE_C.paneKey)
expect(result.renders).toBeLessThan(50)
})
/**
* Defense in depth, driven through the real hook so the assertion covers the
* production wiring and not just the latch module: if any DOM conflation makes
* 'ready' unreachable while 'unavailable' stays reachable, useActivityTerminal-
* PortalStatus must stop the sync-lane churn. nestedUpdateCount is global per
* root, so an unbounded spin surfaces in any unrelated component -- the reason
* this cluster appeared under four different error boundaries.
*/
it('bounds a readiness oscillation driven through the real portal-status hook', async () => {
const target = document.createElement('div')
document.body.append(target)
// Hidden leaf -> 'unavailable'; nothing hidden -> 'loading' (an unhidden
// sibling suppresses 'ready'). 'ready' is unreachable, so the pair spins.
const buildRoot = (hiddenLeafId: string | null): void => {
const tabRoot = document.createElement('div')
tabRoot.dataset.terminalTabId = TAB_ID
for (const leafId of [LEAF_A, LEAF_B]) {
const pane = document.createElement('div')
pane.dataset.leafId = leafId
pane.setAttribute('data-pty-id', `pty-${leafId}`)
pane.appendChild(
Object.assign(document.createElement('div'), { className: 'xterm-screen' })
)
if (leafId === hiddenLeafId) {
pane.style.display = 'none'
}
Object.defineProperty(pane, 'getClientRects', { value: () => [{}], configurable: true })
tabRoot.appendChild(pane)
}
target.replaceChildren(tabRoot)
}
buildRoot(LEAF_A)
let renders = 0
const statuses: ActivityPortalReadinessStatus[] = []
// Why a hard cap: an unlatched spin never settles, so `act` would hang until
// the suite timeout instead of failing on the assertion below. Stop feeding
// the loop past the cap so the test fails fast and legibly.
const RENDER_CAP = 50
function ActivityTerminalSlot(): null {
renders += 1
const status = useActivityTerminalPortalStatus(target, PANE_A.paneKey)
statuses.push(status)
// Models Terminal re-applying the opposite isolation, which the hook's
// MutationObserver then reports back as the opposite readiness.
useLayoutEffect(() => {
if (renders > RENDER_CAP) {
return
}
if (status === 'unavailable') {
buildRoot(null)
} else if (status === 'loading') {
buildRoot(LEAF_A)
}
})
return null
}
root = createRoot(document.createElement('div'))
await act(async () => {
root.render(<ActivityTerminalSlot />)
await new Promise((resolve) => setTimeout(resolve, 60))
})
// Latched: the hook stops emitting new states well before React's 50-deep
// sync-update throw, and stays parked on 'unavailable'.
expect(renders).toBeLessThanOrEqual(RENDER_CAP)
expect(statuses.at(-1)).toBe('unavailable')
})
/**
* The latch's one user-facing risk: pinning "Terminal unavailable" over a
* terminal that churned during a slow attach and then genuinely came up. The
* latch only rewrites the hook's output, never the DOM probe, so a real
* 'ready' still arrives and releases it. Asserted through the real hook
* because the subscription is rebuilt only on target/paneKey change.
*/
it('releases a latched readiness once the terminal genuinely attaches', async () => {
const target = document.createElement('div')
document.body.append(target)
const buildRoot = (mode: 'hidden' | 'sibling' | 'ready'): void => {
const tabRoot = document.createElement('div')
tabRoot.dataset.terminalTabId = TAB_ID
for (const leafId of [LEAF_A, LEAF_B]) {
const pane = document.createElement('div')
pane.dataset.leafId = leafId
pane.setAttribute('data-pty-id', `pty-${leafId}`)
pane.appendChild(
Object.assign(document.createElement('div'), { className: 'xterm-screen' })
)
if (mode === 'hidden' && leafId === LEAF_A) {
pane.style.display = 'none'
}
if (mode === 'ready' && leafId === LEAF_B) {
pane.style.display = 'none'
}
Object.defineProperty(pane, 'getClientRects', { value: () => [{}], configurable: true })
tabRoot.appendChild(pane)
}
target.replaceChildren(tabRoot)
}
buildRoot('hidden')
let churning = true
let churns = 0
const statuses: ActivityPortalReadinessStatus[] = []
function ActivityTerminalSlot(): null {
const status = useActivityTerminalPortalStatus(target, PANE_A.paneKey)
statuses.push(status)
useLayoutEffect(() => {
if (!churning || churns > 30) {
return
}
churns += 1
buildRoot(status === 'unavailable' ? 'sibling' : 'hidden')
})
return null
}
root = createRoot(document.createElement('div'))
await act(async () => {
root.render(<ActivityTerminalSlot />)
await new Promise((resolve) => setTimeout(resolve, 60))
})
expect(statuses.at(-1)).toBe('unavailable')
churning = false
await act(async () => {
buildRoot('ready')
await new Promise((resolve) => setTimeout(resolve, 60))
})
expect(statuses.at(-1)).toBe('ready')
})
})
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import {
ACTIVITY_PORTAL_READINESS_MAX_FLIPS,
createActivityPortalReadinessLatch
} from './activity-portal-readiness-oscillation'
describe('createActivityPortalReadinessLatch', () => {
it('latches to unavailable once loading<->unavailable keeps flipping', () => {
const latch = createActivityPortalReadinessLatch()
const seen: string[] = []
for (let i = 0; i < 40; i += 1) {
seen.push(latch.next(i % 2 === 0 ? 'loading' : 'unavailable'))
}
// Why: the whole point is that an unbounded oscillation stops producing new
// states long before React's 50 nested sync updates throw #185.
expect(seen.slice(-10).every((status) => status === 'unavailable')).toBe(true)
expect(seen.indexOf('unavailable')).toBeLessThan(ACTIVITY_PORTAL_READINESS_MAX_FLIPS + 2)
})
it('passes through a normal loading -> ready startup', () => {
const latch = createActivityPortalReadinessLatch()
expect(latch.next('loading')).toBe('loading')
expect(latch.next('ready')).toBe('ready')
expect(latch.next('ready')).toBe('ready')
})
it('does not latch when ready keeps refunding the budget', () => {
const latch = createActivityPortalReadinessLatch()
for (let i = 0; i < 30; i += 1) {
expect(latch.next('loading')).toBe('loading')
expect(latch.next('ready')).toBe('ready')
}
})
it('tolerates a few legitimate flips while xterm attaches', () => {
const latch = createActivityPortalReadinessLatch()
expect(latch.next('loading')).toBe('loading')
expect(latch.next('unavailable')).toBe('unavailable')
expect(latch.next('loading')).toBe('loading')
expect(latch.next('ready')).toBe('ready')
})
it('releases once the terminal genuinely comes up after a churny attach', () => {
// Why: a slow SSH host can burn the flip budget and still attach. The
// subscription is only rebuilt when target/paneKey change, so a latch that
// never released would pin "Terminal unavailable" over a working terminal.
const latch = createActivityPortalReadinessLatch()
for (let i = 0; i < ACTIVITY_PORTAL_READINESS_MAX_FLIPS + 4; i += 1) {
latch.next(i % 2 === 0 ? 'loading' : 'unavailable')
}
expect(latch.next('unavailable')).toBe('unavailable')
expect(latch.next('ready')).toBe('ready')
expect(latch.next('loading')).toBe('loading')
expect(latch.next('ready')).toBe('ready')
})
})
@@ -0,0 +1,61 @@
export type ActivityPortalReadinessStatus = 'loading' | 'ready' | 'unavailable'
// Why: React throws #185 ("Maximum update depth exceeded") once 50 nested SYNC
// updates land on one root. Readiness updates run from useLayoutEffect, so a
// loading<->unavailable flip-flop rides that sync lane and saturates the
// counter. nestedUpdateCount is global per ROOT, so the throw then lands on
// whichever unrelated component sets state next -- which is why this cluster
// surfaced under four different error boundaries. Latch well below 50 (two
// readiness hooks share the budget) but above the flip or two a legitimately
// slow terminal produces while xterm attaches.
export const ACTIVITY_PORTAL_READINESS_MAX_FLIPS = 8
export type ActivityPortalReadinessLatch = {
next: (status: ActivityPortalReadinessStatus) => ActivityPortalReadinessStatus
}
/**
* Bounds a loading<->unavailable oscillation for one readiness subscription.
*
* Why: defense in depth. Any DOM conflation that makes 'ready' unreachable
* while 'unavailable' stays reachable spins forever; latching degrades that one
* Activity pane instead of crashing the renderer.
*
* Scope: callers create one latch per (target, paneKey) subscription, so this
* only bounds a spin *within* one subscription. A cycle that also flips paneKey
* rebuilds the latch each pass and stays unbounded; Activity cannot produce one
* because displayedPaneKey only ever advances toward the selected pane.
*/
export function createActivityPortalReadinessLatch(): ActivityPortalReadinessLatch {
let lastStatus: ActivityPortalReadinessStatus | null = null
let flips = 0
let latched = false
return {
next(status) {
// Why 'ready' also releases the latch: it is never part of the
// pathological cycle, so a ready-free spin can never reach it — and the
// subscription is rebuilt only when target/paneKey change, so a latch
// that never released would pin "Terminal unavailable" over a terminal
// that churned during a slow attach and then genuinely came up.
if (status === 'ready') {
lastStatus = status
flips = 0
latched = false
return status
}
if (latched) {
return 'unavailable'
}
if (lastStatus !== null && lastStatus !== status) {
flips += 1
}
lastStatus = status
if (flips >= ACTIVITY_PORTAL_READINESS_MAX_FLIPS) {
latched = true
return 'unavailable'
}
return status
}
}
}
@@ -0,0 +1,95 @@
export type ActivityPortalThreadRef = {
paneKey: string
worktree: { id: string }
tab: { id: string }
}
export type ActivityPortalReconciliation<TThread extends ActivityPortalThreadRef> = {
displayedIsSelectedTerminal: boolean
visibleThread: TThread | null
stagedThread: TThread | null
}
/**
* Picks which thread the Activity portal shows now (visible) and which one is
* warmed up in the inactive slot (staged).
*/
export function reconcileActivityPortalThreads<TThread extends ActivityPortalThreadRef>(args: {
selectedThread: TThread | null
displayedThread: TThread | null
selectedHasLiveTab: boolean
displayedHasLiveTab: boolean
}): ActivityPortalReconciliation<TThread> {
const { selectedThread, displayedThread, selectedHasLiveTab, displayedHasLiveTab } = args
// Why worktree+tab, not paneKey: Terminal mounts one TerminalPane per
// (worktree, tab) and routes it by worktree+tab, so a same-tab staged
// descriptor never gets a pane and its readiness never leaves 'loading' —
// no swap arm fires and the old pane sticks. Same tab swaps in place via
// isolatedPaneKey instead of staging.
const displayedIsSelectedTerminal = Boolean(
selectedThread &&
displayedThread &&
displayedThread.worktree.id === selectedThread.worktree.id &&
displayedThread.tab.id === selectedThread.tab.id
)
const visibleThread =
selectedThread && selectedHasLiveTab
? displayedThread && displayedHasLiveTab && displayedThread.paneKey !== selectedThread.paneKey
? displayedIsSelectedTerminal
? selectedThread
: displayedThread
: selectedThread
: null
const stagedThread =
selectedThread &&
selectedHasLiveTab &&
visibleThread &&
visibleThread.paneKey !== selectedThread.paneKey &&
!displayedIsSelectedTerminal
? selectedThread
: null
return { displayedIsSelectedTerminal, visibleThread, stagedThread }
}
export type ActivityPortalSwap =
| { kind: 'clear' }
| { kind: 'swap-staged'; paneKey: string }
| { kind: 'settle-visible'; paneKey: string }
| null
/**
* Decides how displayedPaneKey advances for one commit.
*
* Why 'unavailable' counts as settled: a stale or unresolvable pane must still
* hand the slot over, otherwise the previous terminal stays on screen under the
* newly selected row and the readiness pass retries forever.
*/
export function resolveActivityPortalSwap<TThread extends ActivityPortalThreadRef>(args: {
selectedThread: TThread | null
selectedHasLiveTab: boolean
visibleThread: TThread | null
stagedThread: TThread | null
visiblePortalReady: boolean
stagedPortalReady: boolean
stagedPortalUnavailable: boolean
}): ActivityPortalSwap {
const {
selectedThread,
selectedHasLiveTab,
visibleThread,
stagedThread,
visiblePortalReady,
stagedPortalReady,
stagedPortalUnavailable
} = args
if (!selectedThread || !selectedHasLiveTab) {
return { kind: 'clear' }
}
if (stagedThread && (stagedPortalReady || stagedPortalUnavailable)) {
return { kind: 'swap-staged', paneKey: stagedThread.paneKey }
}
if (!stagedThread && visibleThread?.paneKey === selectedThread.paneKey && visiblePortalReady) {
return { kind: 'settle-visible', paneKey: selectedThread.paneKey }
}
return null
}