| 1 | import { TranscriptViewportWriter } from "./transcriptViewportWriter"; |
| 2 | |
| 3 | type Anchor = { key: string; top: number; turn?: string; previous: string[] }; |
| 4 | type Position = { following: boolean; anchor?: Anchor }; |
| 5 | const positions = new Map<string, Position>(); |
| 6 | let generation = 0; |
| 7 | |
| 8 | /** One native writer, no geometry state in React and no recursive measurement publication. */ |
| 9 | export class ChatScrollController { |
| 10 | readonly generation = ++generation; |
| 11 | private writer = new TranscriptViewportWriter(); |
| 12 | private element?: HTMLElement; |
| 13 | private content?: HTMLElement; |
| 14 | private observer?: ResizeObserver; |
| 15 | private mutations?: MutationObserver; |
| 16 | private observedRows = new Set<HTMLElement>(); |
| 17 | private anchor?: Anchor; |
| 18 | private following = true; |
| 19 | private observedTop = 0; |
| 20 | private userScrollUntil = 0; |
| 21 | private disposed = false; |
| 22 | private opened = false; |
| 23 | private frame = 0; |
| 24 | private layoutFrame = 0; |
| 25 | private attachment = 0; |
| 26 | private transaction = 0; |
| 27 | private listeners = new Set<() => void>(); |
| 28 | private snapshot = { following: true, activeKey: "" }; |
| 29 | // Reader intent is reported on its own channel: a pending navigation must be |
| 30 | // able to yield to a wheel tick without publishing a React-visible snapshot |
| 31 | // for every event of the gesture. |
| 32 | private readerListeners = new Set<() => void>(); |
| 33 | private readerEpoch = 0; |
| 34 | constructor(readonly sessionKey: string) {} |
| 35 | subscribeReaderIntent = (listener: () => void): (() => void) => { |
| 36 | this.readerListeners.add(listener); |
| 37 | return () => { this.readerListeners.delete(listener); }; |
| 38 | }; |
| 39 | private noteReaderIntent() { |
| 40 | this.readerEpoch++; |
| 41 | for (const listener of [...this.readerListeners]) listener(); |
| 42 | } |
| 43 | getSnapshot = () => this.snapshot; |
| 44 | subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; |
| 45 | private publish() { |
| 46 | const activeKey = this.anchor?.turn ?? this.anchor?.key ?? ""; |
| 47 | if (this.snapshot.following === this.following && this.snapshot.activeKey === activeKey) return; |
| 48 | this.snapshot = { following: this.following, activeKey }; |
| 49 | this.listeners.forEach(listener => listener()); |
| 50 | } |
| 51 | attach(element: HTMLElement, content: HTMLElement) { |
| 52 | const attachment = ++this.attachment; |
| 53 | this.disposed = false; |
| 54 | this.element = element; |
| 55 | this.content = content; |
| 56 | this.writer.attach(element, this.generation); |
| 57 | element.addEventListener("scroll", this.onScroll, { passive: true }); |
| 58 | element.addEventListener("wheel", this.onWheel, { passive: true }); |
| 59 | element.addEventListener("touchstart", this.onRead, { passive: true }); |
| 60 | element.addEventListener("keydown", this.onKey); |
| 61 | element.addEventListener("pointerdown", this.onPointer, { passive: true }); |
| 62 | if (typeof ResizeObserver !== "undefined") { |
| 63 | this.observer = new ResizeObserver(() => { if (!this.disposed && attachment === this.attachment) this.scheduleLayout(); }); |
| 64 | this.observer.observe(content); |
| 65 | this.observer.observe(element); |
| 66 | } |
| 67 | if (typeof MutationObserver !== "undefined") { |
| 68 | this.mutations = new MutationObserver(() => { |
| 69 | if (this.disposed || attachment !== this.attachment) return; |
| 70 | const rows = new Set(this.rows()); |
| 71 | for (const row of this.observedRows) if (!rows.has(row)) this.observer?.unobserve(row); |
| 72 | for (const row of rows) if (!this.observedRows.has(row)) this.observer?.observe(row); |
| 73 | this.observedRows = rows; |
| 74 | this.scheduleLayout(); |
| 75 | }); |
| 76 | // Chat nodes are direct children of the column. Internal Markdown and |
| 77 | // tool-body mutations are already covered by the row ResizeObserver; |
| 78 | // observing the full subtree would rescan every loaded row for each |
| 79 | // worker parse and turns cumulative history loading into quadratic work. |
| 80 | this.mutations.observe(content, { childList: true }); |
| 81 | } |
| 82 | } |
| 83 | private scheduleLayout() { |
| 84 | if (this.layoutFrame) return; |
| 85 | const attachment = this.attachment; |
| 86 | this.layoutFrame = requestAnimationFrame(() => { |
| 87 | this.layoutFrame = 0; |
| 88 | if (!this.disposed && attachment === this.attachment) this.layout(); |
| 89 | }); |
| 90 | } |
| 91 | ready() { |
| 92 | if (this.opened || !this.element) return; |
| 93 | this.opened = true; |
| 94 | const saved = positions.get(this.sessionKey); |
| 95 | this.following = saved?.following ?? true; |
| 96 | this.anchor = saved?.anchor; |
| 97 | this.layout(); |
| 98 | } |
| 99 | private rows() { |
| 100 | const content = this.content; |
| 101 | if (!content) return []; |
| 102 | return Array.from(content.children).filter((row): row is HTMLElement => |
| 103 | row instanceof HTMLElement && row.hasAttribute("data-chat-anchor-key") && row.childNodes.length > 0); |
| 104 | } |
| 105 | private capture() { |
| 106 | const el = this.element; |
| 107 | if (!el) return; |
| 108 | const top = el.getBoundingClientRect().top; |
| 109 | const rows = this.rows(); |
| 110 | let low = 0, high = rows.length; |
| 111 | while (low < high) { |
| 112 | const middle = (low + high) >>> 1; |
| 113 | if (rows[middle].getBoundingClientRect().bottom <= top + 1) low = middle + 1; else high = middle; |
| 114 | } |
| 115 | const index = Math.min(low, rows.length - 1); |
| 116 | const row = rows[index]; |
| 117 | if (row) this.anchor = { key: row.dataset.chatAnchorKey!, top: row.getBoundingClientRect().top - top, |
| 118 | turn: row.dataset.chatTurn, previous: rows.slice(0, index).map(row => row.dataset.chatAnchorKey!) }; |
| 119 | } |
| 120 | private write(offset: number, owner: "tail-follow" | "restore" = "restore") { |
| 121 | if (this.disposed || !this.element) return; |
| 122 | this.writer.write({ session: this.sessionKey, generation: this.generation, transactionId: ++this.transaction, |
| 123 | geometryRevision: 0, owner, intent: this.following ? "tail" : "reader", offset }); |
| 124 | this.observedTop = this.element.scrollTop; |
| 125 | } |
| 126 | layout() { |
| 127 | const el = this.element; |
| 128 | if (!el || !this.opened || this.disposed) return; |
| 129 | if (this.following) this.write(el.scrollHeight, "tail-follow"); |
| 130 | else if (this.anchor) { |
| 131 | const rows = this.rows(); |
| 132 | let row = rows.find(row => row.dataset.chatAnchorKey === this.anchor!.key); |
| 133 | if (!row) row = rows.find(row => row.dataset.chatAnchorKey === `${this.anchor!.turn}:process`); |
| 134 | if (!row) { |
| 135 | const previous = new Set(this.anchor.previous); |
| 136 | row = [...rows].reverse().find(row => previous.has(row.dataset.chatAnchorKey!)) ?? rows[0]; |
| 137 | } |
| 138 | if (row) this.write(el.scrollTop + row.getBoundingClientRect().top - el.getBoundingClientRect().top - this.anchor.top); |
| 139 | } |
| 140 | this.capture(); this.save(); this.publish(); |
| 141 | } |
| 142 | private save() { |
| 143 | positions.delete(this.sessionKey); |
| 144 | positions.set(this.sessionKey, { following: this.following, anchor: this.anchor }); |
| 145 | // Same lifetime policy as inactive view caches; contains identities, never message bodies. |
| 146 | if (positions.size > 100) positions.delete(positions.keys().next().value!); |
| 147 | } |
| 148 | toBottom = () => { this.userScrollUntil = 0; this.following = true; this.anchor = undefined; this.noteReaderIntent(); this.layout(); }; |
| 149 | /** Leave tail following without reporting reader intent: a navigation jump is |
| 150 | * programmatic, so it must not cancel itself. */ |
| 151 | stopFollowing = () => { this.following = false; this.capture(); this.save(); this.publish(); }; |
| 152 | jump = (key: string) => { |
| 153 | const el = this.element; |
| 154 | const row = this.rows().find(row => row.dataset.chatAnchorKey === key); |
| 155 | if (!el || !row) return; |
| 156 | this.following = false; |
| 157 | this.write(el.scrollTop + row.getBoundingClientRect().top - el.getBoundingClientRect().top); |
| 158 | this.capture(); this.save(); this.publish(); |
| 159 | }; |
| 160 | beforeChange = () => { if (!this.following) this.capture(); }; |
| 161 | private onRead = () => { this.userScrollUntil = performance.now() + 1000; this.following = false; this.capture(); this.save(); this.publish(); this.noteReaderIntent(); }; |
| 162 | private onWheel = (event: WheelEvent) => { this.userScrollUntil = performance.now() + 1000; this.noteReaderIntent(); if (event.deltaY < 0) this.onRead(); }; |
| 163 | private onKey = (event: KeyboardEvent) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) this.onRead(); }; |
| 164 | private onPointer = (event: PointerEvent) => { |
| 165 | const el = this.element; |
| 166 | if (el && (event.target === el || event.shiftKey)) this.onRead(); |
| 167 | }; |
| 168 | private onScroll = () => { |
| 169 | const el = this.element; |
| 170 | if (!el || this.disposed) return; |
| 171 | const floor = Math.max(0, el.scrollHeight - el.clientHeight); |
| 172 | if (Math.abs(el.scrollTop - Math.min(this.observedTop, floor)) > 0.5) { |
| 173 | // WebKit can emit a native clamp after asynchronous content replacement. |
| 174 | // Only actual input may change reader intent; layout is not user input. |
| 175 | if (!this.following || performance.now() < this.userScrollUntil) { |
| 176 | this.following = el.scrollTop > this.observedTop && floor - el.scrollTop <= 24; |
| 177 | this.capture(); this.save(); |
| 178 | } else this.scheduleLayout(); |
| 179 | } |
| 180 | this.observedTop = el.scrollTop; |
| 181 | if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = 0; if (!this.disposed) this.publish(); }); |
| 182 | }; |
| 183 | dispose() { |
| 184 | this.attachment++; |
| 185 | this.save(); this.disposed = true; |
| 186 | const el = this.element; |
| 187 | el?.removeEventListener("scroll", this.onScroll); |
| 188 | el?.removeEventListener("wheel", this.onWheel); |
| 189 | el?.removeEventListener("touchstart", this.onRead); |
| 190 | el?.removeEventListener("keydown", this.onKey); |
| 191 | el?.removeEventListener("pointerdown", this.onPointer); |
| 192 | this.observer?.disconnect(); |
| 193 | this.mutations?.disconnect(); this.observedRows.clear(); |
| 194 | cancelAnimationFrame(this.frame); this.frame = 0; |
| 195 | cancelAnimationFrame(this.layoutFrame); this.layoutFrame = 0; |
| 196 | this.writer.attach(null, ++generation); |
| 197 | this.element = undefined; this.content = undefined; this.opened = false; |
| 198 | this.listeners.clear(); this.readerListeners.clear(); |
| 199 | } |
| 200 | } |
| 201 |