Merge pull request #218 from Codename-11/fix/post-stream-history-scroll

fix(android): preserve chat anchor across history reload
This commit is contained in:
Bailey Dixon
2026-07-17 07:42:46 -04:00
committed by GitHub
6 changed files with 71 additions and 3 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Long streamed replies stay anchored when final rendering completes.** Android now follows the final Markdown re-layout and server message reconciliation when the reader is already at the conversation bottom, without pulling readers away from history they intentionally scrolled up to view.
- **Long streamed replies stay anchored through final rendering and history reconciliation.** Android follows the final Markdown re-layout and preserves stable visible-row identity when persisted server message IDs replace live IDs, without pulling readers away from history they intentionally scrolled up to view.
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
## [Android 1.4.6] - 2026-07-15
+11
View File
@@ -1,5 +1,16 @@
# Hermes-Relay — Dev Log
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
Android chat now separates the stable Compose identity of a visible message row
from its authoritative server message ID. The post-turn history reconcile can
adopt persisted IDs and rebuild message boundaries without making LazyColumn
remove and reinsert the long answer currently anchoring the viewport.
Regression coverage exercises both the same-count user/assistant ID adoption
and a list-expansion reconcile that inserts persisted rows around a matched live
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
## 2026-07-16 — Stable chat position after stream completion
Android chat now observes the assistant message identity and the streaming-to-final
@@ -129,6 +129,19 @@ data class ChatMessage(
* the live message can be matched to its server row.
*/
val backgroundTask: BackgroundTaskState? = null,
/**
* Stable identity for Compose list rendering.
*
* Gateway/user rows start with client UUIDs, then post-turn history
* reconciliation adopts the server message id into [id]. That server-id
* adoption must not make a visible bubble look removed and reinserted to
* LazyColumn: doing so discards its scroll anchor, which is especially
* disruptive when the row is a long answer occupying the viewport.
*
* New rows default to their current [id]. Reconciled rows retain this key
* through `copy`, while [id] remains the authoritative lookup/wire id.
*/
val uiKey: String = id,
)
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
@@ -1315,7 +1315,9 @@ class ChatHandler {
// `id = messageId` adopts the server id: for an id-matched (SSE)
// row it's a no-op, but for a positionally reconciled (gateway /
// user) row whose `prior` still carries a client UUID it swaps in
// the server id so EVERY future reload matches by id.
// the server id so EVERY future reload matches by id. `uiKey` is
// deliberately not overwritten: Compose must continue treating
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
role = role,
@@ -2120,7 +2120,11 @@ fun ChatScreen(
) {
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
items(messages.size, key = { messages[it].id }) { index ->
// `id` can legitimately change once after a Gateway turn:
// the history reconcile adopts the persisted server id.
// Keep Compose identity stable across that data update so
// LazyColumn retains the visible row and its scroll anchor.
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val processNotification = message.hermesProcessNotificationOrNull()
@@ -1046,6 +1046,11 @@ class ChatHandlerTest {
// Server ids adopted onto the live rows.
assertEquals("srv-1", user.id)
assertEquals("srv-2", assistant.id)
// Compose identity stays on the live rows. A same-count post-turn
// reload must not remove/reinsert the two bubbles just because their
// authoritative ids arrived.
assertEquals("uuid-user", user.uiKey)
assertEquals("uuid-assistant", assistant.uiKey)
// State carried by id, in place.
assertEquals(1, user.attachments.size)
assertEquals("outb64", user.attachments[0].content)
@@ -1056,6 +1061,39 @@ class ChatHandlerTest {
assertFalse(assistant.isStreaming)
}
@Test
fun loadMessageHistory_listRebuildPreservesMatchedTailUiKey() {
// Some persisted turns rebuild one live streaming bubble into several
// server rows (for example, restored message boundaries/tool output).
// The reconciled tail must retain its UI identity even while new rows
// are inserted around it, otherwise LazyColumn loses the viewport
// anchor on a long answer.
handler.addPlaceholderMessage(
ChatMessage(
id = "uuid-live-tail",
role = MessageRole.ASSISTANT,
content = "final chunk",
timestamp = 3L,
isStreaming = false,
)
)
handler.loadMessageHistory(
listOf(
MessageItem(id = "srv-user", role = "user", content = JsonPrimitive("question"), timestamp = 1.0),
MessageItem(id = "srv-prefix", role = "assistant", content = JsonPrimitive("earlier chunk"), timestamp = 2.0),
MessageItem(id = "srv-tail", role = "assistant", content = JsonPrimitive("final chunk"), timestamp = 3.0),
)
)
val messages = handler.messages.value
assertEquals(3, messages.size)
assertEquals("uuid-live-tail", messages.single { it.id == "srv-tail" }.uiKey)
assertEquals("srv-user", messages.single { it.id == "srv-user" }.uiKey)
assertEquals("srv-prefix", messages.single { it.id == "srv-prefix" }.uiKey)
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by