wip: react185-cascade (#16940)

This commit is contained in:
Neil
2026-08-27 20:56:00 -07:00
committed by GitHub
parent c54bf92181
commit c72afda498
11 changed files with 1370 additions and 7 deletions
@@ -1426,6 +1426,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const [groupBy, setGroupBy] = useState<ActivityGroupBy>('status')
const [query, setQuery] = useState('')
const activityFilterInputRef = useRef<HTMLInputElement | null>(null)
// Why: bounds auto mark-read to one acknowledgement per selected thread turn.
const autoAcknowledgedTurnRef = useRef<string | null>(null)
const [compactMode, setCompactMode] = useState(false)
const [selectedPaneKey, setSelectedPaneKey] = useState<string | null>(null)
const [displayedPaneKey, setDisplayedPaneKey] = useState<string | null>(null)
@@ -1731,11 +1733,20 @@ export default function ActivityPrototypePage(): React.JSX.Element {
) {
return
}
// Why (React #185): a turn stamped ahead of this clock (SSH/remote execution host) can never
// have its unread cleared, and each retry lands on a later millisecond, so acknowledgeAgents'
// `prev < now` guard rewrites the ack map every time and re-enters here forever through
// storeData. Auto-read is once per turn, not a retry.
const autoAcknowledgeKey = `${selectedThread.paneKey}:${selectedThread.latestTimestamp}`
if (autoAcknowledgedTurnRef.current === autoAcknowledgeKey) {
return
}
const selectedThreadHasDetailOnlyView =
!selectedHasLiveTab || selectedThread.migrationUnsupportedPtyId !== undefined
const selectedThreadIsVisibleTerminal =
visibleThread?.paneKey === effectiveSelectedPaneKey && visiblePortalReady
if (selectedThreadHasDetailOnlyView || selectedThreadIsVisibleTerminal) {
autoAcknowledgedTurnRef.current = autoAcknowledgeKey
storeData.acknowledgeAgents([selectedThread.paneKey])
}
}, [
@@ -0,0 +1,224 @@
/** @vitest-environment happy-dom */
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { Repo } from '../../../../shared/repo-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import ActivityPrototypePage from './ActivityPrototypePage'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'
const TAB_A = 'tab-a'
const PANE_A = makePaneKey(TAB_A, LEAF_A)
const PROMPT = 'turn stamped by the execution host'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
}
const worktree: Worktree = {
id: 'wt-1',
repoId: repo.id,
path: '/repo/wt-1',
head: 'abc123',
branch: 'feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1
}
const tab: TerminalTab = {
id: TAB_A,
ptyId: 'pty-a',
worktreeId: worktree.id,
title: 'Claude',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
const initialState = useAppStore.getInitialState()
let root: Root
let seededContainer: HTMLElement
beforeEach(() => {
useAppStore.setState(initialState, true)
})
afterEach(() => {
act(() => root?.unmount())
useAppStore.setState(initialState, true)
document.body.replaceChildren()
})
function seedRetainedThread(stampedAt: number, lastAssistantMessage: string): void {
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [] },
retainedAgentsByPaneKey: {
[PANE_A]: {
entry: {
state: 'done',
prompt: PROMPT,
updatedAt: stampedAt,
stateStartedAt: stampedAt,
paneKey: PANE_A,
terminalTitle: 'Claude',
stateHistory: [],
agentType: 'claude',
lastAssistantMessage
},
worktreeId: worktree.id,
tab,
agentType: 'claude',
startedAt: stampedAt
}
},
activeRepoId: repo.id,
activeWorktreeId: worktree.id
})
}
function seedThreadStampedByLocalClock(): void {
seedRetainedThread(Date.now() - 60_000, 'finished locally')
}
// Why: an SSH/remote execution host stamps the turn with its own clock, so the renderer can
// acknowledge a thread whose event still sorts as newer than the acknowledgement.
function seedThreadStampedAheadOfLocalClock(): void {
seedRetainedThread(Date.now() + 60_000, 'finished on the execution host')
}
async function mountActivityPage(): Promise<void> {
const container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
await act(async () => {
root.render(
<TooltipProvider>
<ActivityPrototypePage />
</TooltipProvider>
)
await new Promise((resolve) => setTimeout(resolve, 30))
})
seededContainer = container
}
async function selectSeededThread(): Promise<void> {
const row = Array.from(seededContainer.querySelectorAll<HTMLElement>('[role="button"]')).find(
(element) => element.textContent?.includes(PROMPT)
)
expect(row).toBeDefined()
await act(async () => {
row?.click()
await new Promise((resolve) => setTimeout(resolve, 30))
})
}
function countAcknowledgeWrites(): { readonly count: () => number; stop: () => void } {
let writes = 0
const unsubscribe = useAppStore.subscribe((next, prev) => {
if (next.acknowledgedAgentsByPaneKey !== prev.acknowledgedAgentsByPaneKey) {
writes += 1
}
})
return { count: () => writes, stop: unsubscribe }
}
describe('Activity auto mark-read loop (React #185)', () => {
it('acknowledges a selected thread once even when its unread flag cannot clear', async () => {
seedThreadStampedAheadOfLocalClock()
await mountActivityPage()
const acknowledgeWrites = countAcknowledgeWrites()
try {
await selectSeededThread()
} finally {
acknowledgeWrites.stop()
}
expect(acknowledgeWrites.count()).toBe(1)
})
it('re-acknowledges when a new turn lands on the still-selected thread', async () => {
seedThreadStampedAheadOfLocalClock()
await mountActivityPage()
const acknowledgeWrites = countAcknowledgeWrites()
try {
await selectSeededThread()
expect(acknowledgeWrites.count()).toBe(1)
// Why: the per-turn guard must not swallow the next turn's auto mark-read.
const nextTurnAt = Date.now() + 120_000
await act(async () => {
useAppStore.setState((s) => {
const previous = s.retainedAgentsByPaneKey[PANE_A]
return {
retainedAgentsByPaneKey: {
[PANE_A]: {
...previous,
startedAt: nextTurnAt,
entry: {
...previous.entry,
updatedAt: nextTurnAt,
stateStartedAt: nextTurnAt,
lastAssistantMessage: 'second turn on the execution host'
}
}
}
}
})
await new Promise((resolve) => setTimeout(resolve, 50))
})
} finally {
acknowledgeWrites.stop()
}
expect(acknowledgeWrites.count()).toBe(2)
})
it('still marks a locally stamped thread read on selection', async () => {
seedThreadStampedByLocalClock()
await mountActivityPage()
await selectSeededThread()
expect(useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_A]).toBeGreaterThan(0)
})
it('leaves the selected thread unread after the user marks it unread', async () => {
seedThreadStampedByLocalClock()
await mountActivityPage()
await selectSeededThread()
// Why pinned: auto mark-read is once per turn, so an explicit mark-unread on the
// still-selected thread must survive — a guard keyed on unread instead of the turn
// would silently re-acknowledge it and make the menu action a no-op.
await act(async () => {
useAppStore.getState().unacknowledgeAgents([PANE_A])
await new Promise((resolve) => setTimeout(resolve, 50))
})
expect(useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_A]).toBeUndefined()
})
})
@@ -0,0 +1,234 @@
/**
* @vitest-environment happy-dom
*
* Reproduces the shape of the production React #185 cluster filed under
* boundary_id `sidebar.worktrees` (component stack: RovingFocusGroupItem inside
* DropdownMenuItem inside the worktree row's context menu).
*
* The point of the test is that the menu is a BYSTANDER: it contains no loop of
* its own (first case), and it only takes the blame because Radix's roving-focus
* item runs `onFocusableItemAdd()` — a setState — from a mount LAYOUT effect,
* which is the next dispatch after an unrelated driver has already pushed
* React's root-global nested-update counter past its limit (second case).
*/
import React, { act, useLayoutEffect, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../../shared/worktree/types'
import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution'
import { TooltipProvider } from '@/components/ui/tooltip'
import WorktreeContextMenu from './WorktreeContextMenu'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const state = {
updateWorktreeMeta: vi.fn(),
setWorktreesPinnedAndReveal: vi.fn(),
workspaceStatuses: [
{ id: 'todo', label: 'Todo' },
{ id: 'doing', label: 'Doing' }
],
openModal: vi.fn(),
projectGroups: [{ id: 'g1', name: 'Group 1' }],
createProjectGroup: vi.fn(),
moveProjectToGroup: vi.fn(),
deleteStateByWorktreeId: {},
worktreeLineageById: {},
workspaceLineageByChildKey: {},
updateWorktreeLineage: vi.fn(),
tabsByWorktree: {},
ptyIdsByTabId: {},
browserTabsByWorktree: {},
keybindings: {},
settings: { activeRuntimeEnvironmentId: null, openInApplications: [] },
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (s: typeof state) => unknown) => selector(state), {
getState: () => state
})
}))
vi.mock('@/store/selectors', () => ({
useAllWorktrees: () => [],
useRepoById: (repoId?: string) =>
repoId ? { id: repoId, name: repoId, displayName: repoId, projectGroupId: null } : undefined,
useRepoMap: () => new Map(),
useWorktreeMap: () => new Map()
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback,
i18n: { language: 'en', on: () => {}, off: () => {} }
}))
vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null }))
vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null }))
const mounted: { container: HTMLDivElement; root: Root }[] = []
afterEach(() => {
for (const { root, container } of mounted) {
act(() => root.unmount())
container.remove()
}
mounted.length = 0
})
function worktreeFixture(): Worktree {
return {
id: 'repo::wt-1',
repoId: 'repo',
name: 'wt-1',
displayName: 'wt-1',
path: '/path/to/wt-1',
isMainWorktree: false
} as unknown as Worktree
}
type Capture = { errors: Error[]; componentStacks: string[] }
class SidebarBoundary extends React.Component<
{ children: React.ReactNode; capture: Capture },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
this.props.capture.errors.push(error)
this.props.capture.componentStacks.push(info.componentStack ?? '')
}
render(): React.ReactNode {
return this.state.failed ? null : this.props.children
}
}
function mount(node: React.ReactNode, capture: Capture): HTMLDivElement {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container, {
onUncaughtError: (error) => capture.errors.push(error as Error),
onCaughtError: () => undefined
})
mounted.push({ container, root })
act(() => {
root.render(
<SidebarBoundary capture={capture}>
<TooltipProvider>{node}</TooltipProvider>
</SidebarBoundary>
)
})
return container
}
/**
* Stands in for whatever unrelated sidebar/app code kept sync lanes pending
* commit after commit in the field report. It is the driver, not the menu.
*/
function CommitCascadeDriver({
ticks,
onTick
}: {
ticks: number
onTick: () => void
}): React.JSX.Element {
useLayoutEffect(() => {
if (ticks < REACT_NESTED_UPDATE_LIMIT + 4) {
onTick()
}
})
return <div data-testid="driver">{ticks}</div>
}
describe('WorktreeContextMenu and React #185', () => {
it('opening the row context menu on its own does not trip the nested-update limit', () => {
const capture: Capture = { errors: [], componentStacks: [] }
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const container = mount(
<WorktreeContextMenu worktree={worktreeFixture()}>
<div data-testid="card-child">Card</div>
</WorktreeContextMenu>,
capture
)
const scope = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement
act(() => {
scope.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })
)
})
consoleError.mockRestore()
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeTruthy()
expect(capture.errors.map((error) => error.message)).toEqual([])
})
// Reproduces the field report: the driver is elsewhere, the menu is blamed.
it('is blamed for a driver-owned cascade when its items mount past the limit', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const blamedFrames: string[] = []
// Which commit the right-click lands on decides which mounting fiber
// dispatches first past the limit; sweep the window around it rather than
// pinning one React-version-specific offset.
for (
let openAt = REACT_NESTED_UPDATE_LIMIT - 6;
openAt <= REACT_NESTED_UPDATE_LIMIT;
openAt++
) {
const capture: Capture = { errors: [], componentStacks: [] }
function Harness(): React.JSX.Element {
const [ticks, setTicks] = useState(0)
const hostRef = React.useRef<HTMLDivElement>(null)
// Right-click lands mid-cascade, so the menu content — and every
// RovingFocusGroupItem inside it — mounts while the counter is already deep.
useLayoutEffect(() => {
if (ticks !== openAt) {
return
}
hostRef.current
?.querySelector('[data-worktree-context-menu-scope]')
?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
}, [ticks])
return (
<>
<div ref={hostRef}>
<WorktreeContextMenu worktree={worktreeFixture()}>
<div data-testid="card-child">Card</div>
</WorktreeContextMenu>
</div>
<CommitCascadeDriver ticks={ticks} onTick={() => setTicks((value) => value + 1)} />
</>
)
}
mount(<Harness />, capture)
const depthErrors = capture.errors.filter((error) =>
error.message.includes('Maximum update depth exceeded')
)
expect(depthErrors.length).toBeGreaterThan(0)
blamedFrames.push(capture.componentStacks[0]?.split('\n')[1]?.trim() ?? '')
}
consoleError.mockRestore()
// Carried as the assertion message so a React/Radix version bump reports the
// frames it blamed instead of a bare `false`.
const blamed = blamedFrames.map((frame) => frame.split(' (')[0]).join(', ')
// The production report's innermost component_stack frame.
expect(
blamedFrames.some((frame) => frame.startsWith('at RovingFocusGroupItem')),
blamed
).toBe(true)
// ...and the loop is never in menu code: only the driver owns a setState loop.
expect(
blamedFrames.some((frame) => frame.startsWith('at CommitCascadeDriver')),
blamed
).toBe(true)
})
})
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
/**
* Pins the settling guarantee for every setState-in-effect path SortableTab owns
* (rename shortcut, title churn mid-rename, store write storms, StrictMode double
* invoke) under a real React root, where "Maximum update depth exceeded" throws.
*
* Written while triaging the React #185 crash cluster whose component_stack names
* SortableTab: these paths settle, which matches shared/react-update-depth-attribution.ts
* — #185 lands on whichever fiber dispatched next after a root-global counter tripped,
* so that stack names a bystander. Keep this green so the tab stays exonerated.
*/
import type { ReactElement, ReactNode } from 'react'
import { cloneElement, isValidElement, StrictMode, useEffect, useState } from 'react'
import { act, render, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { useAppStore } from '../../store'
import SortableTab from './SortableTab'
type ProbeState = {
unreadTerminalTabs: Record<string, boolean>
unreadAgentCompletionPanes: Record<string, boolean>
agentStatusByPaneKey: Record<string, unknown>
agentStatusEpoch: number
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<string, unknown>
renamingTabId: string | null
keybindings: Record<string, unknown>
setRenamingTabId: (tabId: string | null) => void
}
type StoreApiWithHook = {
(selector: (state: ProbeState) => unknown): unknown
getState: () => ProbeState
setState: (partial: Partial<ProbeState>) => void
}
// Both specifiers resolve to the same store module; memoize so they share one instance.
async function createProbeStore(): Promise<StoreApiWithHook> {
const globalKey = '__sortableTabProbeStore'
const globals = globalThis as Record<string, unknown>
if (!globals[globalKey]) {
const { create } = await import('zustand')
globals[globalKey] = create<ProbeState>((set) => ({
unreadTerminalTabs: {},
unreadAgentCompletionPanes: {},
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
renamingTabId: null,
keybindings: {},
setRenamingTabId: (tabId) => set({ renamingTabId: tabId })
}))
}
return globals[globalKey] as StoreApiWithHook
}
vi.mock('@/store', async () => ({ useAppStore: await createProbeStore() }))
vi.mock('../../store', async () => ({ useAppStore: await createProbeStore() }))
vi.mock('@dnd-kit/sortable', () => ({
useSortable: ({ id }: { id: string }) => ({
attributes: { role: 'tab', 'data-sortable-id': id },
listeners: { onPointerDown: vi.fn() },
setNodeRef: vi.fn()
})
}))
vi.mock('@/lib/use-tab-agent', () => ({ useTabAgent: () => null }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) =>
asChild && isValidElement(children) ? (
cloneElement(children as ReactElement<Record<string, unknown>>)
) : (
<span>{children}</span>
)
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSeparator: () => null,
DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSub: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/input', () => ({
Input: (props: Record<string, unknown>) => <input {...props} />
}))
vi.mock('./shell-icons', () => ({ ShellIcon: () => <span /> }))
vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => <span /> }))
vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => <span /> }))
function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
id: 'terminal-tab-1',
title: 'Terminal 1',
worktreeId: 'wt-1',
...overrides
} as TerminalTab
}
const dragData: TabDragItemData = {
kind: 'tab',
worktreeId: 'wt-1',
groupId: 'group-1',
unifiedTabId: 'unified-1',
visibleTabId: 'terminal-tab-1',
tabType: 'terminal',
label: 'Terminal 1'
}
const probeStore = useAppStore as unknown as StoreApiWithHook
let renderCount = 0
function Harness({ tab }: { tab: TerminalTab }): ReactElement {
renderCount += 1
return (
<SortableTab
tab={tab}
unifiedTabId="unified-1"
groupId="group-1"
tabCount={1}
hasTabsToRight={false}
hasTabsToLeft={false}
isActive
isPinned={false}
isExpanded={false}
onActivate={vi.fn()}
onClose={vi.fn()}
onCloseOthers={vi.fn()}
onCloseToRight={vi.fn()}
onCloseToLeft={vi.fn()}
onSetCustomTitle={vi.fn()}
onSetTabColor={vi.fn()}
onTogglePin={vi.fn()}
onToggleExpand={vi.fn()}
// A fresh object per render, exactly like renderTabBarItems builds it.
dragData={{ ...dragData }}
dropIndicator={null}
/>
)
}
/** Re-renders the tab with a new tab object on every store write, like the tab strip does. */
function ChurningHarness({ titles }: { titles: string[] }): ReactElement {
const [index, setIndex] = useState(0)
useEffect(() => {
if (index < titles.length - 1) {
setIndex(index + 1)
}
}, [index, titles.length])
return <Harness tab={makeTab({ title: titles[index] })} />
}
afterEach(() => {
cleanup()
probeStore.setState({ renamingTabId: null, unreadTerminalTabs: {}, agentStatusEpoch: 0 })
renderCount = 0
})
describe('SortableTab update-depth probe', () => {
it('settles when the rename shortcut arms renamingTabId', () => {
probeStore.setState({ renamingTabId: 'terminal-tab-1' })
const { container } = render(<Harness tab={makeTab()} />)
expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull()
expect(probeStore.getState().renamingTabId).toBeNull()
expect(renderCount).toBeLessThan(20)
})
it('settles under title churn while the rename editor is open', () => {
probeStore.setState({ renamingTabId: 'terminal-tab-1' })
render(<ChurningHarness titles={['a', 'b', 'c', 'd', 'e', 'f']} />)
expect(renderCount).toBeLessThan(40)
})
it('settles under a store write storm', () => {
render(<Harness tab={makeTab()} />)
const before = renderCount
act(() => {
for (let i = 0; i < 60; i += 1) {
probeStore.setState({ agentStatusEpoch: i, agentStatusByPaneKey: {} })
}
})
expect(renderCount - before).toBeLessThan(200)
})
it('settles in StrictMode double-invoked effects', () => {
probeStore.setState({ renamingTabId: 'terminal-tab-1' })
render(
<StrictMode>
<Harness tab={makeTab()} />
</StrictMode>
)
expect(probeStore.getState().renamingTabId).toBeNull()
})
})
@@ -0,0 +1,233 @@
/** @vitest-environment happy-dom */
/**
* Crash cluster "React #185 in TerminalPaneOverlayLayer" (boundary terminal.workbench).
*
* The overlay's own park verdict does not oscillate (terminal-cold-park-verdict-loop
* covers that). What used to put it in the stack: its cold-park effect re-runs on
* every tab-model write — a runtime title publication, an unread bump re-mints
* tabsByWorktree — and each run dispatched setColdParkedTerminalTabIds even when
* the verdict was unchanged. React only bails on such a dispatch while the fiber
* has no pending lanes, so inside a cascade some other component drives, this hook
* is the one that trips the root-global nested-update counter and gets blamed (see
* src/shared/react-update-depth-attribution.ts).
*/
import { act, useEffect, useState, StrictMode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const harness = vi.hoisted(() => ({
worktreeId: 'repo::/cold-park-tab-model-identity',
parkEffectRuns: 0,
renderedParkedSets: [] as string[][]
}))
vi.mock('../../store', async () => {
const { create } = await import('zustand')
const useAppStore = create(() => ({
activeGroupIdByWorktree: {} as Record<string, string | undefined>,
groupsByWorktree: {} as Record<string, TabGroup[]>,
pendingStartupByTabId: {} as Record<string, unknown>,
ptyIdsByTabId: {} as Record<string, string[]>,
runtimeStatusByEnvironmentId: new Map<string, unknown>(),
runtimePaneTitlesByTabId: {} as Record<string, Record<number, string>>,
settings: {} as Record<string, unknown>,
sleepingAgentSessionsByPaneKey: {} as Record<string, unknown>,
tabsByWorktree: {} as Record<string, TerminalTab[]>,
terminalLayoutsByTabId: {} as Record<string, unknown>,
unifiedTabsByWorktree: {} as Record<string, Tab[]>,
consumeSuppressedPtyExit: () => false,
focusGroup: () => {},
reconcileWorktreeTabModel: () => ({ renderableTabCount: 2 }),
setActiveWorktree: () => {}
}))
return { useAppStore }
})
vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({
useNativeChatToggleShortcut: () => {}
}))
vi.mock('./TerminalOverlaySlot', () => ({ TerminalOverlaySlot: () => null }))
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => true,
disposeParkedTerminalWatchersForWorktree: () => {},
syncParkedTerminalTabWatchers: (args: { parkedTabIds: ReadonlySet<string> }) => {
harness.renderedParkedSets.push([...args.parkedTabIds].sort())
}
}))
// Counts cold-park effect executions: the effect clears and re-arms its recheck
// timers on every run, so this is also the per-commit work the identity dep costs.
vi.mock('./terminal-parking-e2e-overrides', () => ({
getTerminalParkingPolicyOverrides: () => {
harness.parkEffectRuns += 1
return { coldParkDelayMs: 0, hotRetainMs: 0 }
}
}))
vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() }))
import { useAppStore } from '../../store'
import TerminalPaneOverlayLayer from './TerminalPaneOverlayLayer'
const TAB_IDS = ['tab-a', 'tab-b'] as const
const GROUP_ID = 'group-a'
/** Above React's nested-update limit so the cascade actually reaches the bail. */
const CASCADE_COMMITS = REACT_NESTED_UPDATE_LIMIT * 2
type ParkingStoreState = {
groupsByWorktree: Record<string, TabGroup[]>
tabsByWorktree: Record<string, TerminalTab[]>
unifiedTabsByWorktree: Record<string, Tab[]>
}
const parkingStore = useAppStore as unknown as {
getState: () => ParkingStoreState
setState: (partial: unknown) => void
}
function terminalTab(id: string): TerminalTab {
return {
id,
worktreeId: harness.worktreeId,
ptyId: `${harness.worktreeId}@@session-${id}`,
title: id,
generation: 0
} as TerminalTab
}
function unifiedTerminalTab(id: string): Tab {
return {
id: `unified-${id}`,
entityId: id,
worktreeId: harness.worktreeId,
groupId: GROUP_ID,
contentType: 'terminal',
label: id,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
} as Tab
}
/** An ordinary runtime title publication: re-mints tabsByWorktree, changes no park input. */
function publishRuntimeTitle(revision: number): void {
parkingStore.setState((state: ParkingStoreState) => ({
tabsByWorktree: {
...state.tabsByWorktree,
[harness.worktreeId]: state.tabsByWorktree[harness.worktreeId].map((tab) => ({
...tab,
title: `${tab.id}-${revision}`
}))
}
}))
}
/**
* The cascade driver: a component with its own runaway passive effect, in no way
* related to terminal parking. It is the bug; the overlay is the bystander.
*/
function UnrelatedCascadeDriver(): null {
const [tick, setTick] = useState(0)
useEffect(() => {
if (tick >= CASCADE_COMMITS) {
return
}
publishRuntimeTitle(tick)
setTick((current) => current + 1)
}, [tick])
return null
}
function renderTree(root: Root): unknown {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let error: unknown = null
try {
act(() => {
root.render(
<StrictMode>
<TerminalPaneOverlayLayer
worktreeId={harness.worktreeId}
worktreePath="cold-park-tab-model-identity"
isWorktreeActive={false}
coldParkTerminalPanes={false}
/>
<UnrelatedCascadeDriver />
</StrictMode>
)
})
} catch (thrown) {
error = thrown
}
consoleError.mockRestore()
return error
}
describe('cold-park effect vs. unrelated commit cascade', () => {
let container: HTMLDivElement
let root: Root | undefined
beforeEach(() => {
harness.parkEffectRuns = 0
harness.renderedParkedSets.length = 0
const tabs = TAB_IDS.map(terminalTab)
const unifiedTabs = TAB_IDS.map(unifiedTerminalTab)
parkingStore.setState({
activeGroupIdByWorktree: { [harness.worktreeId]: GROUP_ID },
groupsByWorktree: {
[harness.worktreeId]: [
{
id: GROUP_ID,
worktreeId: harness.worktreeId,
activeTabId: unifiedTabs[0].id,
tabOrder: unifiedTabs.map((tab) => tab.id),
recentTabIds: [unifiedTabs[0].id]
} as TabGroup
]
},
tabsByWorktree: { [harness.worktreeId]: tabs },
unifiedTabsByWorktree: { [harness.worktreeId]: unifiedTabs }
})
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
try {
act(() => root?.unmount())
} catch {
/* a failed commit leaves no mounted tree */
}
root = undefined
container.remove()
})
it('dispatches no park-state update for tab-model writes that change no park verdict', () => {
root = createRoot(container)
const error = renderTree(root)
// The park verdict itself never churns: one empty -> parked transition,
// matching the field bundle, which carries no terminal_park_verdict_churn crumb.
expect(harness.renderedParkedSets.at(-1)).toEqual(['tab-b'])
expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual(
new Set(['', 'tab-b'])
)
// React #185 still fires — the driver is the bug — but it must land on the
// driver's own dispatch, not on the overlay's park state. Asserting the throw
// happened keeps the "not blamed" check below from passing on an empty stack.
expect(error instanceof Error ? error.message : '').toContain('Maximum update depth exceeded')
const stack = error instanceof Error ? (error.stack ?? '') : String(error ?? '')
expect(stack).not.toContain('use-terminal-tab-cold-parking')
expect(stack).toContain('terminal-cold-park-tab-model-identity')
// The effect itself still re-runs — coverage is re-derived from store state
// the park key cannot encode — it just stops dispatching an unchanged verdict.
expect(harness.parkEffectRuns).toBeGreaterThan(REACT_NESTED_UPDATE_LIMIT)
})
})
@@ -138,6 +138,8 @@ export function useTerminalTabColdParking(args: {
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
() => new Set()
)
// Mirrors the committed park set; written only from the post-commit effect below.
const coldParkedTerminalTabIdsRef = useRef(coldParkedTerminalTabIds)
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
@@ -224,9 +226,16 @@ export function useTerminalTabColdParking(args: {
parkVerdictRecords: parkVerdictRecordsRef.current,
nowMs
})
setColdParkedTerminalTabIds((current) =>
haveSameTerminalTabIds(current, parkedTabIds) ? current : parkedTabIds
)
// Why the ref and not the updater form: returning `current` still dispatches,
// and React only bails eagerly while the fiber has no pending lanes. This
// effect re-runs on every tab-model write (runtime titles, unread bumps),
// so inside any commit cascade the no-op dispatch was what tripped React's
// root-global nested-update counter — naming this hook in a #185 whose real
// driver is elsewhere (see src/shared/react-update-depth-attribution.ts).
if (!haveSameTerminalTabIds(coldParkedTerminalTabIdsRef.current, parkedTabIds)) {
coldParkedTerminalTabIdsRef.current = parkedTabIds
setColdParkedTerminalTabIds(parkedTabIds)
}
for (const candidate of candidates) {
if (
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAutoAckViewedAgent } from './useAutoAckViewedAgent'
import { useAppStore } from '../store'
import { makeTab } from '../store/slices/store-test-helpers'
import { makePaneKey } from '../../../shared/stable-pane-id'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
// Why: the hook re-scans on every store write and advances its diff refs to the PRE-write snapshot,
// so its ref guard never suppresses the rescan an ack triggers. It only terminated because
// acknowledgeAgents returned the same object within one millisecond — a scan costing >=1ms with a
// turn stamped ahead of the local clock (SSH/remote host) re-acked forever (React #185).
const TAB_ID = 'tab-main'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const NOW = new Date('2026-06-02T12:00:00Z').getTime()
const SKEW_MS = 90_000
const ACK_CALL_CEILING = 20
function seedFutureStampedTurn(stateStartedAt: number): void {
const entry: AgentStatusEntry = {
state: 'done',
prompt: 'remote turn',
updatedAt: stateStartedAt,
stateStartedAt,
agentType: 'codex',
paneKey: PANE_KEY,
stateHistory: []
}
useAppStore.setState({
activeView: 'terminal',
activeTabId: TAB_ID,
activeWorktreeId: 'wt-1',
activeTabIdByWorktree: {},
tabsByWorktree: { 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1' })] },
terminalLayoutsByTabId: {
[TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null }
},
agentStatusByPaneKey: { [PANE_KEY]: entry },
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey: {},
unreadAgentCompletionPanes: {},
unreadTerminalTabs: {}
})
}
/** Wraps the real action, then circuit-breaks so a regression fails the assertion instead of hanging. */
function instrumentAcknowledgeAgents(): string[][] {
const real = useAppStore.getState().acknowledgeAgents
const calls: string[][] = []
useAppStore.setState({
acknowledgeAgents: (paneKeys: string[]) => {
calls.push(paneKeys)
if (calls.length > ACK_CALL_CEILING) {
return
}
real(paneKeys)
}
})
return calls
}
describe('useAutoAckViewedAgent — clock-skewed execution host', () => {
let realAcknowledgeAgents: (paneKeys: string[]) => void
beforeEach(() => {
realAcknowledgeAgents = useAppStore.getState().acknowledgeAgents
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
// Why a ticking clock: the loop self-terminated only while Date.now() stayed on one millisecond.
let clock = NOW
vi.spyOn(Date, 'now').mockImplementation(() => clock++)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
useAppStore.setState({ acknowledgeAgents: realAcknowledgeAgents })
})
it('acks a turn stamped ahead of the local clock exactly once', () => {
seedFutureStampedTurn(NOW + SKEW_MS)
const calls = instrumentAcknowledgeAgents()
renderHook(() => useAutoAckViewedAgent(false))
expect(calls).toEqual([[PANE_KEY]])
const ackAt = useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_KEY] ?? 0
expect(ackAt).toBeGreaterThanOrEqual(NOW + SKEW_MS)
})
it('leaves nothing to re-scan for a future-stamped turn on the next store write', () => {
seedFutureStampedTurn(NOW + SKEW_MS)
renderHook(() => useAutoAckViewedAgent(false))
const calls = instrumentAcknowledgeAgents()
useAppStore.getState().markTerminalTabUnread('tab-unrelated')
expect(calls).toEqual([])
})
it('still acks a normally stamped turn once', () => {
seedFutureStampedTurn(NOW - 5_000)
const calls = instrumentAcknowledgeAgents()
renderHook(() => useAutoAckViewedAgent(false))
expect(calls).toEqual([[PANE_KEY]])
})
})
@@ -0,0 +1,148 @@
/** @vitest-environment happy-dom */
/**
* Crash cluster "React #185 whose component_stack names SortableTab"
* (boundary terminal.workbench, app 1.4.190).
*
* useTabAgent's signal effect re-runs on every tab-model write that touches the
* title — a working agent republishes an OSC spinner frame many times a second,
* re-minting tabsByWorktree — and it used to dispatch setHasObservedAgentSignal(true)
* on every run, including the overwhelming majority where the flag was already true.
* That dispatch costs a second render+commit of SortableTab per title frame, and it
* puts a dispatchSetState on SortableTab's fiber inside commitHookEffectListMount —
* the exact frame the #185 reports blame (see shared/react-update-depth-attribution.ts:
* the throw lands on whoever dispatches next after a root-global counter trips).
*
* Same defect and same remedy as the cold-park fix in
* terminal-cold-park-tab-model-identity.react185.test.tsx.
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import { useTabAgent } from './use-tab-agent'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const initialAppState = useAppStore.getInitialState()
const WORKTREE_ID = 'repo::/tab-agent-observed-signal'
const TAB_ID = 'terminal-tab-observed-signal'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const TITLE_PUBLICATIONS = 10
function paneLayout(ptyId: string): TerminalLayoutSnapshot {
return {
root: null,
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
}
}
function workingAgentStatus(): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'codex',
paneKey: PANE_KEY,
stateHistory: []
} as AgentStatusEntry
}
function terminalTab(title: string): TerminalTab {
return {
id: TAB_ID,
worktreeId: WORKTREE_ID,
ptyId: 'pty-a',
title,
generation: 0
} as TerminalTab
}
/** An ordinary agent spinner frame: re-mints tabsByWorktree, changes no agent signal. */
function publishRuntimeTitle(revision: number): void {
useAppStore.setState(
(state) =>
({
tabsByWorktree: {
...state.tabsByWorktree,
[WORKTREE_ID]: [terminalTab(`⠋ codex ${revision}`)]
}
}) as never
)
}
let probeRenders = 0
let latestAgent: TuiAgent | null = null
/** Stands in for SortableTab, useTabAgent's only production caller. */
function TabAgentProbe(): null {
probeRenders += 1
const tab = useAppStore((state) => state.tabsByWorktree[WORKTREE_ID]?.[0]) as TerminalTab
latestAgent = useTabAgent(tab)
return null
}
describe('useTabAgent observed-signal dispatch', () => {
let container: HTMLDivElement
let root: Root | undefined
beforeEach(() => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE_KEY]: workingAgentStatus() },
terminalLayoutsByTabId: { [TAB_ID]: paneLayout('pty-a') },
ptyIdsByTabId: { [TAB_ID]: ['pty-a'] },
tabsByWorktree: { [WORKTREE_ID]: [terminalTab('⠋ codex 0')] }
} as never)
container = document.createElement('div')
document.body.appendChild(container)
probeRenders = 0
latestAgent = null
root = createRoot(container)
act(() => root!.render(<TabAgentProbe />))
})
afterEach(() => {
act(() => root?.unmount())
root = undefined
container.remove()
useAppStore.setState(initialAppState, true)
})
it('schedules no extra commit for a title publication that changes no agent signal', () => {
probeRenders = 0
for (let revision = 1; revision <= TITLE_PUBLICATIONS; revision += 1) {
act(() => publishRuntimeTitle(revision))
}
// One commit per publication. Re-dispatching the unchanged observed-signal
// flag doubled this, and put SortableTab's fiber in every #185 stack.
expect(probeRenders).toBe(TITLE_PUBLICATIONS)
expect(latestAgent).toBe('codex')
})
it('still re-arms the observed signal after a pty respawn', () => {
act(() => {
useAppStore.setState({
agentStatusByPaneKey: {},
terminalLayoutsByTabId: { [TAB_ID]: paneLayout('pty-b') },
ptyIdsByTabId: { [TAB_ID]: ['pty-b'] },
tabsByWorktree: { [WORKTREE_ID]: [terminalTab('zsh')] }
} as never)
})
expect(latestAgent).toBeNull()
act(() => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE_KEY]: workingAgentStatus() },
tabsByWorktree: { [WORKTREE_ID]: [terminalTab('⠋ codex 1')] }
} as never)
})
expect(latestAgent).toBe('codex')
})
})
+7 -1
View File
@@ -284,7 +284,13 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
? explicitTitleAgent === tab.launchAgent
: Boolean(explicitTitleAgent || siblingHookAgent)
// Why: a recognized foreground process arms exit clearing even for agents with no hook or title integration.
if (focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal) {
// Why the ref gate: this effect re-runs on every title frame, and re-dispatching an
// already-true flag costs SortableTab a second commit each time — and names its fiber
// in #185 stacks driven elsewhere (see shared/react-update-depth-attribution.ts).
if (
!hasObservedAgentSignalRef.current &&
(focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal)
) {
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
}
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createUIStore } from './ui-slice-test-harness'
import type { AppState } from '../types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
// Why: an SSH/remote execution host stamps turns with ITS clock. When that clock runs ahead, every
// unread rule (`ackAt < turnTimestamp`) stayed true after an ack, so the row could never be marked
// read and its auto-ack effect re-fired on each new millisecond — the React #185 update loop.
const NOW = new Date('2026-06-02T12:00:00Z').getTime()
const SKEW_MS = 90_000
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
function makeAgentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'done',
prompt: 'Review complete',
updatedAt: NOW,
stateStartedAt: NOW,
agentType: 'codex',
paneKey: PANE_KEY,
stateHistory: [],
...overrides
}
}
function makeTerminalTab(id: string, worktreeId: string): TerminalTab {
return {
id,
worktreeId,
ptyId: null,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
}
describe('acknowledgeAgents with a clock-skewed execution host', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('clears unread for a live turn stamped ahead of the local clock in one acknowledge', () => {
const store = createUIStore()
const futureStartedAt = NOW + SKEW_MS
store.setState({
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentEntry({ stateStartedAt: futureStartedAt, updatedAt: futureStartedAt })
}
} as Partial<AppState>)
store.getState().acknowledgeAgents([PANE_KEY])
const ackAt = store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]
expect(ackAt).toBe(futureStartedAt)
expect(ackAt < futureStartedAt).toBe(false)
})
it('stops rewriting the ack map once a future-stamped turn is acknowledged', () => {
const store = createUIStore()
const futureStartedAt = NOW + SKEW_MS
store.setState({
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentEntry({ stateStartedAt: futureStartedAt, updatedAt: futureStartedAt })
}
} as Partial<AppState>)
store.getState().acknowledgeAgents([PANE_KEY])
const firstMap = store.getState().acknowledgedAgentsByPaneKey
// Later millisecond, same turn: the retry that used to allocate a new map (and re-render forever).
vi.setSystemTime(NOW + 1_000)
store.getState().acknowledgeAgents([PANE_KEY])
expect(store.getState().acknowledgedAgentsByPaneKey).toBe(firstMap)
})
it('covers a retained row and a future history event on the same pane', () => {
const store = createUIStore()
const futureHistoryAt = NOW + SKEW_MS
store.setState({
retainedAgentsByPaneKey: {
[PANE_KEY]: {
entry: makeAgentEntry({
stateStartedAt: NOW + 1_000,
stateHistory: [{ state: 'blocked', prompt: 'p', startedAt: futureHistoryAt }]
}),
worktreeId: 'wt-1',
tab: makeTerminalTab('tab-1', 'wt-1'),
agentType: 'codex',
startedAt: NOW + 1_000
}
}
} as Partial<AppState>)
store.getState().acknowledgeAgents([PANE_KEY])
expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(futureHistoryAt)
})
it('covers a future-stamped migration-unsupported row, which Activity renders as a blocked event', () => {
const store = createUIStore()
const futureUpdatedAt = NOW + SKEW_MS
store.setState({
migrationUnsupportedByPtyId: {
'pty-1': {
ptyId: 'pty-1',
paneKey: PANE_KEY,
reason: 'legacy-numeric-pane-key',
source: 'ssh',
updatedAt: futureUpdatedAt
}
}
} as Partial<AppState>)
store.getState().acknowledgeAgents([PANE_KEY])
expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(futureUpdatedAt)
})
it('still stamps the local clock when the turn is not ahead of it', () => {
const store = createUIStore()
store.setState({
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentEntry({ stateStartedAt: NOW - 5_000, updatedAt: NOW - 5_000 })
}
} as Partial<AppState>)
store.getState().acknowledgeAgents([PANE_KEY])
expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(NOW)
})
})
+33 -3
View File
@@ -372,6 +372,23 @@ function collectAcknowledgedAgentNotificationId({
}
}
function usableTimestamp(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
}
/** Newest turn timestamp an unread check can compare against for one agent row. */
function latestAgentTurnTimestamp(entry: {
stateStartedAt?: number
stateHistory?: { startedAt?: number }[]
}): number {
let latest = usableTimestamp(entry.stateStartedAt)
// Why history too: Activity renders one event per stateHistory entry, each with its own unread check.
for (const history of entry.stateHistory ?? []) {
latest = Math.max(latest, usableTimestamp(history.startedAt))
}
return latest
}
function isPlainPersistedRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -1215,10 +1232,15 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
return s
}
const now = Date.now()
// Why: only reallocate if an ack advances; compare prev<now not !== — Date.now() ticks every ms and !== would rewrite the map every call.
const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {})
// Why: only reallocate if an ack advances; compare prev<stamp not !== — the stamp ticks every ms and !== would rewrite the map every call.
let next: Record<string, number> | null = null
for (const key of paneKeys) {
const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0
// Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock,
// and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the
// row, so its auto-ack effect re-fires on each new millisecond forever (React #185).
let stamp = now
const liveEntry = s.agentStatusByPaneKey?.[key]
if (liveEntry) {
collectAcknowledgedAgentNotificationId({
@@ -1228,6 +1250,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
stateStartedAt: liveEntry.stateStartedAt,
previousAckAt: prev
})
stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry))
}
const retained = s.retainedAgentsByPaneKey?.[key]
if (retained) {
@@ -1238,12 +1261,19 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
stateStartedAt: retained.entry.stateStartedAt,
previousAckAt: prev
})
stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry))
}
if (prev < now) {
for (const unsupported of migrationUnsupported) {
// Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn.
if (unsupported.paneKey === key) {
stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt))
}
}
if (prev < stamp) {
if (next === null) {
next = { ...s.acknowledgedAgentsByPaneKey }
}
next[key] = now
next[key] = stamp
}
}
return next ? { acknowledgedAgentsByPaneKey: next } : s