@@ -121,6 +121,13 @@ export struct ChatTimeline {
121121 private followedActiveTurnKey: string = '';
122122 private userScrollActive: boolean = false;
123123 private historyTouchActive: boolean = false;
124+ private firstVisibleIndex: number = 0;
125+ private historyRestoreKey: string = '';
126+ private historyRestoreTop: number = 0;
127+ private lastVisibleIndex: number = 0;
128+ private historyRestoreTimer: number = 0;
129+ private historyRestoreAttempts: number = 0;
130+ private historyStableFrames: number = 0;
124131 // Whether new content should pull the viewport down with it. True until the
125132 // user scrolls up to read back through the transcript: at that point taking
126133 // the viewport away from them would be worse than letting the reply grow off
@@ -172,13 +179,15 @@ export struct ChatTimeline {
172179 aboutToDisappear(): void {
173180 RemoteLogger.info(`chat timeline disposed items=${this.timelineItems.length}`);
174181 this.clearFollowTimer();
182+ this.cancelHistoryAnchor();
175183 }
176184
177185 // A new turn means the user has just sent a message and expects to follow its
178186 // answer even if opening the keyboard changed the List viewport. Subsequent
179187 // revisions keep following only until the user deliberately scrolls away.
180- @Monitor('timelineRevision')
188+ @Monitor('timelineRevision', 'historyLoading' )
181189 onTimelineChanged(): void {
190+ this.restoreHistoryAnchorIfNeeded();
182191 this.logTimelineProjection('revision');
183192 const activeTurnKey = this.activeTurnKey();
184193 if (activeTurnKey.length > 0 && activeTurnKey !== this.followedActiveTurnKey) {
@@ -207,6 +216,7 @@ export struct ChatTimeline {
207216 if (!this.historyLoading) {
208217 this.historyArrival.cancelArrival();
209218 this.stickToBottom = false;
219+ this.captureHistoryAnchor();
210220 this.onLoadOlder();
211221 }
212222 })
@@ -339,19 +349,20 @@ export struct ChatTimeline {
339349 // contentPadding and SwiftUI's safeAreaInset.
340350 .contentStartOffset(this.topInset)
341351 .contentEndOffset(this.bottomInset)
342- // A streaming reply grows in place rather than arriving as a new item, so
343- // the bottom of the list has to be the anchor — otherwise the text the
344- // agent is writing right now renders below the fold and never comes back.
345- .stackFromEnd(true)
352+ // Keep short transcripts at the content start. Active turns and explicit
353+ // follow-to-bottom calls still place streaming content at the tail.
354+ .stackFromEnd(false)
346355 // Keeps the read position when older messages are prepended above.
347- .maintainVisibleContentPosition(true )
356+ .maintainVisibleContentPosition(false )
348357 .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
349358 .scrollBar(BarState.Off)
350359 .cachedCount(4)
351360 .onTouch((event: TouchEvent) => {
352361 if (event.type === TouchType.Down) {
362+ if (!this.historyLoading) this.cancelHistoryAnchor();
353363 this.historyTouchActive = true;
354364 this.historyArrival.beginGesture();
365+ RemoteLogger.info(`history gesture begin loading=${this.historyLoading} offset=${this.listScroller.currentOffset().yOffset}`);
355366 } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
356367 this.historyTouchActive = false;
357368 }
@@ -361,6 +372,10 @@ export struct ChatTimeline {
361372 this.userScrollActive = true;
362373 }
363374 })
375+ .onScrollIndex((start: number, end: number, _center: number) => {
376+ this.firstVisibleIndex = start;
377+ this.lastVisibleIndex = end;
378+ })
364379 .onDidScroll((_scrollOffset: number, scrollState: ScrollState) => {
365380 if (this.listScroller.isAtEnd()) {
366381 this.stickToBottom = true;
@@ -465,19 +480,101 @@ export struct ChatTimeline {
465480 private evaluateHistoryStart(): void {
466481 const atStart: boolean = this.listScroller.currentOffset().yOffset <= 0;
467482 const readerOwnsPosition: boolean = this.historyTouchActive && this.userScrollActive;
483+ if (atStart && this.historyTouchActive) {
484+ RemoteLogger.info(`history start check owns=${readerOwnsPosition} loading=${this.historyLoading} hasMore=${this.hasMoreMessages} failed=${this.historyFailed}`);
485+ }
468486 if (!this.historyArrival.arrived(atStart && readerOwnsPosition)) {
469487 return;
470488 }
471489 if (this.hasMoreMessages && !this.historyLoading && !this.historyFailed) {
472490 this.stickToBottom = false;
491+ this.captureHistoryAnchor();
473492 this.onLoadOlder();
474493 return;
475494 }
476495 // A drag made while busy is consumed, not queued for after completion.
477496 }
478497
498+ private cancelHistoryAnchor(): void {
499+ if (this.historyRestoreTimer !== 0) clearTimeout(this.historyRestoreTimer);
500+ this.historyRestoreTimer = 0;
501+ this.historyRestoreKey = '';
502+ this.historyRestoreAttempts = 0;
503+ this.historyStableFrames = 0;
504+ }
505+
506+ private captureHistoryAnchor(): void {
507+ const history = this.historyTimelineItems();
508+ const header = this.hasMoreMessages ? 1 : 0;
509+ // A page can extend the first assistant row upwards without adding rows.
510+ // Preserve the lower visible row's trailing edge, not that row's changing head.
511+ const index = Math.min(history.length - 1, Math.max(0, this.lastVisibleIndex - header));
512+ const item = history[index];
513+ if (!item) return;
514+ try {
515+ const rect = this.listScroller.getItemRect(index + header);
516+ if (rect.height <= 0) return;
517+ this.historyRestoreKey = ChatTimelineRevisionTracker.itemKey(item);
518+ this.historyRestoreTop = rect.y + rect.height;
519+ this.historyRestoreAttempts = 0;
520+ this.historyStableFrames = 0;
521+ this.clearFollowTimer();
522+ this.stickToBottom = false;
523+ RemoteLogger.info(
524+ `history viewport capture key=${this.historyRestoreKey} index=${index} bottom=${this.historyRestoreTop} rows=${history.length}`
525+ );
526+ } catch (_) {
527+ RemoteLogger.warn('History viewport capture unavailable');
528+ }
529+ }
530+
531+ private restoreHistoryAnchorIfNeeded(): void {
532+ if (!this.historyRestoreKey || this.historyLoading || this.historyRestoreTimer !== 0) return;
533+ this.historyRestoreTimer = setTimeout(() => {
534+ this.historyRestoreTimer = 0;
535+ const key = this.historyRestoreKey;
536+ const history = this.historyTimelineItems();
537+ const index = history.findIndex((item: ObservableChatTimelineItem) =>
538+ ChatTimelineRevisionTracker.itemKey(item) === key);
539+ if (index < 0 || this.historyFailed) {
540+ this.cancelHistoryAnchor();
541+ return;
542+ }
543+ this.historyRestoreAttempts++;
544+ try {
545+ const listIndex = index + (this.hasMoreMessages ? 1 : 0);
546+ const rect = this.listScroller.getItemRect(listIndex);
547+ if (rect.height <= 0) {
548+ // Mount the identity's new virtual position before measuring it.
549+ this.listScroller.scrollToIndex(listIndex, false, ScrollAlign.END);
550+ this.historyStableFrames = 0;
551+ } else {
552+ const actual = rect.y + rect.height;
553+ const delta = actual - this.historyRestoreTop;
554+ RemoteLogger.info(
555+ `history viewport measure key=${key} rows=${history.length} actual=${actual} expected=${this.historyRestoreTop} delta=${delta} attempt=${this.historyRestoreAttempts}`
556+ );
557+ if (Math.abs(delta) > 0.5) {
558+ this.historyStableFrames = 0;
559+ this.listScroller.scrollBy(0, delta);
560+ } else {
561+ this.historyStableFrames++;
562+ }
563+ }
564+ } catch (_) {
565+ this.listScroller.scrollToIndex(index + (this.hasMoreMessages ? 1 : 0), false, ScrollAlign.END);
566+ }
567+ if (this.historyStableFrames >= 3 || this.historyRestoreAttempts >= 30) {
568+ RemoteLogger.info(`history viewport settle stable=${this.historyStableFrames} attempts=${this.historyRestoreAttempts}`);
569+ this.cancelHistoryAnchor();
570+ } else {
571+ this.restoreHistoryAnchorIfNeeded();
572+ }
573+ }, 16);
574+ }
575+
479576 private requestFollowToBottom(reason: string): void {
480- if (!this.stickToBottom || this.followTimerId !== 0) {
577+ if (this.historyRestoreKey || this.historyLoading || !this.stickToBottom || this.followTimerId !== 0) {
481578 return;
482579 }
483580 this.followTimerId = setTimeout(() => {
@@ -508,6 +605,11 @@ export struct ChatTimeline {
508605 source === ScrollSource.EDGE_EFFECT || source === ScrollSource.OTHER_USER_INPUT;
509606 }
510607
608+ private areaPosition(position: Object): number {
609+ const value = (position as Record<string, Object>)?.y;
610+ return this.areaLength(value === undefined ? 0 : value);
611+ }
612+
511613 private areaLength(value: Object): number {
512614 const parsed = Number.parseFloat(`${value}`);
513615 return Number.isNaN(parsed) ? 0 : parsed;
0 commit comments