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:
parent
29c40e3353
commit
feb321f6a0
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = []
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue