|
| 1 | +import { |
| 2 | + ChangeDetectionStrategy, |
| 3 | + Component, |
| 4 | + DestroyRef, |
| 5 | + afterNextRender, |
| 6 | + afterRenderEffect, |
| 7 | + inject, |
| 8 | + signal, |
| 9 | + viewChild, |
| 10 | + viewChildren, |
| 11 | +} from '@angular/core' |
| 12 | +import { injectVirtualizer } from '@tanstack/angular-virtual' |
| 13 | +import type { ElementRef } from '@angular/core' |
| 14 | + |
| 15 | +type Message = { |
| 16 | + id: string |
| 17 | + author: 'user' | 'assistant' |
| 18 | + text: string |
| 19 | +} |
| 20 | + |
| 21 | +const replies = [ |
| 22 | + 'I can break that into the smallest next step and keep the current viewport pinned while this answer grows.', |
| 23 | + 'Older messages are loaded above the viewport. The visible row keeps the same screen position after the prepend.', |
| 24 | + 'When the thread is not at the bottom, new output waits below without pulling the reader away from history.', |
| 25 | +] |
| 26 | + |
| 27 | +const makeMessage = (index: number): Message => ({ |
| 28 | + id: `message-${index}`, |
| 29 | + author: index % 4 === 0 ? 'user' : 'assistant', |
| 30 | + text: |
| 31 | + index % 4 === 0 |
| 32 | + ? `Can you check item ${index}?` |
| 33 | + : `Message ${index}: ${replies[Math.abs(index) % replies.length]}`, |
| 34 | +}) |
| 35 | + |
| 36 | +const initialMessages = Array.from({ length: 45 }, (_, index) => |
| 37 | + makeMessage(index), |
| 38 | +) |
| 39 | + |
| 40 | +@Component({ |
| 41 | + selector: 'app-root', |
| 42 | + standalone: true, |
| 43 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 44 | + template: ` |
| 45 | + <div class="App"> |
| 46 | + <div class="Toolbar"> |
| 47 | + <div class="ToolbarGroup"> |
| 48 | + <button type="button" (click)="prependHistory()">Load older</button> |
| 49 | + <button type="button" (click)="appendMessage()">Add message</button> |
| 50 | + <button type="button" (click)="streamReply()">Stream reply</button> |
| 51 | + <button type="button" (click)="virtualizer.scrollToEnd()"> |
| 52 | + Latest |
| 53 | + </button> |
| 54 | + </div> |
| 55 | + <div class="Status"> |
| 56 | + @if (loadingHistory()) { |
| 57 | + Loading history |
| 58 | + } @else if (virtualizer.isAtEnd(80)) { |
| 59 | + At latest |
| 60 | + } @else { |
| 61 | + Reading history |
| 62 | + } |
| 63 | + </div> |
| 64 | + </div> |
| 65 | +
|
| 66 | + <div class="Shell"> |
| 67 | + <div #scrollElement class="Messages" (scroll)="onScroll()"> |
| 68 | + <div |
| 69 | + class="MessagesSizer" |
| 70 | + [style.height.px]="virtualizer.getTotalSize()" |
| 71 | + > |
| 72 | + @for (row of virtualizer.getVirtualItems(); track row.key) { |
| 73 | + <div |
| 74 | + #virtualItem |
| 75 | + class="MessageRow" |
| 76 | + [attr.data-index]="row.index" |
| 77 | + [style.transform]="'translateY(' + row.start + 'px)'" |
| 78 | + > |
| 79 | + <div |
| 80 | + class="Bubble" |
| 81 | + [class.Bubble-user]="messages()[row.index].author === 'user'" |
| 82 | + [class.Bubble-assistant]=" |
| 83 | + messages()[row.index].author === 'assistant' |
| 84 | + " |
| 85 | + > |
| 86 | + <div class="Meta">{{ messages()[row.index].author }}</div> |
| 87 | + {{ messages()[row.index].text || '...' }} |
| 88 | + </div> |
| 89 | + </div> |
| 90 | + } |
| 91 | + </div> |
| 92 | + </div> |
| 93 | + </div> |
| 94 | + </div> |
| 95 | + `, |
| 96 | +}) |
| 97 | +export class AppComponent { |
| 98 | + readonly messages = signal(initialMessages) |
| 99 | + readonly loadingHistory = signal(false) |
| 100 | + |
| 101 | + readonly scrollElement = |
| 102 | + viewChild<ElementRef<HTMLDivElement>>('scrollElement') |
| 103 | + readonly virtualItems = |
| 104 | + viewChildren<ElementRef<HTMLDivElement>>('virtualItem') |
| 105 | + |
| 106 | + private firstMessageIndex = 0 |
| 107 | + private nextMessageIndex = initialMessages.length |
| 108 | + private autoHistoryTimer: number | undefined |
| 109 | + private streamTimer: number | undefined |
| 110 | + private autoHistoryEnabled = false |
| 111 | + |
| 112 | + // Register before the virtualizer so item measurement runs before its |
| 113 | + // post-render scroll reconciliation, matching React refs before layout effects. |
| 114 | + private readonly measureItems = afterRenderEffect({ |
| 115 | + mixedReadWrite: () => this.measureVirtualItems(), |
| 116 | + }) |
| 117 | + |
| 118 | + readonly virtualizer = injectVirtualizer<HTMLDivElement, HTMLDivElement>( |
| 119 | + () => ({ |
| 120 | + scrollElement: this.scrollElement(), |
| 121 | + count: this.messages().length, |
| 122 | + estimateSize: () => 74, |
| 123 | + getItemKey: (index) => this.messages()[index].id, |
| 124 | + anchorTo: 'end', |
| 125 | + followOnAppend: true, |
| 126 | + scrollEndThreshold: 80, |
| 127 | + overscan: 6, |
| 128 | + useApplicationRefTick: false, |
| 129 | + }), |
| 130 | + ) |
| 131 | + |
| 132 | + private measureVirtualItems(): void { |
| 133 | + this.virtualItems().forEach((element) => { |
| 134 | + this.virtualizer.measureElement(element.nativeElement) |
| 135 | + }) |
| 136 | + } |
| 137 | + |
| 138 | + constructor() { |
| 139 | + const destroyRef = inject(DestroyRef) |
| 140 | + |
| 141 | + afterNextRender(() => { |
| 142 | + this.virtualizer.scrollToEnd() |
| 143 | + this.autoHistoryTimer = window.setTimeout(() => { |
| 144 | + this.autoHistoryEnabled = true |
| 145 | + }, 250) |
| 146 | + }) |
| 147 | + |
| 148 | + destroyRef.onDestroy(() => { |
| 149 | + if (this.autoHistoryTimer !== undefined) { |
| 150 | + window.clearTimeout(this.autoHistoryTimer) |
| 151 | + } |
| 152 | + if (this.streamTimer !== undefined) { |
| 153 | + window.clearInterval(this.streamTimer) |
| 154 | + } |
| 155 | + }) |
| 156 | + } |
| 157 | + |
| 158 | + prependHistory(): void { |
| 159 | + if (this.loadingHistory() || this.firstMessageIndex <= -90) return |
| 160 | + |
| 161 | + this.loadingHistory.set(true) |
| 162 | + window.setTimeout(() => { |
| 163 | + const start = this.firstMessageIndex - 12 |
| 164 | + this.firstMessageIndex = start |
| 165 | + this.messages.update((current) => [ |
| 166 | + ...Array.from({ length: 12 }, (_, offset) => |
| 167 | + makeMessage(start + offset), |
| 168 | + ), |
| 169 | + ...current, |
| 170 | + ]) |
| 171 | + this.loadingHistory.set(false) |
| 172 | + }, 180) |
| 173 | + } |
| 174 | + |
| 175 | + appendMessage(): void { |
| 176 | + const next = this.nextMessageIndex |
| 177 | + this.nextMessageIndex += 1 |
| 178 | + this.messages.update((current) => [...current, makeMessage(next)]) |
| 179 | + } |
| 180 | + |
| 181 | + streamReply(): void { |
| 182 | + if (this.streamTimer !== undefined) return |
| 183 | + |
| 184 | + const id = `stream-${Date.now()}` |
| 185 | + const chunks = [ |
| 186 | + 'Thinking through the failure mode.', |
| 187 | + ' The list should follow only when it was already pinned.', |
| 188 | + ' Prepends should keep the reader anchored to the same message.', |
| 189 | + ' Streaming output should grow without drifting off the bottom.', |
| 190 | + ] |
| 191 | + let chunkIndex = 0 |
| 192 | + |
| 193 | + this.messages.update((current) => [ |
| 194 | + ...current, |
| 195 | + { id, author: 'assistant', text: '' }, |
| 196 | + ]) |
| 197 | + |
| 198 | + this.streamTimer = window.setInterval(() => { |
| 199 | + this.messages.update((current) => |
| 200 | + current.map((message) => |
| 201 | + message.id === id |
| 202 | + ? { |
| 203 | + ...message, |
| 204 | + text: chunks.slice(0, chunkIndex + 1).join(''), |
| 205 | + } |
| 206 | + : message, |
| 207 | + ), |
| 208 | + ) |
| 209 | + |
| 210 | + chunkIndex += 1 |
| 211 | + if (chunkIndex === chunks.length) { |
| 212 | + window.clearInterval(this.streamTimer) |
| 213 | + this.streamTimer = undefined |
| 214 | + } |
| 215 | + }, 280) |
| 216 | + } |
| 217 | + |
| 218 | + onScroll(): void { |
| 219 | + const scrollElement = this.scrollElement()?.nativeElement |
| 220 | + if ( |
| 221 | + !scrollElement || |
| 222 | + !this.autoHistoryEnabled || |
| 223 | + this.virtualizer.isAtEnd(80) |
| 224 | + ) { |
| 225 | + return |
| 226 | + } |
| 227 | + |
| 228 | + if (scrollElement.scrollTop < 120) this.prependHistory() |
| 229 | + } |
| 230 | +} |
0 commit comments