| 1 | import { setTranscriptScrollDiagnosticSink } from "./transcriptScrollProbe"; |
| 2 | import { getProcessFoldPreference } from "./processFoldPreference"; |
| 3 | import { getReasoningDisplayMode } from "./reasoningDisplayPreference"; |
| 4 | |
| 5 | export const TRANSCRIPT_SCROLL_DIAGNOSTIC_SCHEMA_VERSION = 2; |
| 6 | export const MAX_TRANSCRIPT_SCROLL_DIAGNOSTIC_EVENTS = 4_096; |
| 7 | export const TRANSCRIPT_SCROLL_DIAGNOSTIC_DURATION_MS = 90_000; |
| 8 | |
| 9 | export type TranscriptScrollDiagnosticStatus = "idle" | "recording" | "stopped"; |
| 10 | |
| 11 | export type TranscriptScrollDiagnosticEventType = |
| 12 | | "start" |
| 13 | | "stop" |
| 14 | | "mark" |
| 15 | | "sample" |
| 16 | | "wheel" |
| 17 | | "scroll" |
| 18 | | "scroll-write" |
| 19 | | "items-rendered" |
| 20 | | "list-height" |
| 21 | | "row-measure" |
| 22 | | "geometry-contract-violation" |
| 23 | | "geometry-revision" |
| 24 | | "scroll-anomaly" |
| 25 | | "reader-transaction" |
| 26 | | "scroll-state" |
| 27 | | "blank-check" |
| 28 | | "blank-reset" |
| 29 | | "recovery"; |
| 30 | |
| 31 | export type TranscriptScrollDiagnosticEvent = { |
| 32 | t: number; |
| 33 | type: TranscriptScrollDiagnosticEventType; |
| 34 | scrollTop?: number; |
| 35 | scrollHeight?: number; |
| 36 | clientHeight?: number; |
| 37 | bottomDistance?: number; |
| 38 | mountedRows?: number; |
| 39 | totalRows?: number; |
| 40 | firstVisibleIndex?: number; |
| 41 | firstVisibleTop?: number; |
| 42 | deltaY?: number; |
| 43 | targetTop?: number; |
| 44 | targetIndex?: number | "LAST"; |
| 45 | listHeight?: number; |
| 46 | rowIndex?: number; |
| 47 | estimatedSize?: number; |
| 48 | previousSize?: number; |
| 49 | measuredSize?: number; |
| 50 | sizeDelta?: number; |
| 51 | contentRevision?: number; |
| 52 | disclosureCount?: number; |
| 53 | settleFrame?: number; |
| 54 | offBottomFrames?: number; |
| 55 | stagnantFrames?: number; |
| 56 | sequence?: number; |
| 57 | generation?: number; |
| 58 | ownershipEpoch?: number; |
| 59 | geometryRevision?: number; |
| 60 | transactionId?: number; |
| 61 | footerHeight?: number; |
| 62 | viewport?: number; |
| 63 | mounted?: number; |
| 64 | total?: number; |
| 65 | reverseDisplacement?: number; |
| 66 | extentDelta?: number; |
| 67 | stableFrames?: number; |
| 68 | direction?: number; |
| 69 | mode?: "tail-follow" | "manual" | "native-thumb" | "user-resize" | "selection" | "restoring" | "unknown"; |
| 70 | previousMode?: "tail-follow" | "manual" | "native-thumb" | "user-resize" | "selection" | "restoring" | "unknown"; |
| 71 | owner?: "tail-follow" | "jump" | "rewind" | "jump-bottom" | "custom-scrollbar" | "selection-edge-scroll" | "recovery" | "reader-stability" | "anchor-compensation" | "block-window-prepend" | "other"; |
| 72 | writeKind?: "scrollTo" | "scrollBy" | "scrollToIndex" | "pinTail"; |
| 73 | source?: "reset" | "user-scroll-intent" | "manual-reading" | "reader-idle-deadline" | "reader-stability" | "reader-tail-handoff" | "reader-transaction-end" | "scroll-delivered" |
| 74 | | "tail-content-changed" | "content-shrank" | "layout-height-changed" | "viewport-resized" |
| 75 | | "user-resize-begin" | "user-resize-end" | "selection-begin" | "selection-end" |
| 76 | | "programmatic-begin" | "programmatic-end" | "jump-bottom" | "jump-index" | "scroll-offset" |
| 77 | | "recovery-begin" | "recovery-end" | "recovery-mount" | "recovery-anchor" | "extent-rebound" | "tail-follow" | "native-scrollbar-release" | "other"; |
| 78 | phase?: "active" | "settling" | "handoff-pending" | "inactive" | "mount-anchor" | "correct-offset" | "initial" | "settle" | "end"; |
| 79 | rejectedReason?: string; |
| 80 | result?: string; |
| 81 | rowKind?: "older-history" | "user" | "process-header" | "reasoning" | "tool" | "tool-batch" |
| 82 | | "tool-group" | "phase" | "process-notice" | "notice" | "compaction" | "answer" | "extension" |
| 83 | | "turn-actions"; |
| 84 | layoutVariant?: "reasoning-summary" | "reasoning-heading-only" | "reasoning-expanded" |
| 85 | | "tool-collapsed" | "tool-expanded" | "tool-batch-collapsed" | "tool-batch-expanded" |
| 86 | | "tool-group-collapsed" | "tool-group-expanded" | "compaction-collapsed" |
| 87 | | "compaction-expanded" | "static" | "text-flow"; |
| 88 | estimateSource?: "exact" | "calibrated" | "static"; |
| 89 | relativeError?: number; |
| 90 | foldState?: "none" | "open" | "closed" | "mixed"; |
| 91 | state?: "begin" | "suspend" | "retry" | "done" | "cancelled" | "expired"; |
| 92 | reason?: "user-takeover" | "surface-switch" | "superseded" | "viewport-blank" | "other"; |
| 93 | atBottom?: boolean; |
| 94 | scrollable?: boolean; |
| 95 | blank?: boolean; |
| 96 | readerIntent?: boolean; |
| 97 | canClaimTail?: boolean; |
| 98 | substantial?: boolean; |
| 99 | tailCommand?: boolean; |
| 100 | transient?: boolean; |
| 101 | sources?: Array<"footer-resize" | "row-measure" | "data-change" | "viewport-resize" | "fold-change" | "typography-change" | "items-rendered">; |
| 102 | }; |
| 103 | |
| 104 | export type TranscriptScrollDiagnosticEnvironment = { |
| 105 | buildCommit: string; |
| 106 | buildChannel: string; |
| 107 | platform: "windows" | "macos" | "linux" | "other"; |
| 108 | userAgent: string; |
| 109 | devicePixelRatio: number; |
| 110 | viewportWidth: number; |
| 111 | viewportHeight: number; |
| 112 | reducedMotion: boolean; |
| 113 | transcriptWidth: number; |
| 114 | contentWidth: number; |
| 115 | fontSize: number; |
| 116 | lineHeight: number; |
| 117 | processFoldPreference: "auto" | "expanded"; |
| 118 | reasoningDisplayMode: "hidden" | "summary" | "auto" | "expanded" | "legacy-collapsed" | "pending"; |
| 119 | }; |
| 120 | |
| 121 | export type TranscriptScrollDiagnosticPayload = { |
| 122 | schemaVersion: typeof TRANSCRIPT_SCROLL_DIAGNOSTIC_SCHEMA_VERSION; |
| 123 | manifest: TranscriptScrollDiagnosticEnvironment & { |
| 124 | reportId: string; |
| 125 | createdAt: string; |
| 126 | }; |
| 127 | summary: { |
| 128 | durationMs: number; |
| 129 | eventCount: number; |
| 130 | droppedEventCount: number; |
| 131 | markerCount: number; |
| 132 | }; |
| 133 | events: TranscriptScrollDiagnosticEvent[]; |
| 134 | }; |
| 135 | |
| 136 | export type TranscriptScrollDiagnosticSnapshot = { |
| 137 | status: TranscriptScrollDiagnosticStatus; |
| 138 | durationMs: number; |
| 139 | eventCount: number; |
| 140 | droppedEventCount: number; |
| 141 | markerCount: number; |
| 142 | reportId: string; |
| 143 | }; |
| 144 | |
| 145 | type EventFields = Record<string, unknown>; |
| 146 | type Sample = EventFields | null; |
| 147 | type Listener = () => void; |
| 148 | |
| 149 | type RecorderOptions = { |
| 150 | maxEvents?: number; |
| 151 | maxDurationMs?: number; |
| 152 | now?: () => number; |
| 153 | randomID?: () => string; |
| 154 | environment?: () => TranscriptScrollDiagnosticEnvironment; |
| 155 | }; |
| 156 | |
| 157 | const EVENT_TYPES = new Set<TranscriptScrollDiagnosticEventType>([ |
| 158 | "start", "stop", "mark", "sample", "wheel", "scroll", "scroll-write", |
| 159 | "items-rendered", "list-height", "row-measure", "geometry-contract-violation", "geometry-revision", "scroll-anomaly", "reader-transaction", "scroll-state", "blank-check", "blank-reset", "recovery", |
| 160 | ]); |
| 161 | const MODES = new Set(["tail-follow", "manual", "native-thumb", "user-resize", "selection", "restoring", "unknown"]); |
| 162 | const OWNERS = new Set(["tail-follow", "jump", "rewind", "jump-bottom", "custom-scrollbar", "selection-edge-scroll", "recovery", "reader-stability", "anchor-compensation", "block-window-prepend", "other"]); |
| 163 | const WRITE_KINDS = new Set(["scrollTo", "scrollBy", "scrollToIndex", "pinTail"]); |
| 164 | const SOURCES = new Set([ |
| 165 | "reset", "user-scroll-intent", "manual-reading", "reader-idle-deadline", "reader-stability", "reader-tail-handoff", "reader-transaction-end", "scroll-delivered", |
| 166 | "tail-content-changed", "content-shrank", "layout-height-changed", "viewport-resized", |
| 167 | "user-resize-begin", "user-resize-end", "selection-begin", "selection-end", |
| 168 | "programmatic-begin", "programmatic-end", "jump-bottom", "jump-index", "scroll-offset", |
| 169 | "recovery-begin", "recovery-end", "recovery-mount", "recovery-anchor", "extent-rebound", "tail-follow", "native-scrollbar-release", "other", |
| 170 | ]); |
| 171 | const PHASES = new Set(["active", "settling", "handoff-pending", "inactive", "mount-anchor", "correct-offset", "initial", "settle", "end"]); |
| 172 | const ROW_KINDS = new Set([ |
| 173 | "older-history", "user", "process-header", "reasoning", "tool", "tool-batch", "tool-group", "phase", |
| 174 | "process-notice", "notice", "compaction", "answer", "extension", "turn-actions", |
| 175 | ]); |
| 176 | const LAYOUT_VARIANTS = new Set([ |
| 177 | "reasoning-summary", "reasoning-heading-only", "reasoning-expanded", |
| 178 | "tool-collapsed", "tool-expanded", "tool-batch-collapsed", "tool-batch-expanded", |
| 179 | "tool-group-collapsed", "tool-group-expanded", "compaction-collapsed", "compaction-expanded", |
| 180 | "static", "text-flow", |
| 181 | ]); |
| 182 | const ESTIMATE_SOURCES = new Set(["exact", "calibrated", "static"]); |
| 183 | const FOLD_STATES = new Set(["none", "open", "closed", "mixed"]); |
| 184 | const STATES = new Set(["begin", "suspend", "retry", "done", "cancelled", "expired"]); |
| 185 | const REASONS = new Set(["user-takeover", "surface-switch", "superseded", "viewport-blank", "other"]); |
| 186 | const NUMBER_FIELDS = [ |
| 187 | "scrollTop", "scrollHeight", "clientHeight", "bottomDistance", "mountedRows", "totalRows", |
| 188 | "firstVisibleIndex", "firstVisibleTop", "deltaY", "targetTop", "listHeight", "rowIndex", |
| 189 | "estimatedSize", "previousSize", "measuredSize", "sizeDelta", "contentRevision", "disclosureCount", |
| 190 | "settleFrame", "offBottomFrames", "stagnantFrames", "relativeError", |
| 191 | "sequence", "generation", "ownershipEpoch", "geometryRevision", "transactionId", "footerHeight", "viewport", "mounted", "total", "reverseDisplacement", "extentDelta", "stableFrames", "direction", |
| 192 | ] as const; |
| 193 | const BOOLEAN_FIELDS = ["atBottom", "scrollable", "blank", "readerIntent", "canClaimTail", "substantial", "tailCommand", "transient"] as const; |
| 194 | const GEOMETRY_SOURCES = new Set(["footer-resize", "row-measure", "data-change", "viewport-resize", "fold-change", "typography-change", "items-rendered"]); |
| 195 | |
| 196 | function finiteNumber(value: unknown): number | undefined { |
| 197 | if (typeof value !== "number" || !Number.isFinite(value)) return undefined; |
| 198 | return Math.round(value * 100) / 100; |
| 199 | } |
| 200 | |
| 201 | function sanitizeEvent( |
| 202 | t: number, |
| 203 | type: TranscriptScrollDiagnosticEventType, |
| 204 | input: EventFields = {}, |
| 205 | ): TranscriptScrollDiagnosticEvent { |
| 206 | const event: TranscriptScrollDiagnosticEvent = { t: Math.max(0, Math.round(t)), type }; |
| 207 | const target = event as Record<string, unknown>; |
| 208 | for (const field of NUMBER_FIELDS) { |
| 209 | const value = finiteNumber(input[field]); |
| 210 | if (value !== undefined) target[field] = value; |
| 211 | } |
| 212 | for (const field of BOOLEAN_FIELDS) { |
| 213 | if (typeof input[field] === "boolean") target[field] = input[field]; |
| 214 | } |
| 215 | if (typeof input.targetIndex === "number" && Number.isFinite(input.targetIndex)) { |
| 216 | event.targetIndex = Math.max(0, Math.round(input.targetIndex)); |
| 217 | } else if (input.targetIndex === "LAST") { |
| 218 | event.targetIndex = "LAST"; |
| 219 | } |
| 220 | if (typeof input.mode === "string" && MODES.has(input.mode)) event.mode = input.mode as TranscriptScrollDiagnosticEvent["mode"]; |
| 221 | if (typeof input.previousMode === "string" && MODES.has(input.previousMode)) event.previousMode = input.previousMode as TranscriptScrollDiagnosticEvent["previousMode"]; |
| 222 | if (typeof input.owner === "string") event.owner = (OWNERS.has(input.owner) ? input.owner : "other") as TranscriptScrollDiagnosticEvent["owner"]; |
| 223 | if (typeof input.writeKind === "string" && WRITE_KINDS.has(input.writeKind)) event.writeKind = input.writeKind as TranscriptScrollDiagnosticEvent["writeKind"]; |
| 224 | if (typeof input.source === "string") event.source = (SOURCES.has(input.source) ? input.source : "other") as TranscriptScrollDiagnosticEvent["source"]; |
| 225 | if (typeof input.phase === "string" && PHASES.has(input.phase)) event.phase = input.phase as TranscriptScrollDiagnosticEvent["phase"]; |
| 226 | if (typeof input.rowKind === "string" && ROW_KINDS.has(input.rowKind)) event.rowKind = input.rowKind as TranscriptScrollDiagnosticEvent["rowKind"]; |
| 227 | if (typeof input.layoutVariant === "string" && LAYOUT_VARIANTS.has(input.layoutVariant)) { |
| 228 | event.layoutVariant = input.layoutVariant as TranscriptScrollDiagnosticEvent["layoutVariant"]; |
| 229 | } |
| 230 | if (typeof input.estimateSource === "string" && ESTIMATE_SOURCES.has(input.estimateSource)) { |
| 231 | event.estimateSource = input.estimateSource as TranscriptScrollDiagnosticEvent["estimateSource"]; |
| 232 | } |
| 233 | if (typeof input.foldState === "string" && FOLD_STATES.has(input.foldState)) event.foldState = input.foldState as TranscriptScrollDiagnosticEvent["foldState"]; |
| 234 | if (typeof input.state === "string" && STATES.has(input.state)) event.state = input.state as TranscriptScrollDiagnosticEvent["state"]; |
| 235 | if (typeof input.reason === "string") event.reason = (REASONS.has(input.reason) ? input.reason : "other") as TranscriptScrollDiagnosticEvent["reason"]; |
| 236 | if (typeof input.rejectedReason === "string" && /^[a-z0-9-]{1,64}$/.test(input.rejectedReason)) event.rejectedReason = input.rejectedReason; |
| 237 | if (typeof input.result === "string" && /^[a-z0-9-]{1,64}$/.test(input.result)) event.result = input.result; |
| 238 | if (Array.isArray(input.sources)) { |
| 239 | const sources = input.sources.filter((value): value is NonNullable<TranscriptScrollDiagnosticEvent["sources"]>[number] => ( |
| 240 | typeof value === "string" && GEOMETRY_SOURCES.has(value) |
| 241 | )); |
| 242 | if (sources.length > 0) event.sources = [...new Set(sources)]; |
| 243 | } |
| 244 | return event; |
| 245 | } |
| 246 | |
| 247 | function defaultRandomID(): string { |
| 248 | const bytes = new Uint8Array(16); |
| 249 | if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { |
| 250 | crypto.getRandomValues(bytes); |
| 251 | } else { |
| 252 | for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256); |
| 253 | } |
| 254 | return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); |
| 255 | } |
| 256 | |
| 257 | function normalizedPlatform(): TranscriptScrollDiagnosticEnvironment["platform"] { |
| 258 | if (typeof navigator === "undefined") return "other"; |
| 259 | const source = `${navigator.userAgent} ${navigator.platform}`.toLowerCase(); |
| 260 | if (source.includes("win")) return "windows"; |
| 261 | if (source.includes("mac")) return "macos"; |
| 262 | if (source.includes("linux")) return "linux"; |
| 263 | return "other"; |
| 264 | } |
| 265 | |
| 266 | function defaultEnvironment(): TranscriptScrollDiagnosticEnvironment { |
| 267 | const transcript = typeof document === "undefined" ? null : document.querySelector<HTMLElement>(".transcript"); |
| 268 | const style = transcript ? getComputedStyle(transcript) : null; |
| 269 | const rootStyle = typeof document === "undefined" ? null : getComputedStyle(document.documentElement); |
| 270 | const transcriptWidth = transcript?.clientWidth ?? 0; |
| 271 | const inlinePadding = Number.parseFloat(style?.getPropertyValue("--transcript-inline-pad") ?? "") || 0; |
| 272 | const maxContentWidth = Number.parseFloat(rootStyle?.getPropertyValue("--maxw") ?? "") || transcriptWidth; |
| 273 | const contentWidth = Math.max(0, Math.min(maxContentWidth, transcriptWidth - inlinePadding * 2)); |
| 274 | return { |
| 275 | buildCommit: typeof __BUILD_COMMIT__ === "string" ? __BUILD_COMMIT__ : "dev", |
| 276 | buildChannel: typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "development", |
| 277 | platform: normalizedPlatform(), |
| 278 | userAgent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent.slice(0, 512), |
| 279 | devicePixelRatio: finiteNumber(globalThis.devicePixelRatio) ?? 1, |
| 280 | viewportWidth: typeof window === "undefined" ? 0 : Math.max(0, Math.round(window.innerWidth)), |
| 281 | viewportHeight: typeof window === "undefined" ? 0 : Math.max(0, Math.round(window.innerHeight)), |
| 282 | reducedMotion: typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true, |
| 283 | transcriptWidth: finiteNumber(transcriptWidth) ?? 0, |
| 284 | contentWidth: finiteNumber(contentWidth) ?? 0, |
| 285 | fontSize: finiteNumber(Number.parseFloat(style?.fontSize ?? "")) ?? 0, |
| 286 | lineHeight: finiteNumber(Number.parseFloat(style?.lineHeight ?? "")) ?? 0, |
| 287 | processFoldPreference: getProcessFoldPreference(), |
| 288 | reasoningDisplayMode: getReasoningDisplayMode(), |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | export function createTranscriptScrollDiagnostics(options: RecorderOptions = {}) { |
| 293 | const maxEvents = Math.max(4, Math.min(MAX_TRANSCRIPT_SCROLL_DIAGNOSTIC_EVENTS, Math.round(options.maxEvents ?? MAX_TRANSCRIPT_SCROLL_DIAGNOSTIC_EVENTS))); |
| 294 | const maxDurationMs = Math.max(1_000, Math.min(TRANSCRIPT_SCROLL_DIAGNOSTIC_DURATION_MS, Math.round(options.maxDurationMs ?? TRANSCRIPT_SCROLL_DIAGNOSTIC_DURATION_MS))); |
| 295 | const now = options.now ?? (() => performance.now()); |
| 296 | const randomID = options.randomID ?? defaultRandomID; |
| 297 | const environment = options.environment ?? defaultEnvironment; |
| 298 | const listeners = new Set<Listener>(); |
| 299 | |
| 300 | let status: TranscriptScrollDiagnosticStatus = "idle"; |
| 301 | let startedAt = 0; |
| 302 | let stoppedAt = 0; |
| 303 | let reportId = ""; |
| 304 | let createdAt = ""; |
| 305 | let manifest: TranscriptScrollDiagnosticEnvironment | null = null; |
| 306 | let events: TranscriptScrollDiagnosticEvent[] = []; |
| 307 | let droppedEventCount = 0; |
| 308 | let markerCount = 0; |
| 309 | let stopTimer: ReturnType<typeof setTimeout> | null = null; |
| 310 | let sampleFrame: number | null = null; |
| 311 | let sampler: (() => Sample) | undefined; |
| 312 | let lastSampleAt = -Infinity; |
| 313 | let lastSampleSignature = ""; |
| 314 | |
| 315 | const emit = () => listeners.forEach((listener) => listener()); |
| 316 | |
| 317 | const append = (type: TranscriptScrollDiagnosticEventType, fields: EventFields = {}) => { |
| 318 | if (status !== "recording" || !EVENT_TYPES.has(type)) return; |
| 319 | events.push(sanitizeEvent(now() - startedAt, type, fields)); |
| 320 | if (events.length > maxEvents) { |
| 321 | const removed = events.splice(0, events.length - maxEvents); |
| 322 | markerCount -= removed.filter((event) => event.type === "mark").length; |
| 323 | droppedEventCount += removed.length; |
| 324 | } |
| 325 | }; |
| 326 | |
| 327 | const cancelAsync = () => { |
| 328 | if (stopTimer !== null) clearTimeout(stopTimer); |
| 329 | stopTimer = null; |
| 330 | if (sampleFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(sampleFrame); |
| 331 | sampleFrame = null; |
| 332 | }; |
| 333 | |
| 334 | const scheduleSample = () => { |
| 335 | if (!sampler || typeof requestAnimationFrame !== "function") return; |
| 336 | const tick = () => { |
| 337 | sampleFrame = null; |
| 338 | if (status !== "recording" || !sampler) return; |
| 339 | const elapsed = now() - startedAt; |
| 340 | if (elapsed - lastSampleAt >= 50) { |
| 341 | const sample = sampler(); |
| 342 | if (sample) { |
| 343 | const sanitized = sanitizeEvent(elapsed, "sample", sample); |
| 344 | const signature = JSON.stringify({ ...sanitized, t: 0 }); |
| 345 | if (signature !== lastSampleSignature || elapsed - lastSampleAt >= 250) { |
| 346 | append("sample", sample); |
| 347 | lastSampleSignature = signature; |
| 348 | lastSampleAt = elapsed; |
| 349 | } |
| 350 | } |
| 351 | } |
| 352 | sampleFrame = requestAnimationFrame(tick); |
| 353 | }; |
| 354 | sampleFrame = requestAnimationFrame(tick); |
| 355 | }; |
| 356 | |
| 357 | const snapshot = (): TranscriptScrollDiagnosticSnapshot => ({ |
| 358 | status, |
| 359 | durationMs: Math.max(0, Math.round((status === "recording" ? now() : stoppedAt) - startedAt)), |
| 360 | eventCount: events.length, |
| 361 | droppedEventCount, |
| 362 | markerCount, |
| 363 | reportId, |
| 364 | }); |
| 365 | |
| 366 | const stop = (): TranscriptScrollDiagnosticPayload => { |
| 367 | if (status === "idle" || !manifest) throw new Error("scroll diagnostics are not active"); |
| 368 | if (status === "recording") { |
| 369 | append("stop"); |
| 370 | stoppedAt = now(); |
| 371 | status = "stopped"; |
| 372 | cancelAsync(); |
| 373 | emit(); |
| 374 | } |
| 375 | return { |
| 376 | schemaVersion: TRANSCRIPT_SCROLL_DIAGNOSTIC_SCHEMA_VERSION, |
| 377 | manifest: { ...manifest, reportId, createdAt }, |
| 378 | summary: { |
| 379 | durationMs: Math.max(0, Math.round(stoppedAt - startedAt)), |
| 380 | eventCount: events.length, |
| 381 | droppedEventCount, |
| 382 | markerCount, |
| 383 | }, |
| 384 | events: events.map((event) => ({ ...event })), |
| 385 | }; |
| 386 | }; |
| 387 | |
| 388 | return { |
| 389 | start(nextSampler?: () => Sample) { |
| 390 | cancelAsync(); |
| 391 | status = "recording"; |
| 392 | startedAt = now(); |
| 393 | stoppedAt = startedAt; |
| 394 | reportId = randomID(); |
| 395 | createdAt = new Date().toISOString(); |
| 396 | manifest = environment(); |
| 397 | events = []; |
| 398 | droppedEventCount = 0; |
| 399 | markerCount = 0; |
| 400 | sampler = nextSampler; |
| 401 | lastSampleAt = -Infinity; |
| 402 | lastSampleSignature = ""; |
| 403 | append("start"); |
| 404 | stopTimer = setTimeout(() => stop(), maxDurationMs); |
| 405 | scheduleSample(); |
| 406 | emit(); |
| 407 | }, |
| 408 | stop, |
| 409 | reset() { |
| 410 | cancelAsync(); |
| 411 | status = "idle"; |
| 412 | startedAt = 0; |
| 413 | stoppedAt = 0; |
| 414 | reportId = ""; |
| 415 | createdAt = ""; |
| 416 | manifest = null; |
| 417 | events = []; |
| 418 | droppedEventCount = 0; |
| 419 | markerCount = 0; |
| 420 | sampler = undefined; |
| 421 | emit(); |
| 422 | }, |
| 423 | mark() { |
| 424 | if (status !== "recording") return; |
| 425 | markerCount += 1; |
| 426 | append("mark"); |
| 427 | emit(); |
| 428 | }, |
| 429 | record(type: TranscriptScrollDiagnosticEventType, fields: EventFields = {}) { |
| 430 | append(type, fields); |
| 431 | }, |
| 432 | getSnapshot: snapshot, |
| 433 | subscribe(listener: Listener) { |
| 434 | listeners.add(listener); |
| 435 | return () => { listeners.delete(listener); }; |
| 436 | }, |
| 437 | }; |
| 438 | } |
| 439 | |
| 440 | export const transcriptScrollDiagnostics = createTranscriptScrollDiagnostics(); |
| 441 | setTranscriptScrollDiagnosticSink((type, fields) => { |
| 442 | transcriptScrollDiagnostics.record(type as TranscriptScrollDiagnosticEventType, fields); |
| 443 | }); |
| 444 | |
| 445 | export { isTranscriptScrollDiagnosticsBuild } from "./transcriptScrollProbe"; |
| 446 |