返回 DeepSeek-Reasonix
transcriptViewportWriter.ts
根目录 / desktop / frontend / src / lib / transcriptViewportWriter.ts
1 import { noteTranscriptScrollWrite, recordTranscriptScrollDiagnostic } from "./transcriptScrollProbe";
2 type TranscriptWriteRequest = { session: string; generation: number; transactionId: number; geometryRevision: number; owner: "tail-follow" | "restore"; intent: "tail" | "reader"; offset: number };
3 type TranscriptWriteResult = { accepted: boolean; offset: number; changed?: boolean; reason?: string };
4
5 /** The only production owner allowed to mutate the native Transcript scroll
6 * position. Every adapter, gesture, and transaction routes through this class. */
7 export class TranscriptViewportWriter {
8 private element: HTMLElement | null = null;
9 private generation = 0;
10 private frozen = false;
11
12 attach(element: HTMLElement | null, generation: number): void {
13 this.element = element;
14 this.generation = generation;
15 }
16
17 freeze(value: boolean): void {
18 this.frozen = value;
19 }
20
21 write = (request: TranscriptWriteRequest): TranscriptWriteResult => {
22 const element = this.element;
23 if (!element) return this.reject(request, "no-viewport");
24 if (request.generation !== this.generation) return this.reject(request, "stale-generation");
25 if (this.frozen) return this.reject(request, "user-gesture");
26 if (!Number.isFinite(element.scrollHeight) || !Number.isFinite(element.clientHeight)) {
27 return this.reject(request, "invalid-geometry");
28 }
29 const maximum = Math.max(0, element.scrollHeight - element.clientHeight);
30 const accepted = Number.isFinite(request.offset)
31 ? Math.max(0, Math.min(maximum, request.offset))
32 : maximum;
33 const before = element.scrollTop;
34 const noOp = Math.abs(before - accepted) <= 0.5;
35 if (!noOp) element.scrollTop = accepted;
36 const landed = element.scrollTop;
37 const acceptedByNative = Math.abs(landed - accepted) <= 4;
38 noteTranscriptScrollWrite({
39 session: request.session,
40 transaction: request.transactionId,
41 owner: request.owner,
42 intent: request.intent,
43 requestedOffset: request.offset,
44 acceptedOffset: landed,
45 outcome: noOp ? "no-op" : acceptedByNative ? "accepted" : "native-clamp",
46 kind: request.owner === "tail-follow" ? "pinTail" : "scrollTo",
47 top: request.offset,
48 scrollTop: landed,
49 scrollHeight: element.scrollHeight,
50 clientHeight: element.clientHeight,
51 bottomDistance: maximum - landed,
52 mode: request.intent,
53 generation: request.generation,
54 geometryRevision: request.geometryRevision,
55 transactionId: request.transactionId,
56 });
57 return { accepted: acceptedByNative, offset: landed, changed: Math.abs(landed - before) > 0.5, reason: acceptedByNative ? undefined : "native-clamp" };
58 };
59
60 private reject(request: TranscriptWriteRequest, reason: string): TranscriptWriteResult {
61 const offset = this.element?.scrollTop ?? 0;
62 recordTranscriptScrollDiagnostic("scroll-write-rejected", {
63 session: request.session,
64 generation: request.generation,
65 transaction: request.transactionId,
66 owner: request.owner,
67 intent: request.intent,
68 geometryRevision: request.geometryRevision,
69 requestedOffset: request.offset,
70 acceptedOffset: offset,
71 outcome: reason,
72 });
73 noteTranscriptScrollWrite({
74 session: request.session,
75 transaction: request.transactionId,
76 owner: request.owner,
77 intent: request.intent,
78 requestedOffset: request.offset,
79 acceptedOffset: offset,
80 outcome: reason,
81 kind: "scrollTo",
82 top: request.offset,
83 scrollTop: offset,
84 mode: request.intent,
85 generation: request.generation,
86 geometryRevision: request.geometryRevision,
87 transactionId: request.transactionId,
88 rejectedReason: reason,
89 });
90 return { accepted: false, offset, reason };
91 }
92 }
93
93 lines TYPESCRIPT