返回 DeepSeek-Reasonix
transcriptScrollDiagnostics.ts
根目录 / desktop / frontend / src / lib / transcriptScrollDiagnostics.ts
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
446 lines TYPESCRIPT