Skip to content

Commit b856beb

Browse files
authored
Merge pull request #3206 from wgqqqqq/wgq/fix-short-conversation-alignment
Fix short mobile conversations aligning to bottom
2 parents c4396f6 + 293fdbb commit b856beb

6 files changed

Lines changed: 138 additions & 11 deletions

File tree

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationTimelineView.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ internal fun ConversationTimelineView(
194194
top = topInset,
195195
bottom = if (bottomInset > 0.dp) bottomInset else 12.dp,
196196
),
197-
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Bottom),
197+
verticalArrangement = Arrangement.spacedBy(12.dp),
198198
) {
199199
if (hasLeadingItem) {
200200
item(key = "load-older-messages") {

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets‎

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LINE, SOFT } from './Theme';
33
@ComponentV2
44
export struct ConversationLoadingState {
55
@Param maxContentWidth: number = 0;
6+
@Param topInset: number = 0;
67

78
build() {
89
Row() {
@@ -13,7 +14,7 @@ export struct ConversationLoadingState {
1314
}
1415
.width('100%')
1516
.constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : '100%' })
16-
.padding({ left: 22, right: 22, top: 28, bottom: 28 })
17+
.padding({ left: 22, right: 22, top: this.topInset + 28, bottom: 28 })
1718
}
1819
.width('100%')
1920
.height('100%')

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ export struct ConversationView {
243243
.height('100%')
244244
if (this.isLoadingTranscript) {
245245
ConversationLoadingState({
246-
maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 760 : 0
246+
maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 760 : 0,
247+
topInset: this.hoverOperateMode ? 0 : this.topInset
247248
})
248249
.width('100%')
249250
.height('100%')

‎src/apps/mobile/harmonyos/entry/src/main/ets/services/HostSessionStream.ets‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { RemoteLogger } from './RemoteLogger';
12
import { CommandStatusResponse } from '../model/RemoteModels';
23

34
/** Stream id of the host's workspace/session catalog. */
@@ -257,7 +258,10 @@ export class HostSessionStream {
257258
let pages = 0;
258259
try {
259260
while (this.hasOlder && pages < MAX_HISTORY_PAGES_PER_REQUEST && !this.stopped) {
260-
const page = await this.read({ before: this.oldest, epoch: this.epoch });
261+
const before = this.oldest;
262+
RemoteLogger.info(`history page request stream=${this.streamId} before=${before} epoch=${this.epoch}`);
263+
const page = await this.read({ before: before, epoch: this.epoch });
264+
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}`);
261265
if (this.stopped) return;
262266
if (page.epoch !== this.epoch) {
263267
await this.gap();

‎src/crates/assembly/core/src/agentic/persistence/manager.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6570,11 +6570,30 @@ mod tests {
65706570
.await
65716571
.expect("staged revert should save");
65726572

6573+
assert!(manager
6574+
.load_visible_session_turn(workspace.path(), &session_id, "turn-1")
6575+
.await
6576+
.expect("hidden lookup")
6577+
.is_none());
65736578
std::fs::write(
65746579
manager.turn_path(workspace.path(), &session_id, 1),
65756580
"invalid json",
65766581
)
65776582
.unwrap();
6583+
assert_eq!(
6584+
manager
6585+
.load_visible_session_turn(workspace.path(), &session_id, "turn-0")
6586+
.await
6587+
.expect("indexed lookup")
6588+
.expect("visible turn")
6589+
.turn_id,
6590+
"turn-0"
6591+
);
6592+
assert!(manager
6593+
.load_visible_session_turn(workspace.path(), &session_id, "unknown")
6594+
.await
6595+
.expect("missing lookup")
6596+
.is_none());
65786597
let (page, next) = manager
65796598
.load_visible_history_turn(workspace.path(), &session_id, None)
65806599
.await

0 commit comments

Comments
 (0)