diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt index b3f2c10b7a..bc758afddd 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt @@ -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") { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index e39c3e0699..9f56690f2a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -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 @@ -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) { @@ -207,6 +216,7 @@ export struct ChatTimeline { if (!this.historyLoading) { this.historyArrival.cancelArrival(); this.stickToBottom = false; + this.captureHistoryAnchor(); this.onLoadOlder(); } }) @@ -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; } @@ -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; @@ -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(() => { @@ -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)?.y; + return this.areaLength(value === undefined ? 0 : value); + } + private areaLength(value: Object): number { const parsed = Number.parseFloat(`${value}`); return Number.isNaN(parsed) ? 0 : parsed; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets index b8c7593144..1d8fb95753 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets @@ -3,6 +3,7 @@ import { LINE, SOFT } from './Theme'; @ComponentV2 export struct ConversationLoadingState { @Param maxContentWidth: number = 0; + @Param topInset: number = 0; build() { Row() { @@ -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%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 3181adb971..0ce830d6cc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -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%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets index b3324531ca..2447652f8f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets @@ -1,3 +1,4 @@ +import { RemoteLogger } from './RemoteLogger'; import { CommandStatusResponse } from '../model/RemoteModels'; /** Stream id of the host's workspace/session catalog. */ @@ -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(); diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index f3668263be..5406875a35 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -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