diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx index efd47b976ec..2b1b5545b42 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx @@ -14,12 +14,12 @@ import es from '../../i18n/locales/es.json' import ja from '../../i18n/locales/ja.json' import ko from '../../i18n/locales/ko.json' import zh from '../../i18n/locales/zh.json' +import { useAppStore } from '@/store' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' -const runtimeStatusByEnvironmentId = new Map() - -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => selector({ runtimeStatusByEnvironmentId }) -})) +const original = useAppStore.getState() +let runtimeStatusByEnvironmentId = new Map() vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, @@ -56,12 +56,14 @@ async function render( describe('NoticeHostGlyph', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true - runtimeStatusByEnvironmentId.clear() + runtimeStatusByEnvironmentId = new Map() + useAppStore.setState({ runtimeStatusByEnvironmentId }) }) afterEach(() => { roots.splice(0).forEach((root) => act(() => root.unmount())) document.body.replaceChildren() + useAppStore.setState(original, true) }) it('names the SSH host it would act on', async () => { @@ -74,7 +76,11 @@ describe('NoticeHostGlyph', () => { }) it('names the paired runtime separately from the SSH host of the same name', async () => { - runtimeStatusByEnvironmentId.set('openclaw-env', { status: 'ready' }) + const response = createCompatibleRuntimeStatusResponse() + if (!response.ok) { + throw new Error('expected a compatible status') + } + runtimeStatusByEnvironmentId.set('openclaw-env', { status: response.result, checkedAt: 1 }) const container = await render('runtime:openclaw-env') expect(container.querySelector('[data-notice-host-kind="runtime"]')).not.toBeNull() @@ -84,7 +90,7 @@ describe('NoticeHostGlyph', () => { }) it('marks a paired runtime a probe found unreachable as disconnected', async () => { - runtimeStatusByEnvironmentId.set('openclaw-env', { status: null }) + runtimeStatusByEnvironmentId.set('openclaw-env', { status: null, checkedAt: 1 }) const container = await render('runtime:openclaw-env') expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx index 7c070450e7d..97e3d353b05 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx @@ -2,13 +2,9 @@ import React from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { HostRowIcon } from '../host-row-icon' -import { useAppStore } from '@/store' +import { useRuntimeHostDisconnected } from '@/store/use-runtime-status-view' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' -import { - isDisconnectedRuntimeHostState, - runtimeHostConnectionStateForEntry -} from '@/runtime/runtime-host-connection-state' type NoticeHostGlyphProps = { hostId: ExecutionHostId @@ -32,14 +28,9 @@ export default function NoticeHostGlyph({ const host = parseExecutionHostId(hostId) // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", // which is not the same verdict as a probe that came back unreachable. - const isDisconnected = useAppStore((s) => { - if (host?.kind !== 'runtime') { - return false - } - return isDisconnectedRuntimeHostState( - runtimeHostConnectionStateForEntry(s.runtimeStatusByEnvironmentId.get(host.environmentId)) - ) - }) + const isDisconnected = useRuntimeHostDisconnected( + host?.kind === 'runtime' ? host.environmentId : null + ) if (!host) { return null diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts index b7b521e0855..bd1695b715a 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -11,10 +11,7 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' -import { - isDisconnectedRuntimeHostState, - runtimeHostConnectionStateForEntry -} from '@/runtime/runtime-host-connection-state' +import { useRuntimeHostDisconnected } from '@/store/use-runtime-status-view' import { useAppStore } from '@/store' import { selectRuntimeAwareSshStatus, @@ -207,16 +204,7 @@ export function useWorktreeCardFoundation({ : null // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", // which is not the same verdict as a probe that came back unreachable. - const isRuntimeDisconnected = useAppStore((s) => { - if (!runtimeOwnerEnvironmentId) { - return false - } - return isDisconnectedRuntimeHostState( - runtimeHostConnectionStateForEntry( - s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) - ) - ) - }) + const isRuntimeDisconnected = useRuntimeHostDisconnected(runtimeOwnerEnvironmentId || null) const [titleRenaming, setTitleRenaming] = useState(false) const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) // Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state. diff --git a/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts b/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts index 7b56a8d9a51..b4d8c52c9bd 100644 --- a/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts +++ b/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts @@ -25,8 +25,6 @@ type PairedRuntimeParkingEnvironmentIdsCache = { environmentIds: ReadonlySet } -let pairedRuntimeParkingEnvironmentIdsCache: PairedRuntimeParkingEnvironmentIdsCache | null = null - function haveSameEnvironmentIds(left: ReadonlySet, right: ReadonlySet): boolean { if (left.size !== right.size) { return false @@ -39,34 +37,43 @@ function haveSameEnvironmentIds(left: ReadonlySet, right: ReadonlySet => { + const cached = cache + if (cached?.statuses === statuses) { + return cached.environmentIds + } + + const capable = new Set() + for (const [environmentId, entry] of statuses) { + // Why last-verified: a capability is a fact about the host's build, so an unverifiable + // probe must not unpark its terminals. See docs/reference/ssh-execution-boundary.md. + // Why the revoked gate: parking discards the client's only copy of the scrollback against + // a host-side restore, so a host that refused us must not keep promising one. + if (isRuntimeHostContactRevoked(entry)) { + continue + } + const status = lastVerifiedRuntimeStatus(entry) + if (status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) { + capable.add(environmentId) + } + } + const environmentIds = + cached && haveSameEnvironmentIds(cached.environmentIds, capable) + ? cached.environmentIds + : capable + cache = { statuses, environmentIds } + return environmentIds + } +} + +let selectParkingEnvironmentIds = createPairedRuntimeParkingEnvironmentIdsSelector() + export function selectPairedRuntimeParkingEnvironmentIds( statuses: PairedRuntimeParkingCapabilityStatuses ): ReadonlySet { - const cached = pairedRuntimeParkingEnvironmentIdsCache - if (cached?.statuses === statuses) { - return cached.environmentIds - } - - const capable = new Set() - for (const [environmentId, entry] of statuses) { - // Why last-verified: a capability is a fact about the host's build, so an unverifiable - // probe must not unpark its terminals. See docs/reference/ssh-execution-boundary.md. - // Why the revoked gate: parking discards the client's only copy of the scrollback against - // a host-side restore, so a host that refused us must not keep promising one. - if (isRuntimeHostContactRevoked(entry)) { - continue - } - const status = lastVerifiedRuntimeStatus(entry) - if (status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) { - capable.add(environmentId) - } - } - const environmentIds = - cached && haveSameEnvironmentIds(cached.environmentIds, capable) - ? cached.environmentIds - : capable - pairedRuntimeParkingEnvironmentIdsCache = { statuses, environmentIds } - return environmentIds + return selectParkingEnvironmentIds(statuses) } export function selectPairedRuntimeParkingEnvironmentIdsFromState(state: { @@ -76,5 +83,5 @@ export function selectPairedRuntimeParkingEnvironmentIdsFromState(state: { } export function resetPairedRuntimeParkingEnvironmentIdsCacheForTest(): void { - pairedRuntimeParkingEnvironmentIdsCache = null + selectParkingEnvironmentIds = createPairedRuntimeParkingEnvironmentIdsSelector() } diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.runtime-status.test.tsx b/src/renderer/src/components/use-terminal-workspace-foundation.runtime-status.test.tsx new file mode 100644 index 00000000000..bff19cefab3 --- /dev/null +++ b/src/renderer/src/components/use-terminal-workspace-foundation.runtime-status.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { useTerminalWorkspaceFoundation } from './use-terminal-workspace-foundation' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' + +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) +const original = useAppStore.getState() +const container = document.createElement('div') +const root = createRoot(container) + +afterEach(() => { + act(() => root.unmount()) + useAppStore.setState(original, true) +}) + +it('does not render the workspace root for fresh evidence with unchanged parking', () => { + let renders = 0 + function Probe() { + const { pairedRuntimeParkingEnvironmentIds } = useTerminalWorkspaceFoundation() + renders += 1 + return {[...pairedRuntimeParkingEnvironmentIds].join(',')} + } + act(() => root.render()) + const mountedRenders = renders + for (let checkedAt = 1; checkedAt <= 20; checkedAt += 1) { + const snapshot: RuntimeHostStatusSnapshot = { + environmentId: 'env-a', + pairingRevision: 1, + sequence: checkedAt, + checkedAt, + status: null, + verification: 'unavailable', + transport: 'ready' + } + act(() => + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([['env-a', { status: null, checkedAt, snapshot }]]) + }) + ) + } + expect(renders - mountedRenders).toBe(0) + const snapshot: RuntimeHostStatusSnapshot = { + environmentId: 'env-a', + pairingRevision: 1, + sequence: 21, + checkedAt: 21, + verification: 'verified', + transport: 'ready', + status: { + runtimeId: 'a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY] + } + } + act(() => + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + ['env-a', { status: snapshot.status, snapshot, checkedAt: 21 }] + ]) + }) + ) + expect(container.textContent).toBe('env-a') + expect(renders - mountedRenders).toBe(1) + act(() => useAppStore.getState().clearRuntimeEnvironmentStatus('env-a')) + expect(container.textContent).toBe('') + expect(renders - mountedRenders).toBe(2) +}) diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 28d8916cfb6..16603647c50 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -7,7 +7,7 @@ import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-e import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' import { useReusedArrayIdentity } from './sidebar/worktree-list/listing/use-reused-array-identity' -import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' +import { usePairedRuntimeParkingEnvironmentIds } from '@/store/use-runtime-status-view' import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' @@ -77,11 +77,7 @@ export function useTerminalWorkspaceFoundation() { const terminalSshParkingEnabled = useAppStore( (state) => state.settings?.terminalSshViewParking !== false ) - const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) - const pairedRuntimeParkingEnvironmentIds = useMemo( - () => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId), - [runtimeStatusByEnvironmentId] - ) + const pairedRuntimeParkingEnvironmentIds = usePairedRuntimeParkingEnvironmentIds() const terminalRetentionBudgetEnabled = useAppStore( (state) => state.settings?.terminalHiddenWorktreeRetentionBudget !== false ) @@ -115,7 +111,6 @@ export function useTerminalWorkspaceFoundation() { pendingStartupByTabId, terminalParkingEnabled, terminalSshParkingEnabled, - runtimeStatusByEnvironmentId, pairedRuntimeParkingEnvironmentIds, terminalRetentionBudgetEnabled, browserGuestRetentionBudgetEnabled, diff --git a/src/renderer/src/store/runtime-status-subscriptions.test.ts b/src/renderer/src/store/runtime-status-subscriptions.test.ts new file mode 100644 index 00000000000..e6a618822ac --- /dev/null +++ b/src/renderer/src/store/runtime-status-subscriptions.test.ts @@ -0,0 +1,204 @@ +import { createStore } from 'zustand/vanilla' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeEnvironmentStatus, + RuntimeHostStatusSnapshot +} from '../../../shared/runtime-host-status' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import * as connectionState from '@/runtime/runtime-host-connection-state' +import { createRuntimeStatusSubscriptions } from './runtime-status-subscriptions' + +function entry(patch: Partial = {}): RuntimeEnvironmentStatus { + const snapshot: RuntimeHostStatusSnapshot = { + environmentId: 'a', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: { + runtimeId: 'runtime-a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY] + }, + verification: 'verified', + transport: 'ready', + ...patch + } + return { + snapshot, + status: snapshot.verification === 'verified' ? snapshot.status : null, + checkedAt: snapshot.checkedAt + } +} + +function setup() { + const store = createStore(() => ({ + runtimeStatusByEnvironmentId: new Map([ + ['a', entry()], + ['b', entry({ environmentId: 'b' })] + ]), + unrelated: 0 + })) + const subscriptions = createRuntimeStatusSubscriptions(store) + function publish(environmentId: string, value: RuntimeEnvironmentStatus) { + store.setState({ + runtimeStatusByEnvironmentId: new Map(store.getState().runtimeStatusByEnvironmentId).set( + environmentId, + value + ) + }) + } + return { store, subscriptions, publish } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('runtime status notification isolation', () => { + it.each([100, 1000])( + 'derives once per affected host with %i readers and skips unrelated writes', + (readers) => { + const { store, subscriptions, publish } = setup() + const rootSubscribe = vi.spyOn(store, 'subscribe') + const derive = vi.spyOn(connectionState, 'runtimeHostConnectionStateForEntry') + const listeners = Array.from({ length: readers }, () => vi.fn()) + const releases = listeners.map((listener) => subscriptions.subscribeConnection('a', listener)) + const b = vi.fn() + releases.push(subscriptions.subscribeConnection('b', b)) + const parking = vi.fn() + releases.push(subscriptions.subscribeParking(parking)) + expect(rootSubscribe).toHaveBeenCalledTimes(1) + const oldPath = vi.fn((state) => + connectionState.runtimeHostConnectionStateForEntry( + state.runtimeStatusByEnvironmentId.get('a') + ) + ) + const releaseControl = store.subscribe(oldPath) + derive.mockClear() + store.setState({ unrelated: 1 }) + expect(derive).toHaveBeenCalledTimes(1) + expect(oldPath).toHaveBeenCalledTimes(1) + releaseControl() + derive.mockClear() + publish('b', entry({ environmentId: 'b', sequence: 2, checkedAt: 2 })) + expect(derive).toHaveBeenCalledTimes(1) + expect(b).not.toHaveBeenCalled() + expect(parking).not.toHaveBeenCalled() + expect(listeners.every((listener) => listener.mock.calls.length === 0)).toBe(true) + publish('a', entry({ verification: 'blocked' })) + expect(derive).toHaveBeenCalledTimes(2) + expect(listeners.every((listener) => listener.mock.calls.length === 1)).toBe(true) + expect(subscriptions.getParkingSnapshot()).toEqual(new Set(['b'])) + expect(parking).toHaveBeenCalledTimes(1) + releases.forEach((release) => release()) + } + ) + + it('retains parking through lost contact, withdraws it on refusal, and handles deletion/reset', () => { + const { store, subscriptions, publish } = setup() + const parking = vi.fn() + const connection = vi.fn() + const releaseParking = subscriptions.subscribeParking(parking) + const releaseConnection = subscriptions.subscribeConnection('a', connection) + const initialParking = subscriptions.getParkingSnapshot() + publish('a', entry({ verification: 'unavailable', transport: 'disconnected' })) + expect(subscriptions.getConnectionSnapshot('a')).toBe('reconnecting') + expect(subscriptions.getParkingSnapshot()).toBe(initialParking) + expect(parking).not.toHaveBeenCalled() + publish('a', entry({ verification: 'blocked', blockedReason: 'access-revoked' })) + expect(subscriptions.getConnectionSnapshot('a')).toBe('disconnected') + expect(subscriptions.getParkingSnapshot()).toEqual(new Set(['b'])) + store.setState({ runtimeStatusByEnvironmentId: new Map() }) + expect(subscriptions.getConnectionSnapshot('a')).toBe('checking') + expect(subscriptions.getParkingSnapshot()).toEqual(new Set()) + publish('a', entry()) + expect(subscriptions.getConnectionSnapshot('a')).toBe('connected') + expect(subscriptions.getParkingSnapshot()).toEqual(new Set(['a'])) + expect(connection).toHaveBeenCalledTimes(4) + expect(parking).toHaveBeenCalledTimes(3) + releaseParking() + releaseConnection() + }) + + it('reads current authoritative state before subscription delivery and after a late subscription', () => { + const { store, subscriptions, publish } = setup() + const seen: string[] = [] + const earlier = store.subscribe(() => { + seen.push(subscriptions.getConnectionSnapshot('a')) + expect(subscriptions.getParkingSnapshot()).toEqual(new Set(['b'])) + }) + const release = subscriptions.subscribeConnection('a', () => {}) + publish('a', entry({ retired: true })) + expect(seen).toEqual(['disconnected']) + earlier() + release() + publish('a', entry()) + expect(subscriptions.getConnectionSnapshot('a')).toBe('connected') + const notified = vi.fn() + const lateRelease = subscriptions.subscribeConnection('a', notified) + publish('a', entry({ verification: 'blocked' })) + expect(notified).toHaveBeenCalledTimes(1) + lateRelease() + }) + + it('keeps baselines current through nested commits and listener removal', () => { + const { store, subscriptions, publish } = setup() + const observed: string[] = [] + let nested = false + const first = subscriptions.subscribeConnection('a', () => { + if (!nested) { + nested = true + publish('a', entry()) + } + }) + const second = subscriptions.subscribeConnection('a', () => + observed.push(subscriptions.getConnectionSnapshot('a')) + ) + publish('a', entry({ verification: 'blocked' })) + expect(observed).toEqual(['connected', 'connected']) + const length = observed.length + store.setState({ unrelated: 2 }) + publish('a', entry({ sequence: 3 })) + expect(observed).toHaveLength(length) + first() + second() + const removed = vi.fn() + let remove = () => {} + const remover = subscriptions.subscribeConnection('a', () => remove()) + remove = subscriptions.subscribeConnection('a', removed) + publish('a', entry({ verification: 'blocked' })) + expect(removed).not.toHaveBeenCalled() + remover() + }) + + it('releases the shared root listener and supports repeated cleanup and store isolation', () => { + const { store, subscriptions } = setup() + const unsubscribe = vi.fn() + const originalSubscribe = store.subscribe + vi.spyOn(store, 'subscribe').mockImplementation((listener) => { + const release = originalSubscribe(listener) + return () => { + unsubscribe() + release() + } + }) + const release = subscriptions.subscribeConnection('a', () => {}) + release() + release() + expect(unsubscribe).toHaveBeenCalledTimes(1) + const listener = vi.fn() + const next = subscriptions.subscribeConnection('a', listener) + release() + const other = setup() + other.publish('a', entry({ retired: true })) + expect(listener).not.toHaveBeenCalled() + expect(subscriptions.getConnectionSnapshot('a')).toBe('connected') + expect(other.subscriptions.getConnectionSnapshot('a')).toBe('disconnected') + store.setState({ runtimeStatusByEnvironmentId: new Map() }) + expect(listener).toHaveBeenCalledTimes(1) + next() + expect(unsubscribe).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/store/runtime-status-subscriptions.ts b/src/renderer/src/store/runtime-status-subscriptions.ts new file mode 100644 index 00000000000..5abb7eba120 --- /dev/null +++ b/src/renderer/src/store/runtime-status-subscriptions.ts @@ -0,0 +1,141 @@ +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import { + runtimeHostConnectionStateForEntry, + type RuntimeHostConnectionState +} from '@/runtime/runtime-host-connection-state' +import { createPairedRuntimeParkingEnvironmentIdsSelector } from '@/components/terminal-pane/paired-runtime-parking-capabilities' + +type RuntimeStatusState = { + runtimeStatusByEnvironmentId: ReadonlyMap +} +type RuntimeStatusStore = { + getState: () => RuntimeStatusState + subscribe: (listener: () => void) => () => void +} +type Listener = () => void +type ConnectionSubscription = { + snapshot: RuntimeHostConnectionState + listeners: Set +} + +export function createRuntimeStatusSubscriptions(store: RuntimeStatusStore) { + const connections = new Map() + const parkingListeners = new Set() + let selectParking = createPairedRuntimeParkingEnvironmentIdsSelector() + let parkingSnapshot: ReadonlySet | null = null + let previousStatuses: RuntimeStatusState['runtimeStatusByEnvironmentId'] | null = null + let unsubscribeStore: (() => void) | null = null + + function getConnectionSnapshot(environmentId: string | null): RuntimeHostConnectionState { + return runtimeHostConnectionStateForEntry( + environmentId === null + ? undefined + : store.getState().runtimeStatusByEnvironmentId.get(environmentId) + ) + } + + function getParkingSnapshot(): ReadonlySet { + return selectParking(store.getState().runtimeStatusByEnvironmentId) + } + + function ensureSubscription(): void { + if (unsubscribeStore) { + return + } + previousStatuses = store.getState().runtimeStatusByEnvironmentId + unsubscribeStore = store.subscribe(() => { + const statuses = store.getState().runtimeStatusByEnvironmentId + const previous = previousStatuses + if (statuses === previous) { + return + } + previousStatuses = statuses + const pending: { listeners: Set; listener: Listener }[] = [] + for (const [environmentId, subscription] of connections) { + if (statuses.get(environmentId) === previous?.get(environmentId)) { + continue + } + const snapshot = runtimeHostConnectionStateForEntry(statuses.get(environmentId)) + if (snapshot === subscription.snapshot) { + continue + } + subscription.snapshot = snapshot + for (const listener of subscription.listeners) { + pending.push({ listeners: subscription.listeners, listener }) + } + } + if (parkingListeners.size > 0) { + const snapshot = getParkingSnapshot() + if (snapshot !== parkingSnapshot) { + parkingSnapshot = snapshot + for (const listener of parkingListeners) { + pending.push({ listeners: parkingListeners, listener }) + } + } + } + // Establish every comparison baseline before callbacks can commit nested writes. + for (const { listeners, listener } of pending) { + if (listeners.has(listener)) { + listener() + } + } + }) + } + + function releaseIfUnused(): void { + if (connections.size > 0 || parkingListeners.size > 0) { + return + } + unsubscribeStore?.() + unsubscribeStore = null + previousStatuses = null + parkingSnapshot = null + selectParking = createPairedRuntimeParkingEnvironmentIdsSelector() + } + + function subscribeConnection(environmentId: string | null, listener: Listener): () => void { + if (environmentId === null) { + return () => {} + } + ensureSubscription() + let subscription = connections.get(environmentId) + if (!subscription) { + subscription = { snapshot: getConnectionSnapshot(environmentId), listeners: new Set() } + connections.set(environmentId, subscription) + } + const registered = () => listener() + subscription.listeners.add(registered) + let released = false + return () => { + if (released) { + return + } + released = true + subscription.listeners.delete(registered) + if (subscription.listeners.size === 0) { + connections.delete(environmentId) + } + releaseIfUnused() + } + } + + function subscribeParking(listener: Listener): () => void { + ensureSubscription() + if (parkingListeners.size === 0) { + parkingSnapshot = getParkingSnapshot() + } + const registered = () => listener() + parkingListeners.add(registered) + let released = false + return () => { + if (released) { + return + } + released = true + parkingListeners.delete(registered) + releaseIfUnused() + } + } + + return { getConnectionSnapshot, subscribeConnection, getParkingSnapshot, subscribeParking } +} diff --git a/src/renderer/src/store/use-runtime-status-view.test.tsx b/src/renderer/src/store/use-runtime-status-view.test.tsx new file mode 100644 index 00000000000..f10c4bbe840 --- /dev/null +++ b/src/renderer/src/store/use-runtime-status-view.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom +import { act, StrictMode, useLayoutEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it } from 'vitest' +import { useAppStore } from './index' +import { readStoreListenerCount } from './store-listener-census' +import { + usePairedRuntimeParkingEnvironmentIds, + useRuntimeHostDisconnected +} from './use-runtime-status-view' + +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) + +it('shares one root subscription, rebinds host scopes, and releases Strict Mode remounts', () => { + const original = useAppStore.getState() + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + const baseline = readStoreListenerCount() + const container = document.createElement('div') + const root = createRoot(container) + let renders = 0 + function Probe({ environmentId }: { environmentId: string | null }) { + renders += 1 + const disconnected = useRuntimeHostDisconnected(environmentId) + const parking = usePairedRuntimeParkingEnvironmentIds() + return ( + + {String(disconnected)}:{parking.size} + + ) + } + try { + for (const environmentId of ['a', 'b', null, 'a']) { + act(() => + root.render( + + + + + ) + ) + expect(readStoreListenerCount()).toBe((baseline ?? 0) + 1) + expect(container.textContent).toBe('false:0false:0') + if (environmentId !== null) { + const mountedRenders = renders + act(() => + useAppStore.getState().setRuntimeEnvironmentStatus(environmentId, { + status: null, + checkedAt: 1, + snapshot: { + environmentId, + pairingRevision: 1, + checkedAt: 1, + sequence: 1, + status: null, + verification: 'unavailable', + transport: 'ready' + } + }) + ) + expect(renders).toBe(mountedRenders) + act(() => + useAppStore.getState().setRuntimeEnvironmentStatus(environmentId, { + status: null, + checkedAt: 2, + snapshot: { + environmentId, + pairingRevision: 1, + checkedAt: 2, + sequence: 2, + status: null, + verification: 'blocked', + transport: 'ready' + } + }) + ) + expect(container.textContent).toBe('true:0true:0') + act(() => useAppStore.getState().clearRuntimeEnvironmentStatus(environmentId)) + expect(container.textContent).toBe('false:0false:0') + } + } + } finally { + act(() => root.unmount()) + expect(readStoreListenerCount()).toBe(baseline) + useAppStore.setState(original, true) + } +}) + +it('observes a commit between render and subscription without waiting for another publication', () => { + const original = useAppStore.getState() + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + const container = document.createElement('div') + const root = createRoot(container) + function Probe() { + return {String(useRuntimeHostDisconnected('race'))} + } + function CommitBeforeSubscription() { + useLayoutEffect(() => { + useAppStore.getState().setRuntimeEnvironmentStatus('race', { status: null, checkedAt: 1 }) + }, []) + return null + } + try { + act(() => + root.render( + <> + + + + ) + ) + expect(container.textContent).toBe('true') + } finally { + act(() => root.unmount()) + useAppStore.setState(original, true) + } +}) diff --git a/src/renderer/src/store/use-runtime-status-view.ts b/src/renderer/src/store/use-runtime-status-view.ts new file mode 100644 index 00000000000..4209c5177ca --- /dev/null +++ b/src/renderer/src/store/use-runtime-status-view.ts @@ -0,0 +1,30 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { useAppStore } from './index' +import { createRuntimeStatusSubscriptions } from './runtime-status-subscriptions' +import { isDisconnectedRuntimeHostState } from '@/runtime/runtime-host-connection-state' + +const runtimeStatusSubscriptions = createRuntimeStatusSubscriptions(useAppStore) + +export function useRuntimeHostDisconnected(environmentId: string | null) { + const subscribe = useCallback( + (listener: () => void) => + runtimeStatusSubscriptions.subscribeConnection(environmentId, listener), + [environmentId] + ) + const getSnapshot = useCallback( + () => + isDisconnectedRuntimeHostState( + runtimeStatusSubscriptions.getConnectionSnapshot(environmentId) + ), + [environmentId] + ) + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} + +export function usePairedRuntimeParkingEnvironmentIds() { + return useSyncExternalStore( + runtimeStatusSubscriptions.subscribeParking, + runtimeStatusSubscriptions.getParkingSnapshot, + runtimeStatusSubscriptions.getParkingSnapshot + ) +} diff --git a/tests/e2e/runtime-status-workspace-responsiveness.spec.ts b/tests/e2e/runtime-status-workspace-responsiveness.spec.ts new file mode 100644 index 00000000000..64066ee2b74 --- /dev/null +++ b/tests/e2e/runtime-status-workspace-responsiveness.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from './helpers/orca-app' +import { + seedAccumulatedWorkspaceFixture, + cleanupAccumulatedWorkspaceFixture +} from './accumulated-workspace-fixture' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { waitForActiveTerminalManager, waitForTerminalOutput } from './helpers/terminal' + +test('runtime probe evidence preserves palette switching and terminal output at workspace scale', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + const source = await waitForActiveWorktree(orcaPage) + const target = (await getAllWorktreeIds(orcaPage)).find((id) => id !== source) + if (!target) { + throw new Error('Expected a second seeded workspace') + } + await switchToWorktree(orcaPage, target) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const targetName = await orcaPage.evaluate( + (id) => + window + .__store!.getState() + .getKnownWorktreeById(id) + ?.branch.replace(/^refs\/heads\//, ''), + target + ) + if (!targetName) { + throw new Error('Missing workspace name') + } + await switchToWorktree(orcaPage, source) + const fixture = await seedAccumulatedWorkspaceFixture(orcaPage) + const cdp = await orcaPage.context().newCDPSession(orcaPage) + await cdp.send('Performance.enable') + const beforeMetrics = await cdp.send('Performance.getMetrics') + const publications: number[] = [] + const gaps: number[] = [] + const traffic = orcaPage.evaluate(async () => { + const store = window.__store! + const durations: number[] = [] + const gaps: number[] = [] + let last = performance.now() + for (let sequence = 1; sequence <= 120; sequence += 1) { + await new Promise((resolve) => setTimeout(resolve, 16)) + const now = performance.now() + gaps.push(now - last) + last = now + const start = performance.now() + store.getState().setRuntimeEnvironmentStatus( + 'probe-fixture', + { + checkedAt: sequence, + status: null, + snapshot: { + environmentId: 'probe-fixture', + pairingRevision: 1, + sequence, + checkedAt: sequence, + status: null, + verification: 'unavailable', + transport: 'ready' + } + }, + { suppressDisconnectToast: true } + ) + durations.push(performance.now() - start) + } + store.getState().clearRuntimeEnvironmentStatus('probe-fixture') + return { durations, gaps } + }) + try { + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + await expect(palette).toBeVisible() + await palette.getByRole('combobox').fill(targetName) + await expect(palette.locator('[cmdk-item][data-selected="true"]')).toContainText(targetName) + await palette.screenshot({ path: testInfo.outputPath('palette-during-probes.png') }) + await palette.getByRole('combobox').press('Enter') + await expect(palette).toBeHidden() + const row = orcaPage.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(target)}]` + ) + await expect(row).toHaveAttribute('aria-current', 'page') + await waitForActiveTerminalManager(orcaPage) + const terminal = orcaPage + .locator('[data-terminal-tab-id]:visible .xterm-helper-textarea') + .first() + await terminal.fill('echo RUNTIME_PROBE_TYPING_OK') + await terminal.press('Enter') + await waitForTerminalOutput(orcaPage, 'RUNTIME_PROBE_TYPING_OK') + await orcaPage.screenshot({ path: testInfo.outputPath('terminal-after-switch.png') }) + const measured = await traffic + publications.push(...measured.durations) + gaps.push(...measured.gaps) + expect(publications).toHaveLength(120) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + const afterMetrics = await cdp.send('Performance.getMetrics') + const metric = (metrics: { metrics: { name: string; value: number }[] }, name: string) => + metrics.metrics.find((metric) => metric.name === name)?.value ?? 0 + const sorted = [...publications].sort((a, b) => a - b) + const report = { + fixture, + publications: publications.length, + publicationMs: { + median: sorted[Math.floor(sorted.length / 2)], + max: Math.max(...publications) + }, + timerGapMs: { max: Math.max(...gaps) }, + rendererTaskMs: + (metric(afterMetrics, 'TaskDuration') - metric(beforeMetrics, 'TaskDuration')) * 1000, + heapUsedDelta: + metric(afterMetrics, 'JSHeapUsedSize') - metric(beforeMetrics, 'JSHeapUsedSize') + } + console.log('[runtime-probe-workspace]', JSON.stringify(report)) + await testInfo.attach('runtime-probe-workspace', { + body: JSON.stringify(report, null, 2), + contentType: 'application/json' + }) + } finally { + await traffic + await cdp.detach() + await cleanupAccumulatedWorkspaceFixture(orcaPage) + } +})