From 92bb3754006be3a824d649424155e89af01a8852 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:58:35 -0700 Subject: [PATCH] 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 --- .../activity/ActivityPrototypePage.tsx | 60 +-- ...ty-portal-readiness-loop.react185.test.tsx | 365 ++++++++++++++++++ ...ivity-portal-readiness-oscillation.test.ts | 56 +++ .../activity-portal-readiness-oscillation.ts | 61 +++ .../activity-portal-thread-reconciliation.ts | 95 +++++ 5 files changed, 608 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx create mode 100644 src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts create mode 100644 src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts create mode 100644 src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index aa7f06c4747..a2fc5ad9270 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -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, diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx new file mode 100644 index 00000000000..c95e90b0a55 --- /dev/null +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -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 +}): { 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(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() + }) + 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() + 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() + 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') + }) +}) diff --git a/src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts b/src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts new file mode 100644 index 00000000000..dc04d287cce --- /dev/null +++ b/src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts b/src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts new file mode 100644 index 00000000000..e4cb80698f5 --- /dev/null +++ b/src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts @@ -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 + } + } +} diff --git a/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts b/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts new file mode 100644 index 00000000000..d662b04e4bf --- /dev/null +++ b/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts @@ -0,0 +1,95 @@ +export type ActivityPortalThreadRef = { + paneKey: string + worktree: { id: string } + tab: { id: string } +} + +export type ActivityPortalReconciliation = { + 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(args: { + selectedThread: TThread | null + displayedThread: TThread | null + selectedHasLiveTab: boolean + displayedHasLiveTab: boolean +}): ActivityPortalReconciliation { + 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(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 +}