diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index d410a1278..18948d86e 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -15,6 +15,7 @@ import { getForegroundTerminalTabLastSeenAtById } from './foreground-terminal-tabs' import { getAgentHibernationOutputSignature } from './agent-hibernation-output-activity' +import { mergePendingTerminalInputActivity } from './terminal-input-activity-coalescing' import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -72,7 +73,10 @@ function snapshotFromState( .map(([ptyId]) => ptyId), agentStatusByPaneKey: state.agentStatusByPaneKey, sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, - lastTerminalInputAtByPaneKey: state.lastTerminalInputAtByPaneKey, + // Why: input stamps are coalesced, so planning must see the not-yet-flushed keystroke. + lastTerminalInputAtByPaneKey: mergePendingTerminalInputActivity( + state.lastTerminalInputAtByPaneKey + ), foregroundTerminalLastSeenAtByTabId: getForegroundTerminalTabLastSeenAtById(), now } diff --git a/src/renderer/src/lib/automation-terminal-ownership.ts b/src/renderer/src/lib/automation-terminal-ownership.ts index 1f39a257a..1cf0c9a47 100644 --- a/src/renderer/src/lib/automation-terminal-ownership.ts +++ b/src/renderer/src/lib/automation-terminal-ownership.ts @@ -3,6 +3,7 @@ import type { AppState } from '@/store/types' import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { readLastTerminalInputAt } from './terminal-input-activity-coalescing' export type AutomationTerminalOwnershipStore = { getState: () => AppState @@ -73,14 +74,19 @@ export function createAutomationTerminalOwnership( ): AutomationTerminalOwnership { let consumed = false let userTookOver = false - const inputAtLaunch = args.store.getState().lastTerminalInputAtByPaneKey[args.paneKey] + // Why: input stamps are coalesced, so compare the freshest value (including a + // pending keystroke) or a take-over inside the coalescing window is missed. + const inputAtLaunch = readLastTerminalInputAt( + args.store.getState().lastTerminalInputAtByPaneKey, + args.paneKey + ) const observeTakeover = (): void => { const state = args.store.getState() if ( (state.activeWorktreeId === args.worktreeId && state.activeTabId === args.tabId && state.activeTabType === 'terminal') || - state.lastTerminalInputAtByPaneKey[args.paneKey] !== inputAtLaunch + readLastTerminalInputAt(state.lastTerminalInputAtByPaneKey, args.paneKey) !== inputAtLaunch ) { userTookOver = true } diff --git a/src/renderer/src/lib/terminal-input-activity-coalescing.test.ts b/src/renderer/src/lib/terminal-input-activity-coalescing.test.ts new file mode 100644 index 000000000..125ab6463 --- /dev/null +++ b/src/renderer/src/lib/terminal-input-activity-coalescing.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS, + flushTerminalInputActivity, + getPendingTerminalInputActivityCountForTests, + mergePendingTerminalInputActivity, + readLastTerminalInputAt, + recordTerminalInputActivity, + resetTerminalInputActivityCoalescingForTests +} from './terminal-input-activity-coalescing' + +const PANE = 'tab-1:leaf-1' +const OTHER_PANE = 'tab-2:leaf-2' + +// Minimal stand-in for the store slot the real commit writes into. +function createStore(initial: Record = {}) { + const stored: Record = { ...initial } + const writes: string[] = [] + return { + stored, + writes, + commit: { + insert: (paneKey: string, timestamp: number) => { + writes.push(`insert:${paneKey}`) + stored[paneKey] = timestamp + }, + refreshExisting: (entries: readonly (readonly [string, number])[]) => { + writes.push('flush') + for (const [paneKey, timestamp] of entries) { + const current = stored[paneKey] + if (current === undefined || current >= timestamp) { + continue + } + stored[paneKey] = timestamp + } + } + } + } +} + +function type(store: ReturnType, paneKey: string, timestamp: number): void { + recordTerminalInputActivity({ + paneKey, + timestamp, + forceWrite: store.stored[paneKey] === undefined, + commit: store.commit + }) +} + +describe('terminal input activity coalescing', () => { + beforeEach(() => { + vi.useFakeTimers() + resetTerminalInputActivityCoalescingForTests() + }) + + it('writes the first keystroke immediately and coalesces the burst', () => { + const store = createStore() + + // 40 keystrokes at 20ms apart = one 800ms burst. + for (let i = 0; i < 40; i++) { + type(store, PANE, 1_000 + i * 20) + } + + // Leading edge + one interval boundary crossing; no per-keystroke writes. + expect(store.writes.filter((w) => w.startsWith('insert')).length).toBeLessThanOrEqual(2) + expect(store.writes.length).toBeLessThan(5) + expect(store.stored[PANE]).toBeDefined() + }) + + it('keeps imperative reads fresh while a write is pending', () => { + const store = createStore() + + type(store, PANE, 1_000) + type(store, PANE, 1_100) + + // The store itself still holds the leading-edge stamp... + expect(store.stored[PANE]).toBe(1_000) + // ...but readers see the pending keystroke. + expect(getPendingTerminalInputActivityCountForTests()).toBe(1) + expect(readLastTerminalInputAt(store.stored, PANE)).toBe(1_100) + expect(mergePendingTerminalInputActivity(store.stored)[PANE]).toBe(1_100) + }) + + it('returns the same map reference when nothing is pending', () => { + const store = createStore({ [PANE]: 500 }) + expect(mergePendingTerminalInputActivity(store.stored)).toBe(store.stored) + }) + + it('flushes pending stamps into the store on the timer', () => { + const store = createStore() + + type(store, PANE, 1_000) + type(store, PANE, 1_100) + vi.advanceTimersByTime(TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS + 1) + + expect(store.stored[PANE]).toBe(1_100) + expect(getPendingTerminalInputActivityCountForTests()).toBe(0) + }) + + it('does not resurrect a pane key that teardown deleted', () => { + const store = createStore() + + type(store, PANE, 1_000) + type(store, OTHER_PANE, 1_000) + type(store, PANE, 1_100) + type(store, OTHER_PANE, 1_100) + expect(getPendingTerminalInputActivityCountForTests()).toBe(2) + + // Teardown (close pane / close tab / worktree purge) removes the key. + delete store.stored[PANE] + + flushTerminalInputActivity() + + expect(PANE in store.stored).toBe(false) + expect(readLastTerminalInputAt(store.stored, PANE)).toBeUndefined() + expect(mergePendingTerminalInputActivity(store.stored)[PANE]).toBeUndefined() + // The surviving sibling still advances. + expect(store.stored[OTHER_PANE]).toBe(1_100) + }) + + it('clears the flush timer so a reset leaves no pending work', () => { + const store = createStore() + + type(store, PANE, 1_000) + type(store, PANE, 1_100) + resetTerminalInputActivityCoalescingForTests() + vi.advanceTimersByTime(TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS * 4) + + expect(store.stored[PANE]).toBe(1_000) + expect(vi.getTimerCount()).toBe(0) + }) + + it('writes immediately again once the coalescing window has passed', () => { + const store = createStore() + + type(store, PANE, 1_000) + type(store, PANE, 1_000 + TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS) + + expect(store.writes).toEqual([`insert:${PANE}`, `insert:${PANE}`]) + expect(store.stored[PANE]).toBe(1_000 + TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS) + }) +}) diff --git a/src/renderer/src/lib/terminal-input-activity-coalescing.ts b/src/renderer/src/lib/terminal-input-activity-coalescing.ts new file mode 100644 index 000000000..1cd596207 --- /dev/null +++ b/src/renderer/src/lib/terminal-input-activity-coalescing.ts @@ -0,0 +1,138 @@ +// Why: xterm reports every keystroke, and one store write per key wakes every zustand +// subscriber in the app. Hibernation — the only real consumer — is a >=60s idle timeout, +// so the leading edge of a typing burst writes immediately (keeping subscriber-visible +// behavior identical for the first key) and the rest collapse into one trailing flush. +// Imperative readers merge the pending value, so they never observe a stale stamp. + +export const TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS = 500 + +// Why: the gate map only needs panes typed into within the window; prune above this size. +const GATE_PRUNE_SIZE = 256 + +export type TerminalInputActivityEntries = readonly (readonly [string, number])[] + +export type TerminalInputActivityCommit = { + /** Leading-edge write; may create the pane key. */ + insert: (paneKey: string, timestamp: number) => void + /** Trailing flush; must only advance pane keys the store still has. */ + refreshExisting: (entries: TerminalInputActivityEntries) => void +} + +const pendingByPaneKey = new Map() +const lastWrittenByPaneKey = new Map() +let flushTimer: ReturnType | null = null +let pendingCommit: TerminalInputActivityCommit | null = null + +function pruneGate(now: number): void { + if (lastWrittenByPaneKey.size <= GATE_PRUNE_SIZE) { + return + } + for (const [paneKey, writtenAt] of lastWrittenByPaneKey) { + // Why: entries past the window already pass the gate, so dropping them changes nothing. + if ( + now - writtenAt >= TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS && + !pendingByPaneKey.has(paneKey) + ) { + lastWrittenByPaneKey.delete(paneKey) + } + } +} + +export function recordTerminalInputActivity(args: { + paneKey: string + timestamp: number + /** True when the store has no stamp for this pane yet, so the first stamp must land now. */ + forceWrite?: boolean + commit: TerminalInputActivityCommit +}): void { + const { paneKey, timestamp, commit } = args + const lastWrittenAt = lastWrittenByPaneKey.get(paneKey) + if ( + args.forceWrite === true || + lastWrittenAt === undefined || + timestamp - lastWrittenAt >= TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS + ) { + pendingByPaneKey.delete(paneKey) + lastWrittenByPaneKey.set(paneKey, timestamp) + pruneGate(timestamp) + commit.insert(paneKey, timestamp) + return + } + pendingByPaneKey.set(paneKey, timestamp) + pendingCommit = commit + if (flushTimer === null) { + flushTimer = setTimeout(() => { + flushTimer = null + flushTerminalInputActivity() + }, TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS) + // Why: renderer-only timer; never hold the Node event loop open under test runners. + ;(flushTimer as unknown as { unref?: () => void }).unref?.() + } +} + +/** Lands every coalesced stamp now. Safe to call from teardown paths. */ +export function flushTerminalInputActivity(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + const commit = pendingCommit + pendingCommit = null + if (pendingByPaneKey.size === 0 || !commit) { + pendingByPaneKey.clear() + return + } + const entries = [...pendingByPaneKey] + pendingByPaneKey.clear() + for (const [paneKey, timestamp] of entries) { + lastWrittenByPaneKey.set(paneKey, timestamp) + } + commit.refreshExisting(entries) +} + +/** Freshest input stamp for a pane, including a not-yet-flushed keystroke. */ +export function readLastTerminalInputAt( + stored: Record, + paneKey: string +): number | undefined { + const storedAt = stored[paneKey] + // Why: a pane key teardown removed must stay removed — never revive it from pending. + if (storedAt === undefined) { + return undefined + } + const pendingAt = pendingByPaneKey.get(paneKey) + return pendingAt !== undefined && pendingAt > storedAt ? pendingAt : storedAt +} + +/** Same map with pending stamps applied; returns the input reference when nothing is pending. */ +export function mergePendingTerminalInputActivity>( + stored: T +): T { + if (pendingByPaneKey.size === 0) { + return stored + } + let next: Record | null = null + for (const [paneKey, pendingAt] of pendingByPaneKey) { + const storedAt = stored[paneKey] + if (storedAt === undefined || storedAt >= pendingAt) { + continue + } + next ??= { ...stored } + next[paneKey] = pendingAt + } + return (next as T | null) ?? stored +} + +export function resetTerminalInputActivityCoalescingForTests(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + pendingByPaneKey.clear() + lastWrittenByPaneKey.clear() + pendingCommit = null +} + +export function getPendingTerminalInputActivityCountForTests(): number { + return pendingByPaneKey.size +} diff --git a/src/renderer/src/lib/typing-latency-census-probe.ts b/src/renderer/src/lib/typing-latency-census-probe.ts new file mode 100644 index 000000000..6ba950abd --- /dev/null +++ b/src/renderer/src/lib/typing-latency-census-probe.ts @@ -0,0 +1,72 @@ +/** + * Live-state reads for the typing-latency census: focused-pane identity/screen + * mode, mounted agent-row count, and the zustand listener count. Split from the + * probe so the sampling loop stays free of store/DOM lookups. + */ +import { useAppStore } from '@/store' +import { + listProbePanes, + paneRootElement, + type ProbePane +} from './typing-latency-echo-instrumentation' +import type { FocusedPaneCensus } from './typing-latency-diagnostic-summary' + +type ProbeStoreState = { + activeTabId?: string | null + paneForegroundAgentByPaneKey?: Record + agentStatusByPaneKey?: Record +} + +export function readProbeStoreState(): (ProbeStoreState & Record) | null { + try { + return useAppStore.getState() as unknown as ProbeStoreState & Record + } catch { + return null + } +} + +function focusedProbePane(panes: readonly ProbePane[]): ProbePane | null { + const focused = typeof document === 'undefined' ? null : document.activeElement + const matched = focused + ? panes.find((pane) => paneRootElement(pane)?.contains(focused) === true) + : undefined + return matched ?? panes[0] ?? null +} + +export function readFocusedPaneCensus(): FocusedPaneCensus | null { + const pane = focusedProbePane(listProbePanes()) + if (!pane) { + return null + } + const state = readProbeStoreState() + const leafId = pane.leafId ?? pane.container?.dataset.leafId ?? null + const tabId = state?.activeTabId ?? null + const paneKey = tabId && leafId ? `${tabId}:${leafId}` : null + const foreground = paneKey ? state?.paneForegroundAgentByPaneKey?.[paneKey] : undefined + const status = paneKey ? state?.agentStatusByPaneKey?.[paneKey] : undefined + const bufferType = pane.terminal?.buffer?.active?.type + return { + paneId: pane.id ?? null, + leafId, + bufferType: bufferType === 'alternate' || bufferType === 'normal' ? bufferType : null, + cols: pane.terminal?.cols ?? null, + rows: pane.terminal?.rows ?? null, + bufferLines: pane.terminal?.buffer?.active?.length ?? null, + foregroundAgent: foreground?.agent ?? null, + statusAgentType: status?.agentType ?? null + } +} + +/** Compact-mode cards collapse agents, so only MOUNTED rows carry this attribute. */ +export function countMountedAgentRows(): number | null { + if (typeof document === 'undefined') { + return null + } + try { + return document.querySelectorAll('[data-agent-send-target]').length + } catch { + return null + } +} + +export { readStoreListenerCount } from '@/store/store-listener-census' diff --git a/src/renderer/src/lib/typing-latency-diagnostic-summary.test.ts b/src/renderer/src/lib/typing-latency-diagnostic-summary.test.ts new file mode 100644 index 000000000..46facf743 --- /dev/null +++ b/src/renderer/src/lib/typing-latency-diagnostic-summary.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { + summarizeLatencySamples, + summarizeTypingScaleCensus, + summarizeWorktreeNesting +} from './typing-latency-diagnostic-summary' + +describe('summarizeLatencySamples', () => { + it('reports null percentiles with no samples', () => { + expect(summarizeLatencySamples([])).toEqual({ count: 0, p50: null, p95: null, max: null }) + }) + + it('computes nearest-rank percentiles and max', () => { + const values = Array.from({ length: 100 }, (_, index) => index + 1) + expect(summarizeLatencySamples(values)).toEqual({ count: 100, p50: 50, p95: 95, max: 100 }) + }) + + it('rounds to two decimals and ignores non-finite samples', () => { + const summary = summarizeLatencySamples([ + 1.23456, + Number.NaN, + Number.POSITIVE_INFINITY, + 9.87654 + ]) + expect(summary).toEqual({ count: 2, p50: 1.23, p95: 9.88, max: 9.88 }) + }) + + it('handles a single sample', () => { + expect(summarizeLatencySamples([42])).toEqual({ count: 1, p50: 42, p95: 42, max: 42 }) + }) +}) + +describe('summarizeWorktreeNesting', () => { + it('returns zero depth for flat sibling worktrees', () => { + expect(summarizeWorktreeNesting(['/a/one', '/a/two'])).toEqual({ + maxDepth: 0, + nestedWorktrees: 0 + }) + }) + + it('counts nesting depth for worktrees inside worktrees', () => { + expect( + summarizeWorktreeNesting(['/repo', '/repo/wt-a', '/repo/wt-a/wt-b', '/repo/wt-a/wt-b/wt-c']) + ).toEqual({ maxDepth: 3, nestedWorktrees: 3 }) + }) + + it('normalizes windows separators, case, and trailing slashes', () => { + expect(summarizeWorktreeNesting(['C:\\Repo\\', 'c:/repo/Nested'])).toEqual({ + maxDepth: 1, + nestedWorktrees: 1 + }) + }) + + it('ignores prefix matches that are not path boundaries', () => { + expect(summarizeWorktreeNesting(['/repo', '/repo-two'])).toEqual({ + maxDepth: 0, + nestedWorktrees: 0 + }) + }) + + it('tolerates empty and duplicate entries', () => { + expect(summarizeWorktreeNesting(['', '/a', '/a'])).toEqual({ maxDepth: 0, nestedWorktrees: 0 }) + }) +}) + +describe('summarizeTypingScaleCensus', () => { + const focusedPane = { + paneId: 3, + leafId: 'leaf-3', + bufferType: 'alternate' as const, + cols: 120, + rows: 40, + bufferLines: 5000, + foregroundAgent: 'codex', + statusAgentType: 'codex' + } + + it('aggregates agent rows, tabs, panes, nesting, and suspect settings', () => { + const census = summarizeTypingScaleCensus({ + state: { + worktreesByRepo: { + repoA: [{ path: '/repo-a' }, { path: '/repo-a/nested' }], + repoB: [{ path: '/repo-b' }] + }, + tabsByWorktree: { wt1: [{}, {}], wt2: [{}] }, + unifiedTabsByWorktree: { wt1: [{}] }, + agentStatusByPaneKey: { 'tab:leaf-1': {}, 'tab:leaf-2': {} }, + retainedAgentsByPaneKey: { 'tab:leaf-3': {} }, + activeTabId: 'tab', + activeTabType: 'terminal', + settings: { + tabAutoGenerateTitle: true, + compactWorktreeCards: false, + agentActivityDisplayMode: 'compact', + terminalScrollbackRows: 10_000, + terminalGpuAcceleration: 'auto' + } + }, + appVersion: '1.4.156', + livePaneCount: 4, + instrumentedPaneCount: 4, + mountedAgentRowCount: 7, + storeListenerCount: 312, + focusedPane + }) + + expect(census.appVersion).toBe('1.4.156') + expect(census.repos).toBe(2) + expect(census.worktrees).toBe(3) + expect(census.worktreeNesting).toEqual({ maxDepth: 1, nestedWorktrees: 1 }) + expect(census.tabs).toEqual({ terminal: 3, unified: 1 }) + expect(census.panes).toEqual({ live: 4, instrumented: 4 }) + expect(census.agentRows).toEqual({ + storeLive: 2, + storeRetained: 1, + storeTotal: 3, + mountedDom: 7 + }) + expect(census.storeListeners).toBe(312) + expect(census.settings.tabAutoGenerateTitle).toBe(true) + expect(census.settings.terminalScrollbackRows).toBe(10_000) + expect(census.activeTab).toEqual({ id: 'tab', type: 'terminal' }) + expect(census.focusedPane).toEqual(focusedPane) + }) + + it('emits nulls instead of throwing when the store is unavailable', () => { + const census = summarizeTypingScaleCensus({ + state: null, + appVersion: null, + livePaneCount: null, + instrumentedPaneCount: 0, + mountedAgentRowCount: null, + storeListenerCount: null, + focusedPane: null + }) + + expect(census).toEqual({ + appVersion: null, + repos: 0, + worktrees: 0, + worktreeNesting: { maxDepth: 0, nestedWorktrees: 0 }, + tabs: { terminal: 0, unified: 0 }, + panes: { live: null, instrumented: 0 }, + agentRows: { storeLive: 0, storeRetained: 0, storeTotal: 0, mountedDom: null }, + storeListeners: null, + settings: { + tabAutoGenerateTitle: null, + compactWorktreeCards: null, + agentActivityDisplayMode: null, + terminalScrollbackRows: null, + terminalGpuAcceleration: null + }, + activeTab: { id: null, type: null }, + focusedPane: null + }) + }) + + it('reports nulls for settings of unexpected types rather than coercing', () => { + const census = summarizeTypingScaleCensus({ + state: { + settings: { + tabAutoGenerateTitle: 'yes', + terminalScrollbackRows: Number.NaN, + terminalGpuAcceleration: 42 + } + }, + appVersion: null, + livePaneCount: 1, + instrumentedPaneCount: 1, + mountedAgentRowCount: 0, + storeListenerCount: 0, + focusedPane: null + }) + + expect(census.settings.tabAutoGenerateTitle).toBeNull() + expect(census.settings.terminalScrollbackRows).toBeNull() + expect(census.settings.terminalGpuAcceleration).toBeNull() + }) + + it('tolerates malformed per-worktree collections', () => { + const census = summarizeTypingScaleCensus({ + state: { + worktreesByRepo: { repoA: [{ path: null }, {}] as never }, + tabsByWorktree: { wt1: null as never }, + unifiedTabsByWorktree: undefined, + settings: null + }, + appVersion: null, + livePaneCount: null, + instrumentedPaneCount: 0, + mountedAgentRowCount: null, + storeListenerCount: null, + focusedPane: null + }) + + expect(census.worktrees).toBe(0) + expect(census.tabs).toEqual({ terminal: 0, unified: 0 }) + }) +}) diff --git a/src/renderer/src/lib/typing-latency-diagnostic-summary.ts b/src/renderer/src/lib/typing-latency-diagnostic-summary.ts new file mode 100644 index 000000000..0808f30e2 --- /dev/null +++ b/src/renderer/src/lib/typing-latency-diagnostic-summary.ts @@ -0,0 +1,231 @@ +/** + * Pure summarization for the devtools typing-latency probe: percentiles over + * per-keystroke samples, plus the scale census (agent rows, tabs, panes, + * worktree nesting, suspect settings) that explains WHY a renderer is slow. + * + * Kept separate from the DOM/store wiring in typing-latency-diagnostic.ts so + * the arithmetic is unit-testable without an xterm or a live store. + */ + +export type LatencyPercentiles = { + count: number + p50: number | null + p95: number | null + max: number | null +} + +/** Nearest-rank percentile; a probe reports what it actually observed, not an interpolation. */ +function percentile(sorted: number[], fraction: number): number | null { + if (sorted.length === 0) { + return null + } + const rank = Math.ceil(fraction * sorted.length) + const index = Math.min(sorted.length - 1, Math.max(0, rank - 1)) + return sorted[index] ?? null +} + +function round(value: number | null): number | null { + return value == null ? null : Math.round(value * 100) / 100 +} + +export function summarizeLatencySamples(values: readonly number[]): LatencyPercentiles { + const finite = values.filter((value) => Number.isFinite(value)) + const sorted = [...finite].sort((a, b) => a - b) + return { + count: sorted.length, + p50: round(percentile(sorted, 0.5)), + p95: round(percentile(sorted, 0.95)), + max: round(sorted.at(-1) ?? null) + } +} + +export type WorktreeNestingCensus = { + maxDepth: number + nestedWorktrees: number +} + +/** Windows and default macOS filesystems are case-insensitive, so compare case-folded. */ +function normalizePathForNesting(rawPath: string): string { + const unified = rawPath.replaceAll('\\', '/').toLowerCase() + return unified.length > 1 && unified.endsWith('/') ? unified.slice(0, -1) : unified +} + +export function summarizeWorktreeNesting(paths: readonly string[]): WorktreeNestingCensus { + const normalized = paths + .filter((path) => typeof path === 'string' && path.length > 0) + .map((path) => normalizePathForNesting(path)) + const unique = [...new Set(normalized)] + let maxDepth = 0 + let nestedWorktrees = 0 + for (const candidate of unique) { + let depth = 0 + for (const ancestor of unique) { + if (ancestor !== candidate && candidate.startsWith(`${ancestor}/`)) { + depth += 1 + } + } + maxDepth = Math.max(maxDepth, depth) + if (depth > 0) { + nestedWorktrees += 1 + } + } + return { maxDepth, nestedWorktrees } +} + +export type FocusedPaneCensus = { + paneId: number | null + leafId: string | null + /** 'alternate' means a full-screen TUI (grok, Codex); 'normal' is a plain shell. */ + bufferType: 'normal' | 'alternate' | null + cols: number | null + rows: number | null + bufferLines: number | null + /** Agent identity from the pane's foreground process table, when Orca resolved one. */ + foregroundAgent: string | null + /** Agent identity from the hook-reported status row for the same pane. */ + statusAgentType: string | null +} + +type CountableRecord = Record | null | undefined + +type WorktreeLike = { path?: string | null } + +export type TypingCensusStoreShape = { + worktreesByRepo?: Record | null + tabsByWorktree?: Record | null + unifiedTabsByWorktree?: Record | null + agentStatusByPaneKey?: CountableRecord + retainedAgentsByPaneKey?: CountableRecord + activeTabId?: string | null + activeTabType?: string | null + settings?: Record | null +} + +export type TypingSettingsCensus = { + /** Question 1 from the field report: is auto tab-title generation on? */ + tabAutoGenerateTitle: boolean | null + compactWorktreeCards: boolean | null + agentActivityDisplayMode: string | null + terminalScrollbackRows: number | null + terminalGpuAcceleration: string | null +} + +export type TypingScaleCensus = { + appVersion: string | null + repos: number + worktrees: number + worktreeNesting: WorktreeNestingCensus + tabs: { terminal: number; unified: number } + panes: { live: number | null; instrumented: number } + agentRows: { + storeLive: number + storeRetained: number + storeTotal: number + /** Mounted rows only; compact mode collapses most agents behind CompactAgentExpansion. */ + mountedDom: number | null + } + storeListeners: number | null + settings: TypingSettingsCensus + activeTab: { id: string | null; type: string | null } + focusedPane: FocusedPaneCensus | null +} + +function countRecord(record: CountableRecord): number { + return record ? Object.keys(record).length : 0 +} + +function sumArrayLengths(byKey: Record | null | undefined): number { + if (!byKey) { + return 0 + } + let total = 0 + for (const list of Object.values(byKey)) { + total += Array.isArray(list) ? list.length : 0 + } + return total +} + +function readBoolean( + settings: Record | null | undefined, + key: string +): boolean | null { + const value = settings?.[key] + return typeof value === 'boolean' ? value : null +} + +function readString( + settings: Record | null | undefined, + key: string +): string | null { + const value = settings?.[key] + return typeof value === 'string' ? value : null +} + +function readNumber( + settings: Record | null | undefined, + key: string +): number | null { + const value = settings?.[key] + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function collectWorktreePaths(byRepo: Record | null | undefined): string[] { + if (!byRepo) { + return [] + } + const paths: string[] = [] + for (const worktrees of Object.values(byRepo)) { + if (!Array.isArray(worktrees)) { + continue + } + for (const worktree of worktrees) { + if (typeof worktree?.path === 'string') { + paths.push(worktree.path) + } + } + } + return paths +} + +export function summarizeTypingScaleCensus(input: { + state: TypingCensusStoreShape | null + appVersion: string | null + livePaneCount: number | null + instrumentedPaneCount: number + mountedAgentRowCount: number | null + storeListenerCount: number | null + focusedPane: FocusedPaneCensus | null +}): TypingScaleCensus { + const state = input.state + const settings = state?.settings ?? null + const worktreePaths = collectWorktreePaths(state?.worktreesByRepo) + const storeLive = countRecord(state?.agentStatusByPaneKey) + const storeRetained = countRecord(state?.retainedAgentsByPaneKey) + return { + appVersion: input.appVersion, + repos: state?.worktreesByRepo ? Object.keys(state.worktreesByRepo).length : 0, + worktrees: worktreePaths.length, + worktreeNesting: summarizeWorktreeNesting(worktreePaths), + tabs: { + terminal: sumArrayLengths(state?.tabsByWorktree), + unified: sumArrayLengths(state?.unifiedTabsByWorktree) + }, + panes: { live: input.livePaneCount, instrumented: input.instrumentedPaneCount }, + agentRows: { + storeLive, + storeRetained, + storeTotal: storeLive + storeRetained, + mountedDom: input.mountedAgentRowCount + }, + storeListeners: input.storeListenerCount, + settings: { + tabAutoGenerateTitle: readBoolean(settings, 'tabAutoGenerateTitle'), + compactWorktreeCards: readBoolean(settings, 'compactWorktreeCards'), + agentActivityDisplayMode: readString(settings, 'agentActivityDisplayMode'), + terminalScrollbackRows: readNumber(settings, 'terminalScrollbackRows'), + terminalGpuAcceleration: readString(settings, 'terminalGpuAcceleration') + }, + activeTab: { id: state?.activeTabId ?? null, type: state?.activeTabType ?? null }, + focusedPane: input.focusedPane + } +} diff --git a/src/renderer/src/lib/typing-latency-diagnostic.ts b/src/renderer/src/lib/typing-latency-diagnostic.ts new file mode 100644 index 000000000..28630cd49 --- /dev/null +++ b/src/renderer/src/lib/typing-latency-diagnostic.ts @@ -0,0 +1,203 @@ +/** + * One-paste typing-latency self-diagnostic: + * + * window.__orcaTypingDiagnostic.start() // then type normally for ~20s + * window.__orcaTypingDiagnostic.report() // logs + returns a JSON-safe object + * window.__orcaTypingDiagnostic.stop() + * + * Why: keystroke-echo lag reproduces on one user's machine only, so the + * measurement has to run THERE. The census answers what a user cannot: agent-row + * scale, store listener count, suspect settings, and which agent/screen mode the + * focused pane is in. + * + * Nothing attaches to the keystroke path until start(); stop() detaches all of it. + */ +import { + countMountedAgentRows, + readFocusedPaneCensus, + readProbeStoreState, + readStoreListenerCount +} from './typing-latency-census-probe' +import { + detachPaneEcho, + findPaneOwningFocus, + instrumentPaneEcho, + listProbePanes, + recordKeystroke, + type InstrumentedPane +} from './typing-latency-echo-instrumentation' +import { + summarizeLatencySamples, + summarizeTypingScaleCensus, + type LatencyPercentiles, + type TypingScaleCensus +} from './typing-latency-diagnostic-summary' + +/** Bounds memory during sustained typing: percentiles only need a rolling window. */ +const MAX_SAMPLES = 2000 + +type ProbeState = { + startedAt: number + startedAtIso: string + parseLatencies: number[] + paintLatencies: number[] + keystrokeBytes: number[] + keystrokeWrites: number[] + unmatchedKeystrokes: number + keystrokesWithoutTerminalFocus: number + panes: InstrumentedPane[] + detachKeydown: () => void +} + +let active: ProbeState | null = null +let lastState: ProbeState | null = null +let cachedAppVersion: string | null = null + +function push(values: number[], value: number): void { + values.push(value) + if (values.length > MAX_SAMPLES) { + values.shift() + } +} + +export type TypingLatencyReport = { + capturedAt: string + sampling: { + running: boolean + startedAt: string | null + durationMs: number | null + keystrokesWithoutTerminalFocus: number + unmatchedKeystrokes: number + instrumentedPanes: number + } + echoParseMs: LatencyPercentiles + echoPaintMs: LatencyPercentiles + bytesPerKeystroke: LatencyPercentiles + writesPerKeystroke: LatencyPercentiles + census: TypingScaleCensus +} + +function buildReport(state: ProbeState | null, running: boolean): TypingLatencyReport { + return { + capturedAt: new Date().toISOString(), + sampling: { + running, + startedAt: state?.startedAtIso ?? null, + durationMs: state ? Math.round(performance.now() - state.startedAt) : null, + keystrokesWithoutTerminalFocus: state?.keystrokesWithoutTerminalFocus ?? 0, + unmatchedKeystrokes: state?.unmatchedKeystrokes ?? 0, + instrumentedPanes: state?.panes.length ?? 0 + }, + echoParseMs: summarizeLatencySamples(state?.parseLatencies ?? []), + echoPaintMs: summarizeLatencySamples(state?.paintLatencies ?? []), + bytesPerKeystroke: summarizeLatencySamples(state?.keystrokeBytes ?? []), + writesPerKeystroke: summarizeLatencySamples(state?.keystrokeWrites ?? []), + census: summarizeTypingScaleCensus({ + state: readProbeStoreState(), + appVersion: cachedAppVersion, + livePaneCount: listProbePanes().length, + instrumentedPaneCount: state?.panes.length ?? 0, + mountedAgentRowCount: countMountedAgentRows(), + storeListenerCount: readStoreListenerCount(), + focusedPane: readFocusedPaneCensus() + }) + } +} + +function cacheAppVersion(): void { + void window.api?.updater + ?.getVersion?.() + .then((version) => { + cachedAppVersion = version + }) + .catch(() => undefined) +} + +/** Modifier-only presses produce no echo and would poison the pending queue. */ +function isEchoingKey(event: KeyboardEvent): boolean { + return event.key.length === 1 || event.key === 'Enter' || event.key === 'Backspace' +} + +function startProbe(): string { + if (active) { + return 'Typing diagnostic already running. Type for ~20s, then run __orcaTypingDiagnostic.report().' + } + cacheAppVersion() + + const state: ProbeState = { + startedAt: performance.now(), + startedAtIso: new Date().toISOString(), + parseLatencies: [], + paintLatencies: [], + keystrokeBytes: [], + keystrokeWrites: [], + unmatchedKeystrokes: 0, + keystrokesWithoutTerminalFocus: 0, + panes: [], + detachKeydown: () => undefined + } + state.panes = listProbePanes().map((pane) => + instrumentPaneEcho(pane, (sample) => { + push(state.parseLatencies, sample.parseMs) + push(state.paintLatencies, sample.paintMs) + push(state.keystrokeBytes, sample.bytes) + push(state.keystrokeWrites, sample.writes) + }) + ) + + const onKeydown = (event: KeyboardEvent): void => { + if (!isEchoingKey(event)) { + return + } + const target = findPaneOwningFocus(state.panes) + if (!target) { + state.keystrokesWithoutTerminalFocus += 1 + return + } + state.unmatchedKeystrokes += recordKeystroke(target, performance.now()) + } + window.addEventListener('keydown', onKeydown, { capture: true }) + state.detachKeydown = () => window.removeEventListener('keydown', onKeydown, { capture: true }) + + active = state + lastState = state + return `Typing diagnostic started on ${state.panes.length} pane(s). Click into the agent terminal, type normally for ~20 seconds, then run __orcaTypingDiagnostic.report().` +} + +function stopProbe(): string { + const state = active + if (!state) { + return 'Typing diagnostic was not running.' + } + active = null + state.detachKeydown() + for (const entry of state.panes) { + detachPaneEcho(entry) + } + return 'Typing diagnostic stopped. Run __orcaTypingDiagnostic.report() to read the last samples.' +} + +function reportProbe(): TypingLatencyReport { + const report = buildReport(active ?? lastState, active !== null) + console.log('[orca] typing latency diagnostic', report) + return report +} + +export type TypingDiagnosticBridge = { + start: () => string + stop: () => string + report: () => TypingLatencyReport +} + +type TypingDiagnosticWindow = Window & { __orcaTypingDiagnostic?: TypingDiagnosticBridge } + +export function installTypingLatencyDiagnostic(): void { + if (typeof window === 'undefined') { + return + } + const target = window as TypingDiagnosticWindow + if (target.__orcaTypingDiagnostic) { + return + } + target.__orcaTypingDiagnostic = { start: startProbe, stop: stopProbe, report: reportProbe } +} diff --git a/src/renderer/src/lib/typing-latency-echo-instrumentation.test.ts b/src/renderer/src/lib/typing-latency-echo-instrumentation.test.ts new file mode 100644 index 000000000..7e220eaa9 --- /dev/null +++ b/src/renderer/src/lib/typing-latency-echo-instrumentation.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + detachPaneEcho, + instrumentPaneEcho, + recordKeystroke, + type EchoSample, + type InstrumentedPane +} from './typing-latency-echo-instrumentation' + +function emptyEntry(): InstrumentedPane { + return { pane: {}, pending: [], disposables: [], restoreWrite: null } +} + +type FakeTerminal = { + write: (data: string | Uint8Array, callback?: () => void) => void + onWriteParsed: (listener: () => void) => { dispose: () => void } + onRender: (listener: () => void) => { dispose: () => void } +} + +function fakeTerminal(): { + terminal: FakeTerminal + emitParsed: () => void + emitRender: () => void + writtenPayloads: (string | Uint8Array)[] + disposeCount: () => number +} { + const parsedListeners: (() => void)[] = [] + const renderListeners: (() => void)[] = [] + const writtenPayloads: (string | Uint8Array)[] = [] + let disposed = 0 + return { + terminal: { + write: (data) => { + writtenPayloads.push(data) + }, + onWriteParsed: (listener) => { + parsedListeners.push(listener) + return { + dispose: () => { + disposed += 1 + } + } + }, + onRender: (listener) => { + renderListeners.push(listener) + return { + dispose: () => { + disposed += 1 + } + } + } + }, + emitParsed: () => parsedListeners.forEach((listener) => listener()), + emitRender: () => renderListeners.forEach((listener) => listener()), + writtenPayloads, + disposeCount: () => disposed + } +} + +describe('recordKeystroke', () => { + it('queues keystrokes rather than overwriting a single slot', () => { + const entry = emptyEntry() + expect(recordKeystroke(entry, 0)).toBe(0) + expect(recordKeystroke(entry, 5)).toBe(0) + expect(entry.pending.map((pending) => pending.t0)).toEqual([0, 5]) + }) + + it('counts keystrokes whose echo never parsed as dropped', () => { + const entry = emptyEntry() + recordKeystroke(entry, 0) + recordKeystroke(entry, 1) + expect(recordKeystroke(entry, 5000)).toBe(2) + expect(entry.pending).toHaveLength(1) + }) + + it('bounds the queue so sustained typing cannot grow memory', () => { + const entry = emptyEntry() + for (let index = 0; index < 200; index += 1) { + recordKeystroke(entry, index) + } + expect(entry.pending.length).toBeLessThanOrEqual(64) + }) +}) + +describe('instrumentPaneEcho', () => { + it('reports parse/paint latency and per-keystroke byte and write volume', () => { + const fake = fakeTerminal() + const samples: EchoSample[] = [] + const entry = instrumentPaneEcho({ terminal: fake.terminal }, (sample) => samples.push(sample)) + + recordKeystroke(entry, performance.now()) + fake.terminal.write('a'.repeat(230)) + fake.terminal.write(new Uint8Array(6)) + fake.emitParsed() + fake.emitRender() + + expect(samples).toHaveLength(1) + expect(samples[0]?.bytes).toBe(236) + expect(samples[0]?.writes).toBe(2) + expect(samples[0]?.parseMs).toBeGreaterThanOrEqual(0) + expect(samples[0]?.paintMs).toBeGreaterThanOrEqual(samples[0]?.parseMs ?? 0) + // Wrapping write() must not swallow terminal output. + expect(fake.writtenPayloads).toHaveLength(2) + }) + + it('holds an unparsed keystroke across a render instead of discarding it', () => { + const fake = fakeTerminal() + const samples: EchoSample[] = [] + const entry = instrumentPaneEcho({ terminal: fake.terminal }, (sample) => samples.push(sample)) + + recordKeystroke(entry, performance.now()) + fake.emitRender() + expect(samples).toHaveLength(0) + + fake.emitParsed() + fake.emitRender() + expect(samples).toHaveLength(1) + }) + + it('restores the original write and disposes listeners on detach', () => { + const fake = fakeTerminal() + const originalWrite = fake.terminal.write + const entry = instrumentPaneEcho({ terminal: fake.terminal }, () => undefined) + expect(fake.terminal.write).not.toBe(originalWrite) + + detachPaneEcho(entry) + + expect(fake.terminal.write).toBe(originalWrite) + expect(fake.disposeCount()).toBe(2) + expect(entry.pending).toEqual([]) + }) + + it('degrades to a no-op instead of throwing when the pane has no terminal', () => { + const entry = instrumentPaneEcho({}, () => undefined) + expect(entry.disposables).toEqual([]) + expect(() => detachPaneEcho(entry)).not.toThrow() + }) +}) diff --git a/src/renderer/src/lib/typing-latency-echo-instrumentation.ts b/src/renderer/src/lib/typing-latency-echo-instrumentation.ts new file mode 100644 index 000000000..3c7b3b50f --- /dev/null +++ b/src/renderer/src/lib/typing-latency-echo-instrumentation.ts @@ -0,0 +1,176 @@ +/** + * Per-pane echo instrumentation for the devtools typing-latency probe. + * + * Mechanics mirror the E2E echo probe: keydown stamps t0, xterm's + * onWriteParsed marks the echo parse, onRender marks the paint, and a bounded + * pending QUEUE (never a single slot) keeps a slow echo from being silently + * discarded. Attached only while the probe runs; detachPaneEcho restores + * everything it wrapped. + */ +import { forEachLivePaneForDesyncSentinel } from '@/lib/pane-manager/pane-manager-registry' + +type Disposable = { dispose: () => void } + +type TerminalLike = { + cols?: number + rows?: number + element?: HTMLElement | null + buffer?: { active?: { type?: string; length?: number } } + write?: (data: string | Uint8Array, callback?: () => void) => void + onWriteParsed?: (listener: () => void) => Disposable + onRender?: (listener: () => void) => Disposable +} + +export type ProbePane = { + id?: number + terminal?: TerminalLike + container?: HTMLElement + leafId?: string +} + +type PendingKeystroke = { + t0: number + bytes: number + writes: number + parsedAt: number | null +} + +export type EchoSample = { + parseMs: number + paintMs: number + bytes: number + writes: number +} + +export type InstrumentedPane = { + pane: ProbePane + pending: PendingKeystroke[] + disposables: Disposable[] + restoreWrite: (() => void) | null +} + +/** An echo that has not parsed within this window is counted as unmatched, never as a sample. */ +const ECHO_TIMEOUT_MS = 2000 +const MAX_PENDING = 64 + +export function listProbePanes(): ProbePane[] { + const panes: ProbePane[] = [] + try { + forEachLivePaneForDesyncSentinel((_key, pane) => { + panes.push(pane as ProbePane) + }) + } catch { + // Why: a mid-teardown manager must not prevent the probe from starting. + } + return panes +} + +export function paneRootElement(pane: ProbePane): HTMLElement | null { + return pane.container ?? pane.terminal?.element ?? null +} + +export function findPaneOwningFocus( + entries: readonly T[] +): T | null { + const focused = typeof document === 'undefined' ? null : document.activeElement + if (!focused) { + return null + } + return entries.find((entry) => paneRootElement(entry.pane)?.contains(focused) === true) ?? null +} + +function oldestUnparsed(entry: InstrumentedPane): PendingKeystroke | null { + return entry.pending.find((pending) => pending.parsedAt === null) ?? null +} + +/** Returns how many pending keystrokes were dropped without an echo. */ +export function recordKeystroke(entry: InstrumentedPane, now: number): number { + let dropped = 0 + while (entry.pending.length > 0 && now - (entry.pending[0]?.t0 ?? now) > ECHO_TIMEOUT_MS) { + entry.pending.shift() + dropped += 1 + } + while (entry.pending.length >= MAX_PENDING) { + entry.pending.shift() + dropped += 1 + } + entry.pending.push({ t0: now, bytes: 0, writes: 0, parsedAt: null }) + return dropped +} + +export function instrumentPaneEcho( + pane: ProbePane, + onSample: (sample: EchoSample) => void +): InstrumentedPane { + const entry: InstrumentedPane = { pane, pending: [], disposables: [], restoreWrite: null } + const terminal = pane.terminal + if (!terminal) { + return entry + } + + // Why: xterm exposes no per-write byte counter, so the probe wraps write() for + // the duration of sampling — this is how per-keystroke output volume (Codex + // ~230-306 bytes vs grok ~66) becomes visible without a build change. + const originalWrite = terminal.write + if (typeof originalWrite === 'function') { + const wrapped = (data: string | Uint8Array, callback?: () => void): void => { + const pending = oldestUnparsed(entry) + if (pending) { + pending.writes += 1 + pending.bytes += typeof data === 'string' ? data.length : data.byteLength + } + originalWrite.call(terminal, data, callback) + } + terminal.write = wrapped + entry.restoreWrite = () => { + if (terminal.write === wrapped) { + terminal.write = originalWrite + } + } + } + + if (typeof terminal.onWriteParsed === 'function') { + entry.disposables.push( + terminal.onWriteParsed(() => { + const pending = oldestUnparsed(entry) + if (pending) { + pending.parsedAt = performance.now() + } + }) + ) + } + if (typeof terminal.onRender === 'function') { + entry.disposables.push( + terminal.onRender(() => { + const now = performance.now() + while (entry.pending.length > 0 && entry.pending[0]?.parsedAt != null) { + const pending = entry.pending.shift() + if (!pending || pending.parsedAt == null) { + continue + } + onSample({ + parseMs: pending.parsedAt - pending.t0, + paintMs: now - pending.t0, + bytes: pending.bytes, + writes: pending.writes + }) + } + }) + ) + } + return entry +} + +export function detachPaneEcho(entry: InstrumentedPane): void { + for (const disposable of entry.disposables) { + try { + disposable.dispose() + } catch { + // Why: a pane disposed mid-run already dropped its listeners. + } + } + entry.disposables = [] + entry.restoreWrite?.() + entry.restoreWrite = null + entry.pending = [] +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 424e838c2..a980c8ed7 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -10,12 +10,14 @@ import { recordRendererCrashBreadcrumb } from './lib/crash-diagnostics' import { applyDocumentTheme } from './lib/document-theme' +import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() +installTypingLatencyDiagnostic() if ( import.meta.env.DEV && diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b010b9084..7565246db 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -43,53 +43,58 @@ import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates' import { e2eConfig } from '@/lib/e2e-config' import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-session' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { installStoreListenerCensus } from './store-listener-census' import { registerRendererMemoryProfileContributor, summarizeStateCollectionSizes } from '@/lib/renderer-memory-profile' -export const useAppStore = create()((...a) => ({ - ...createRepoSlice(...a), - ...createSparsePresetsSlice(...a), - ...createWorktreeSlice(...a), - ...createTerminalSlice(...a), - ...createTabsSlice(...a), - ...createUISlice(...a), - ...createSettingsSlice(...a), - ...createKeybindingsSlice(...a), - ...createGitHubSlice(...a), - ...createHostedReviewSlice(...a), - ...createLinearSlice(...a), - ...createPreflightSlice(...a), - ...createJiraSlice(...a), - ...createEditorSlice(...a), - ...createStatsSlice(...a), - ...createMemorySlice(...a), - ...createWorkspaceSpaceSlice(...a), - ...createClaudeUsageSlice(...a), - ...createCodexUsageSlice(...a), - ...createOpenCodeUsageSlice(...a), - ...createBrowserSlice(...a), - ...createRateLimitSlice(...a), - ...createSshSlice(...a), - ...createRuntimeEnvironmentSshSlice(...a), - ...createAgentStatusSlice(...a), - ...createPaneForegroundAgentSlice(...a), - ...createDiffCommentsSlice(...a), - ...createDetectedAgentsSlice(...a), - ...createRuntimeDetectedAgentsSlice(...a), - ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a), - ...createWorkspaceCleanupSlice(...a), - ...createRuntimeStatusSlice(...a), - ...createPullRequestGenerationSlice(...a), - ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a), - ...createRecentlyClosedTabsSlice(...a), - ...createOrcaProfilesSlice(...a), - ...createNewIssueDraftSlice(...a), - ...createRemoteServerUpdatesSlice(...a) -})) +export const useAppStore = create()((...a) => { + // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. + installStoreListenerCensus(a[2]) + return { + ...createRepoSlice(...a), + ...createSparsePresetsSlice(...a), + ...createWorktreeSlice(...a), + ...createTerminalSlice(...a), + ...createTabsSlice(...a), + ...createUISlice(...a), + ...createSettingsSlice(...a), + ...createKeybindingsSlice(...a), + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a), + ...createLinearSlice(...a), + ...createPreflightSlice(...a), + ...createJiraSlice(...a), + ...createEditorSlice(...a), + ...createStatsSlice(...a), + ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), + ...createClaudeUsageSlice(...a), + ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), + ...createBrowserSlice(...a), + ...createRateLimitSlice(...a), + ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), + ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), + ...createDiffCommentsSlice(...a), + ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), + ...createWorktreeNavHistorySlice(...a), + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a), + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), + ...createOrcaProfilesSlice(...a), + ...createNewIssueDraftSlice(...a), + ...createRemoteServerUpdatesSlice(...a) + } +}) registerHttpLinkStoreAccessor(() => useAppStore.getState()) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 3c6ddb9d7..f49aec042 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -32,6 +32,7 @@ import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { readLastTerminalInputAt } from '@/lib/terminal-input-activity-coalescing' import { getAgentRowGeneratedTitleText, getOrcaDispatchTaskId, @@ -582,7 +583,7 @@ function isValidManualSleepLiveAgentEntry( if (entry.interrupted === true || entry.state === 'done') { return false } - const lastInputAt = state.lastTerminalInputAtByPaneKey[entry.paneKey] + const lastInputAt = readLastTerminalInputAt(state.lastTerminalInputAtByPaneKey, entry.paneKey) if ( typeof lastInputAt === 'number' && Number.isFinite(lastInputAt) && diff --git a/src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts b/src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts new file mode 100644 index 000000000..184eb077f --- /dev/null +++ b/src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts @@ -0,0 +1,104 @@ +/** + * `recordTerminalInput` used to run one `set()` per keystroke, waking every zustand + * subscriber in the store. It is now coalesced: leading-edge write, then one trailing + * flush per window. These tests pin the properties that make that safe. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: { pty: { kill: vi.fn().mockResolvedValue(undefined) } } } + +import { + TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS, + flushTerminalInputActivity, + mergePendingTerminalInputActivity, + readLastTerminalInputAt, + resetTerminalInputActivityCoalescingForTests +} from '@/lib/terminal-input-activity-coalescing' +import { createTestStore } from './store-test-helpers' + +const PANE = 'tab-1:leaf-a' + +describe('recordTerminalInput store writes are coalesced', () => { + beforeEach(() => { + vi.useFakeTimers() + resetTerminalInputActivityCoalescingForTests() + }) + + it('does not write the store on every keystroke', () => { + const store = createTestStore() + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + + for (let i = 0; i < 50; i++) { + store.getState().recordTerminalInput(PANE, 1_000 + i * 15) + } + unsubscribe() + + // 50 keystrokes inside ~750ms: leading edge plus at most one window boundary. + expect(listener.mock.calls.length).toBeLessThanOrEqual(3) + }) + + it('keeps the freshest stamp visible to imperative readers before the flush', () => { + const store = createTestStore() + + store.getState().recordTerminalInput(PANE, 1_000) + store.getState().recordTerminalInput(PANE, 1_200) + + const stored = store.getState().lastTerminalInputAtByPaneKey + expect(stored[PANE]).toBe(1_000) + expect(readLastTerminalInputAt(stored, PANE)).toBe(1_200) + expect(mergePendingTerminalInputActivity(stored)[PANE]).toBe(1_200) + + vi.advanceTimersByTime(TERMINAL_INPUT_ACTIVITY_WRITE_INTERVAL_MS + 1) + expect(store.getState().lastTerminalInputAtByPaneKey[PANE]).toBe(1_200) + }) + + it('writes the very first stamp for a pane synchronously', () => { + const store = createTestStore() + + store.getState().recordTerminalInput(PANE, 1_000) + + expect(store.getState().lastTerminalInputAtByPaneKey[PANE]).toBe(1_000) + }) + + it('does not resurrect a pane key a purge deleted', () => { + const store = createTestStore() + + store.getState().recordTerminalInput(PANE, 1_000) + store.getState().recordTerminalInput(PANE, 1_200) + + // Teardown path (worktree purge / pane close) rewrites the map without the key. + store.setState({ lastTerminalInputAtByPaneKey: {} }) + flushTerminalInputActivity() + + const stored = store.getState().lastTerminalInputAtByPaneKey + expect(stored[PANE]).toBeUndefined() + expect(readLastTerminalInputAt(stored, PANE)).toBeUndefined() + expect(mergePendingTerminalInputActivity(stored)[PANE]).toBeUndefined() + }) + + it('ignores invalid pane keys and timestamps', () => { + const store = createTestStore() + + store.getState().recordTerminalInput('', 1_000) + store.getState().recordTerminalInput(PANE, Number.NaN) + + expect(store.getState().lastTerminalInputAtByPaneKey).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5875f53e3..5c8806185 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -52,6 +52,7 @@ import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-deliver import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' import { pushClosedTerminalTabSnapshot, pushRecentlyClosedTabKind } from './recently-closed-tabs' import { isClaudeAgent } from '@/lib/agent-status' +import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { @@ -842,12 +843,33 @@ export const createTerminalSlice: StateCreator if (!paneKey || !Number.isFinite(timestamp)) { return } - set((s) => ({ - lastTerminalInputAtByPaneKey: { - ...s.lastTerminalInputAtByPaneKey, - [paneKey]: timestamp + recordTerminalInputActivity({ + paneKey, + timestamp, + // Why: the first stamp for a pane must land synchronously; automation take-over + // detection subscribes and compares undefined→value across a launch. + forceWrite: get().lastTerminalInputAtByPaneKey[paneKey] === undefined, + commit: { + insert: (key, at) => + set((s) => ({ + lastTerminalInputAtByPaneKey: { ...s.lastTerminalInputAtByPaneKey, [key]: at } + })), + refreshExisting: (entries) => + set((s) => { + let next: Record | null = null + for (const [key, at] of entries) { + // Why: teardown (close pane/tab/worktree purge) deletes keys; a late flush must not resurrect them. + const current = s.lastTerminalInputAtByPaneKey[key] + if (current === undefined || current >= at) { + continue + } + next ??= { ...s.lastTerminalInputAtByPaneKey } + next[key] = at + } + return next ? { lastTerminalInputAtByPaneKey: next } : {} + }) } - })) + }) }, setCacheTimerStartedAt: (key, ts) => { diff --git a/src/renderer/src/store/store-listener-census.test.ts b/src/renderer/src/store/store-listener-census.test.ts new file mode 100644 index 000000000..bfbe67c1a --- /dev/null +++ b/src/renderer/src/store/store-listener-census.test.ts @@ -0,0 +1,93 @@ +/** + * The census used to wrap `subscribe` on the bound hook AFTER create() ran. zustand's + * useStore() reads the inner api.subscribe, so that version counted only imperative + * subscribers and missed every React hook subscription — the ones that actually scale + * with agent rows. These tests pin both paths. + */ +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand/vanilla' +import { useStore } from 'zustand' +import { installStoreListenerCensus, readStoreListenerCount } from './store-listener-census' + +type CensusState = { n: number } +type CensusApi = { + subscribe: (listener: (state: CensusState, previous: CensusState) => void) => () => void + setState: (partial: Partial) => void +} + +/** Rebuilds what zustand's create() does, so the test exercises the real wiring order. */ +function createCensusStore(): { api: CensusApi; hook: { subscribe: unknown } } { + const api = createStore(() => ({ n: 0 })) as unknown as CensusApi + installStoreListenerCensus(api) + const hook = ((selector: (state: CensusState) => unknown) => + useStore(api as never, selector)) as unknown as { subscribe: unknown } + Object.assign(hook, api) + return { api, hook } +} + +describe('store listener census', () => { + it('counts subscriptions made through the inner api, which is what React useStore uses', () => { + const { api } = createCensusStore() + const baseline = readStoreListenerCount() ?? -1 + expect(baseline).toBe(0) + + const unsubscribe = api.subscribe(() => undefined) + expect(readStoreListenerCount()).toBe(1) + + unsubscribe() + expect(readStoreListenerCount()).toBe(0) + }) + + it('counts subscriptions made through the hook copy that create() assigns', () => { + const { hook } = createCensusStore() + const subscribe = hook.subscribe as (listener: () => void) => () => void + + const unsubscribe = subscribe(() => undefined) + expect(readStoreListenerCount()).toBe(1) + + unsubscribe() + expect(readStoreListenerCount()).toBe(0) + }) + + it('counts the hook copy and the inner api as the same pool', () => { + const { api, hook } = createCensusStore() + const subscribe = hook.subscribe as (listener: () => void) => () => void + + const viaHook = subscribe(() => undefined) + const viaApi = api.subscribe(() => undefined) + expect(readStoreListenerCount()).toBe(2) + + viaHook() + viaApi() + expect(readStoreListenerCount()).toBe(0) + }) + + it('does not double-decrement when React calls the same cleanup twice', () => { + const { api } = createCensusStore() + const keep = api.subscribe(() => undefined) + const unsubscribe = api.subscribe(() => undefined) + expect(readStoreListenerCount()).toBe(2) + + unsubscribe() + unsubscribe() + expect(readStoreListenerCount()).toBe(1) + + keep() + expect(readStoreListenerCount()).toBe(0) + }) + + it('still delivers state updates to a counted listener', () => { + const { api } = createCensusStore() + let seen = 0 + const unsubscribe = api.subscribe((state) => { + seen = state.n + }) + + api.setState({ n: 7 }) + expect(seen).toBe(7) + + unsubscribe() + api.setState({ n: 9 }) + expect(seen).toBe(7) + }) +}) diff --git a/src/renderer/src/store/store-listener-census.ts b/src/renderer/src/store/store-listener-census.ts new file mode 100644 index 000000000..c8a19ad88 --- /dev/null +++ b/src/renderer/src/store/store-listener-census.ts @@ -0,0 +1,52 @@ +/** + * Live count of zustand store subscribers, for the typing-latency census. + * + * Why this lives in the store and not in the probe: zustand's create() builds the + * inner api first, then copies `subscribe` onto the bound hook. useStore() reads + * the INNER api.subscribe, so patching the hook's copy after the fact counts only + * imperative useAppStore.subscribe() callers (16 sites) and silently misses every + * React hook subscription (~2.2k sites) — exactly the ones that scale with agent + * rows. The inner api is only reachable as the state creator's third argument. + * + * Cost is per-subscribe (component mount), never per-setState: zustand notifies by + * iterating its listener Set directly, so this never touches the keystroke path. + */ + +type StoreListenerCensusApi = { + subscribe: (listener: (state: T, previousState: T) => void) => () => void +} + +let liveListenerCount: number | null = null + +export function readStoreListenerCount(): number | null { + return liveListenerCount +} + +/** Call once from inside the store's state creator, passing its `api` argument. */ +export function installStoreListenerCensus(api: StoreListenerCensusApi): void { + try { + const originalSubscribe = api.subscribe + if (typeof originalSubscribe !== 'function') { + return + } + let live = 0 + liveListenerCount = 0 + api.subscribe = (listener) => { + live += 1 + liveListenerCount = live + const unsubscribe = originalSubscribe(listener) + let released = false + return () => { + // Why: React can call the same cleanup twice; only the first release counts. + if (!released) { + released = true + live -= 1 + liveListenerCount = live + } + unsubscribe() + } + } + } catch { + liveListenerCount = null + } +}