perf(terminal): coalesce per-keystroke input stamps + add typing-latency self-diagnostic (#10784)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency diagnostic

A user reports keystroke-echo lag on v1.4.156 that vanishes in Ghostty on the
same machine, at a scale we cannot reproduce locally (nested worktrees, ~20
agents each). Static analysis across the 155..156 renderer diff found no
perceptible regression, so this adds the instrument to measure it where it
actually happens.

Diagnostic (`window.__orcaTypingDiagnostic`, dev-console only, no shipped UI):
reports keydown->paint percentiles from real typing plus a scale census —
agent rows store-total vs mounted-DOM, store listener count, worktree nesting
depth, the settings gating suspect paths, and the focused pane's agent and
buffer mode. Nothing attaches to the keystroke path until start(), so it does
not perturb the latency it measures.

Coalescing: recordTerminalInput wrote the whole lastTerminalInputAtByPaneKey
map on every keystroke, waking every zustand subscriber. Hibernation is a >=60s
idle timeout, so the leading edge of a burst writes immediately and the rest
collapse into one trailing flush. Imperative readers merge the pending stamp,
and a late flush never revives a pane key teardown deleted.

This write path is byte-identical in v1.4.155 and v1.4.156, so the coalescing
is a general perf win, not a fix for the reported regression — the measured
saving (~0.02ms/keystroke) is well below perception.

* fix(diagnostic): count React store subscriptions in the listener census

The census wrapped `subscribe` on the bound hook after `create()` had already run.
zustand's `useStore()` reads the INNER `api.subscribe`, and `create()` copies
subscribe onto the hook as a separate property slot — so patching the hook's copy
counted only the 16 imperative `useAppStore.subscribe()` call sites and missed all
~2.2k React hook subscriptions, i.e. exactly the ones that scale with agent rows.
The metric would have read a near-constant ~16 regardless of scale, which would
have made "latency tracks listener count" read as false no matter the truth.

The inner api is only reachable as the state creator's third argument, so the
counter now installs there and lives in the store rather than the probe.

Still per-subscribe (component mount), never per-setState: zustand notifies by
iterating its listener Set directly, so this never touches the keystroke path.

Tests pin both subscribe paths; reverting to the old wiring fails 3 of the 5.
This commit is contained in:
Neil 2026-07-26 18:47:11 -07:00 committed by GitHub
parent 29c40e3353
commit feb321f6a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1639 additions and 51 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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<string, number> = {}) {
const stored: Record<string, number | undefined> = { ...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<typeof createStore>, 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)
})
})

View File

@ -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<string, number>()
const lastWrittenByPaneKey = new Map<string, number>()
let flushTimer: ReturnType<typeof setTimeout> | 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<string, number | undefined>,
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<T extends Record<string, number | undefined>>(
stored: T
): T {
if (pendingByPaneKey.size === 0) {
return stored
}
let next: Record<string, number | undefined> | 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
}

View File

@ -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<string, { agent?: string | null }>
agentStatusByPaneKey?: Record<string, { agentType?: string | null }>
}
export function readProbeStoreState(): (ProbeStoreState & Record<string, unknown>) | null {
try {
return useAppStore.getState() as unknown as ProbeStoreState & Record<string, unknown>
} 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'

View File

@ -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 })
})
})

View File

@ -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<string, unknown> | null | undefined
type WorktreeLike = { path?: string | null }
export type TypingCensusStoreShape = {
worktreesByRepo?: Record<string, WorktreeLike[]> | null
tabsByWorktree?: Record<string, unknown[]> | null
unifiedTabsByWorktree?: Record<string, unknown[]> | null
agentStatusByPaneKey?: CountableRecord
retainedAgentsByPaneKey?: CountableRecord
activeTabId?: string | null
activeTabType?: string | null
settings?: Record<string, unknown> | 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<string, unknown[]> | 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<string, unknown> | null | undefined,
key: string
): boolean | null {
const value = settings?.[key]
return typeof value === 'boolean' ? value : null
}
function readString(
settings: Record<string, unknown> | null | undefined,
key: string
): string | null {
const value = settings?.[key]
return typeof value === 'string' ? value : null
}
function readNumber(
settings: Record<string, unknown> | null | undefined,
key: string
): number | null {
const value = settings?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function collectWorktreePaths(byRepo: Record<string, WorktreeLike[]> | 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
}
}

View File

@ -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 }
}

View File

@ -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()
})
})

View File

@ -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<T extends { pane: ProbePane }>(
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 = []
}

View File

@ -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 &&

View File

@ -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<AppState>()((...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<AppState>()((...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())

View File

@ -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) &&

View File

@ -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<typeof AgentStatusModule>()
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({})
})
})

View File

@ -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<AppState, [], [], TerminalSlice>
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<string, number> | 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) => {

View File

@ -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<CensusState>) => 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<CensusState>(() => ({ 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)
})
})

View File

@ -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<T> = {
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<T>(api: StoreListenerCensusApi<T>): 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
}
}