Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ internal fun ConversationTimelineView(
top = topInset,
bottom = if (bottomInset > 0.dp) bottomInset else 12.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Bottom),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (hasLeadingItem) {
item(key = "load-older-messages") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ export struct ChatTimeline {
private followedActiveTurnKey: string = '';
private userScrollActive: boolean = false;
private historyTouchActive: boolean = false;
private firstVisibleIndex: number = 0;
private historyRestoreKey: string = '';
private historyRestoreTop: number = 0;
private lastVisibleIndex: number = 0;
private historyRestoreTimer: number = 0;
private historyRestoreAttempts: number = 0;
private historyStableFrames: number = 0;
// Whether new content should pull the viewport down with it. True until the
// user scrolls up to read back through the transcript: at that point taking
// the viewport away from them would be worse than letting the reply grow off
Expand Down Expand Up @@ -172,13 +179,15 @@ export struct ChatTimeline {
aboutToDisappear(): void {
RemoteLogger.info(`chat timeline disposed items=${this.timelineItems.length}`);
this.clearFollowTimer();
this.cancelHistoryAnchor();
}

// A new turn means the user has just sent a message and expects to follow its
// answer even if opening the keyboard changed the List viewport. Subsequent
// revisions keep following only until the user deliberately scrolls away.
@Monitor('timelineRevision')
@Monitor('timelineRevision', 'historyLoading')
onTimelineChanged(): void {
this.restoreHistoryAnchorIfNeeded();
this.logTimelineProjection('revision');
const activeTurnKey = this.activeTurnKey();
if (activeTurnKey.length > 0 && activeTurnKey !== this.followedActiveTurnKey) {
Expand Down Expand Up @@ -207,6 +216,7 @@ export struct ChatTimeline {
if (!this.historyLoading) {
this.historyArrival.cancelArrival();
this.stickToBottom = false;
this.captureHistoryAnchor();
this.onLoadOlder();
}
})
Expand Down Expand Up @@ -339,19 +349,20 @@ export struct ChatTimeline {
// contentPadding and SwiftUI's safeAreaInset.
.contentStartOffset(this.topInset)
.contentEndOffset(this.bottomInset)
// A streaming reply grows in place rather than arriving as a new item, so
// the bottom of the list has to be the anchor — otherwise the text the
// agent is writing right now renders below the fold and never comes back.
.stackFromEnd(true)
// Keep short transcripts at the content start. Active turns and explicit
// follow-to-bottom calls still place streaming content at the tail.
.stackFromEnd(false)
// Keeps the read position when older messages are prepended above.
.maintainVisibleContentPosition(true)
.maintainVisibleContentPosition(false)
.edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
.scrollBar(BarState.Off)
.cachedCount(4)
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
if (!this.historyLoading) this.cancelHistoryAnchor();
this.historyTouchActive = true;
this.historyArrival.beginGesture();
RemoteLogger.info(`history gesture begin loading=${this.historyLoading} offset=${this.listScroller.currentOffset().yOffset}`);
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.historyTouchActive = false;
}
Expand All @@ -361,6 +372,10 @@ export struct ChatTimeline {
this.userScrollActive = true;
}
})
.onScrollIndex((start: number, end: number, _center: number) => {
this.firstVisibleIndex = start;
this.lastVisibleIndex = end;
})
.onDidScroll((_scrollOffset: number, scrollState: ScrollState) => {
if (this.listScroller.isAtEnd()) {
this.stickToBottom = true;
Expand Down Expand Up @@ -465,19 +480,101 @@ export struct ChatTimeline {
private evaluateHistoryStart(): void {
const atStart: boolean = this.listScroller.currentOffset().yOffset <= 0;
const readerOwnsPosition: boolean = this.historyTouchActive && this.userScrollActive;
if (atStart && this.historyTouchActive) {
RemoteLogger.info(`history start check owns=${readerOwnsPosition} loading=${this.historyLoading} hasMore=${this.hasMoreMessages} failed=${this.historyFailed}`);
}
if (!this.historyArrival.arrived(atStart && readerOwnsPosition)) {
return;
}
if (this.hasMoreMessages && !this.historyLoading && !this.historyFailed) {
this.stickToBottom = false;
this.captureHistoryAnchor();
this.onLoadOlder();
return;
}
// A drag made while busy is consumed, not queued for after completion.
}

private cancelHistoryAnchor(): void {
if (this.historyRestoreTimer !== 0) clearTimeout(this.historyRestoreTimer);
this.historyRestoreTimer = 0;
this.historyRestoreKey = '';
this.historyRestoreAttempts = 0;
this.historyStableFrames = 0;
}

private captureHistoryAnchor(): void {
const history = this.historyTimelineItems();
const header = this.hasMoreMessages ? 1 : 0;
// A page can extend the first assistant row upwards without adding rows.
// Preserve the lower visible row's trailing edge, not that row's changing head.
const index = Math.min(history.length - 1, Math.max(0, this.lastVisibleIndex - header));
const item = history[index];
if (!item) return;
try {
const rect = this.listScroller.getItemRect(index + header);
if (rect.height <= 0) return;
this.historyRestoreKey = ChatTimelineRevisionTracker.itemKey(item);
this.historyRestoreTop = rect.y + rect.height;
this.historyRestoreAttempts = 0;
this.historyStableFrames = 0;
this.clearFollowTimer();
this.stickToBottom = false;
RemoteLogger.info(
`history viewport capture key=${this.historyRestoreKey} index=${index} bottom=${this.historyRestoreTop} rows=${history.length}`
);
} catch (_) {
RemoteLogger.warn('History viewport capture unavailable');
}
}

private restoreHistoryAnchorIfNeeded(): void {
if (!this.historyRestoreKey || this.historyLoading || this.historyRestoreTimer !== 0) return;
this.historyRestoreTimer = setTimeout(() => {
this.historyRestoreTimer = 0;
const key = this.historyRestoreKey;
const history = this.historyTimelineItems();
const index = history.findIndex((item: ObservableChatTimelineItem) =>
ChatTimelineRevisionTracker.itemKey(item) === key);
if (index < 0 || this.historyFailed) {
this.cancelHistoryAnchor();
return;
}
this.historyRestoreAttempts++;
try {
const listIndex = index + (this.hasMoreMessages ? 1 : 0);
const rect = this.listScroller.getItemRect(listIndex);
if (rect.height <= 0) {
// Mount the identity's new virtual position before measuring it.
this.listScroller.scrollToIndex(listIndex, false, ScrollAlign.END);
this.historyStableFrames = 0;
} else {
const actual = rect.y + rect.height;
const delta = actual - this.historyRestoreTop;
RemoteLogger.info(
`history viewport measure key=${key} rows=${history.length} actual=${actual} expected=${this.historyRestoreTop} delta=${delta} attempt=${this.historyRestoreAttempts}`
);
if (Math.abs(delta) > 0.5) {
this.historyStableFrames = 0;
this.listScroller.scrollBy(0, delta);
} else {
this.historyStableFrames++;
}
}
} catch (_) {
this.listScroller.scrollToIndex(index + (this.hasMoreMessages ? 1 : 0), false, ScrollAlign.END);
}
if (this.historyStableFrames >= 3 || this.historyRestoreAttempts >= 30) {
RemoteLogger.info(`history viewport settle stable=${this.historyStableFrames} attempts=${this.historyRestoreAttempts}`);
this.cancelHistoryAnchor();
} else {
this.restoreHistoryAnchorIfNeeded();
}
}, 16);
}

private requestFollowToBottom(reason: string): void {
if (!this.stickToBottom || this.followTimerId !== 0) {
if (this.historyRestoreKey || this.historyLoading || !this.stickToBottom || this.followTimerId !== 0) {
return;
}
this.followTimerId = setTimeout(() => {
Expand Down Expand Up @@ -508,6 +605,11 @@ export struct ChatTimeline {
source === ScrollSource.EDGE_EFFECT || source === ScrollSource.OTHER_USER_INPUT;
}

private areaPosition(position: Object): number {
const value = (position as Record<string, Object>)?.y;
return this.areaLength(value === undefined ? 0 : value);
}

private areaLength(value: Object): number {
const parsed = Number.parseFloat(`${value}`);
return Number.isNaN(parsed) ? 0 : parsed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LINE, SOFT } from './Theme';
@ComponentV2
export struct ConversationLoadingState {
@Param maxContentWidth: number = 0;
@Param topInset: number = 0;

build() {
Row() {
Expand All @@ -13,7 +14,7 @@ export struct ConversationLoadingState {
}
.width('100%')
.constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : '100%' })
.padding({ left: 22, right: 22, top: 28, bottom: 28 })
.padding({ left: 22, right: 22, top: this.topInset + 28, bottom: 28 })
}
.width('100%')
.height('100%')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,8 @@ export struct ConversationView {
.height('100%')
if (this.isLoadingTranscript) {
ConversationLoadingState({
maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 760 : 0
maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 760 : 0,
topInset: this.hoverOperateMode ? 0 : this.topInset
})
.width('100%')
.height('100%')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RemoteLogger } from './RemoteLogger';
import { CommandStatusResponse } from '../model/RemoteModels';

/** Stream id of the host's workspace/session catalog. */
Expand Down Expand Up @@ -257,7 +258,10 @@ export class HostSessionStream {
let pages = 0;
try {
while (this.hasOlder && pages < MAX_HISTORY_PAGES_PER_REQUEST && !this.stopped) {
const page = await this.read({ before: this.oldest, epoch: this.epoch });
const before = this.oldest;
RemoteLogger.info(`history page request stream=${this.streamId} before=${before} epoch=${this.epoch}`);
const page = await this.read({ before: before, epoch: this.epoch });
RemoteLogger.info(`history page received stream=${this.streamId} before=${before} events=${page.events.length} first=${page.events[0]?.seq} hasMore=${page.has_more} epoch=${page.epoch}`);
if (this.stopped) return;
if (page.epoch !== this.epoch) {
await this.gap();
Expand Down
19 changes: 19 additions & 0 deletions src/crates/assembly/core/src/agentic/persistence/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6570,11 +6570,30 @@ mod tests {
.await
.expect("staged revert should save");

assert!(manager
.load_visible_session_turn(workspace.path(), &session_id, "turn-1")
.await
.expect("hidden lookup")
.is_none());
std::fs::write(
manager.turn_path(workspace.path(), &session_id, 1),
"invalid json",
)
.unwrap();
assert_eq!(
manager
.load_visible_session_turn(workspace.path(), &session_id, "turn-0")
.await
.expect("indexed lookup")
.expect("visible turn")
.turn_id,
"turn-0"
);
assert!(manager
.load_visible_session_turn(workspace.path(), &session_id, "unknown")
.await
.expect("missing lookup")
.is_none());
let (page, next) = manager
.load_visible_history_turn(workspace.path(), &session_id, None)
.await
Expand Down
Loading