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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user