feat(chat): add paged history with stable transcript and scroll identity (#1211)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
83b2a8fa9b
commit
a78b20bc99
26 files changed
+3942
-884
No files matched your search
@@ -87,8 +87,16 @@ data class ChatMessage(
|
||||
val tps: Double? = null,
|
||||
/** Stable completion identity correlating with reply notifications for read tracking. */
|
||||
val completionId: String? = null,
|
||||
/** Confirmed transcript identity (#859); [id] remains the stable live/render key. */
|
||||
val restId: String? = null,
|
||||
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
|
||||
val localOrder: Long? = null,
|
||||
)
|
||||
|
||||
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
|
||||
internal val ChatMessage.canonicalRestId: String?
|
||||
get() = restId ?: id.takeIf { it.startsWith("rest-") }
|
||||
|
||||
/**
|
||||
* Single live transcript log entry for subagent execution.
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,50 @@ open class ChatPersistenceRepository(
|
||||
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
||||
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
|
||||
|
||||
data class Cursor(
|
||||
val group: Int,
|
||||
val order: Long,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
data class Page(
|
||||
val messages: List<ChatMessage>,
|
||||
val cursor: Cursor?,
|
||||
val hasOlder: Boolean,
|
||||
)
|
||||
|
||||
/** Read at most one page plus a lookahead row; never trim a full-session query. */
|
||||
suspend fun loadPage(
|
||||
sessionId: String,
|
||||
before: Cursor?,
|
||||
limit: Int,
|
||||
): Page {
|
||||
require(limit in 1..1_000)
|
||||
val dao = daoProvider()
|
||||
val rows =
|
||||
if (before == null) {
|
||||
dao.getLatestMessagePage(sessionId, limit + 1)
|
||||
} else {
|
||||
dao.getMessagePage(sessionId, before.group, before.order, before.id, limit + 1)
|
||||
}
|
||||
val page = rows.take(limit)
|
||||
val oldest = page.lastOrNull()
|
||||
return Page(
|
||||
messages = page.asReversed().map { it.toUiModel() },
|
||||
cursor = oldest?.let { Cursor(it.sortGroup, it.sortOrder, it.id) } ?: before,
|
||||
hasOlder = rows.size > limit,
|
||||
)
|
||||
}
|
||||
|
||||
/** Record confirmed UUID aliases without replacing their newer locally persisted content. */
|
||||
suspend fun confirmIdentities(
|
||||
messages: List<ChatMessage>,
|
||||
sessionId: String,
|
||||
) {
|
||||
val dao = daoProvider()
|
||||
messages.forEach { dao.confirmIdentity(it.toEntity(sessionId)) }
|
||||
}
|
||||
|
||||
/** Clear all cached messages for a session (e.g. after /undo rewind). */
|
||||
suspend fun clearMessagesForSession(sessionId: String) {
|
||||
daoProvider().deleteMessagesForSession(sessionId)
|
||||
|
||||
@@ -59,7 +59,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -115,8 +114,6 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||
@@ -211,7 +208,6 @@ fun ChatScreen(
|
||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
var pendingSavePath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingSaveName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -265,38 +261,9 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Arm + trigger older-message paging when the user scrolls near the top.
|
||||
// Before prepending, capture the anchor (first visible message id + offset)
|
||||
// so the same content stays under the reader's eye after the insert
|
||||
// (issue #682). Restored in a LaunchedEffect once the page actually lands.
|
||||
val pagingAnchor = remember { mutableStateOf<Pair<String, Int>?>(null) }
|
||||
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
|
||||
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { firstVisibleIndex ->
|
||||
if (firstVisibleIndex > 2) {
|
||||
isOlderPagingArmed = true
|
||||
} else if (isOlderPagingArmed || state.messages.size <= 3) {
|
||||
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
|
||||
val offset = scrollController.captureAnchorOffset()
|
||||
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
|
||||
viewModel.loadOlderMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After older history is prepended, restore the anchor message (by id, so
|
||||
// streaming-token inserts during paging don't skew the index) plus offset.
|
||||
LaunchedEffect(state.messages) {
|
||||
val anchor = pagingAnchor.value ?: return@LaunchedEffect
|
||||
val (anchorId, offset) = anchor
|
||||
val index = state.messages.indexOfFirst { it.id == anchorId }
|
||||
if (index >= 0) {
|
||||
scrollController.scrollToItem(index, offset)
|
||||
pagingAnchor.value = null
|
||||
}
|
||||
}
|
||||
// FullBleedChatList preserves the live lazy row key and pixel offset on prepend.
|
||||
// Never restore a message index captured before an asynchronous fetch: it is not
|
||||
// a lazy row index and the reader may have moved in the meantime.
|
||||
|
||||
// Continuous bottom-follow tracking from LazyListState (issue #682).
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -705,6 +672,8 @@ fun ChatScreen(
|
||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||
isLoading = state.isLoading,
|
||||
isLoadingOlder = state.isLoadingOlder,
|
||||
hasOlderMessages = state.hasOlderMessages,
|
||||
pagingSessionId = state.currentSessionId,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||
|
||||
@@ -10,10 +10,9 @@ import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
*
|
||||
* Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the
|
||||
* 2026-08 delegate refactor). Pure with respect to the VM: it reads the live
|
||||
* message list supplied by the caller and one paging-mode flag — no state
|
||||
* mutation, no I/O beyond URL construction.
|
||||
* Reads the supplied transcript snapshot and recovers notification identity through
|
||||
* ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
|
||||
* it never mutates ViewModel state or dismisses a notification.
|
||||
*/
|
||||
internal fun mapServerMessages(
|
||||
sessionId: String,
|
||||
@@ -24,15 +23,11 @@ internal fun mapServerMessages(
|
||||
isPagingOlder: Boolean = false,
|
||||
context: android.content.Context? = null,
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
liveMessages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
|
||||
val liveByExactId =
|
||||
liveMessages
|
||||
.filter { !it.completionId.isNullOrBlank() }
|
||||
.associateBy { it.id }
|
||||
.associateBy { it.canonicalRestId ?: it.id }
|
||||
|
||||
val unmappedLiveWsAssistants =
|
||||
if (isPagingOlder) {
|
||||
@@ -40,112 +35,91 @@ internal fun mapServerMessages(
|
||||
} else {
|
||||
liveMessages
|
||||
.filter {
|
||||
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
|
||||
it.role == MessageRole.ASSISTANT && it.canonicalRestId == null &&
|
||||
!it.completionId.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||
if (!isPagingOlder) {
|
||||
if (unmappedLiveWsAssistants.isNotEmpty()) {
|
||||
val remainingWs = unmappedLiveWsAssistants.toMutableList()
|
||||
for (i in messages.indices.reversed()) {
|
||||
if (remainingWs.isEmpty()) break
|
||||
val m = messages[i]
|
||||
val r =
|
||||
when (m.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
"system" -> MessageRole.SYSTEM
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
if (r != MessageRole.ASSISTANT) continue
|
||||
val rawContent = m.contentText
|
||||
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
|
||||
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
|
||||
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
|
||||
} else {
|
||||
"rest-$sessionId-${offset + i}"
|
||||
}
|
||||
if (liveByExactId.containsKey(restId)) continue
|
||||
|
||||
val canonicalContent =
|
||||
if (rawContent.contains("MEDIA:")) {
|
||||
HostMediaExtractor.strip(rawContent).trim()
|
||||
} else {
|
||||
rawContent.trim()
|
||||
}
|
||||
|
||||
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
|
||||
if (wsIdx >= 0) {
|
||||
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
|
||||
wsCompletionIdByRestIndex[i] = compId
|
||||
}
|
||||
}
|
||||
}
|
||||
fun restIdAt(index: Int): String =
|
||||
if (latestPaging) {
|
||||
"rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
|
||||
} else {
|
||||
"rest-$sessionId-${offset + index}"
|
||||
}
|
||||
|
||||
// REST-only recovery is fail-closed: only a durable server row ID may
|
||||
// transfer notification identity after the WebSocket copy is gone.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
|
||||
if (
|
||||
activeTarget != null &&
|
||||
activeTarget.sessionId == sessionId &&
|
||||
activeTarget.serverMessageId != null &&
|
||||
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
|
||||
) {
|
||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||
if (!isPagingOlder) {
|
||||
// #129: reserve durable identity before matching any repeated live prose.
|
||||
// REST-only recovery remains fail-closed when the target row is absent.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
|
||||
val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
|
||||
if (durableTarget != null) {
|
||||
val exactIndex =
|
||||
messages.indexOfFirst { message ->
|
||||
message.id == activeTarget.serverMessageId &&
|
||||
message.id == durableTarget.serverMessageId &&
|
||||
message.role.equals("assistant", ignoreCase = true)
|
||||
}
|
||||
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
|
||||
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
|
||||
if (exactIndex >= 0 && !liveByExactId.containsKey(restIdAt(exactIndex))) {
|
||||
wsCompletionIdByRestIndex[exactIndex] = durableTarget.completionId
|
||||
}
|
||||
}
|
||||
val confirmedCompletions =
|
||||
liveMessages.filter { it.canonicalRestId != null }.mapNotNull { it.completionId }.toSet()
|
||||
val remainingWs =
|
||||
unmappedLiveWsAssistants
|
||||
.filter {
|
||||
it.completionId !in confirmedCompletions && it.completionId != durableTarget?.completionId
|
||||
}.toMutableList()
|
||||
// #129: reserve known completions and live aliases. A metadata-free cached REST
|
||||
// echo must remain eligible for its pending WS completion before older duplicates.
|
||||
val reservedRestIds = liveByExactId.keys + liveMessages.mapNotNull { it.restId }
|
||||
// Retain upstream's newest-to-newest, one-to-one WS echo matching.
|
||||
for (i in messages.indices.reversed()) {
|
||||
if (remainingWs.isEmpty()) break
|
||||
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
|
||||
val m = messages[i]
|
||||
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
|
||||
val rawContent = m.contentText
|
||||
if (rawContent.isBlank()) continue
|
||||
val canonicalContent =
|
||||
if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
|
||||
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
|
||||
if (wsIdx >= 0) {
|
||||
remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry NO tool name — the live WS
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
// and mixed cache state make positional mapping misalign, leaving
|
||||
// the newest call with a null name → generic "tool" bubble). When a
|
||||
// match is found the live message is reused wholesale (same id +
|
||||
// toolName + rich payload), so persistence upserts the same row
|
||||
// instead of accumulating a second `rest-` copy in Room. Issue #771.
|
||||
val liveToolByResult = linkedMapOf<String, ChatMessage>()
|
||||
liveMessages
|
||||
.filter { it.role == MessageRole.TOOL }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
canonicalToolResultKey(msg.content)?.let { key ->
|
||||
liveToolByResult.putIfAbsent(key, msg)
|
||||
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
|
||||
val assistants = liveMessages.filter { it.role == MessageRole.ASSISTANT }
|
||||
val assistantsByRestId = assistants.groupBy { it.canonicalRestId }
|
||||
val assistantsByCompletion = assistants.groupBy { it.completionId }
|
||||
val reasoningSources = arrayOfNulls<ChatMessage>(messages.size)
|
||||
messages.forEachIndexed { index, row ->
|
||||
if (row.role?.lowercase() in listOf("user", "system", "tool")) return@forEachIndexed
|
||||
val exact = assistantsByRestId[restIdAt(index)].orEmpty()
|
||||
val completion = liveByExactId[restIdAt(index)]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||
val candidates = exact + completion?.let { assistantsByCompletion[it] }.orEmpty()
|
||||
reasoningSources[index] = candidates.firstOrNull { it.reasoningText.isNotBlank() } ?: candidates.firstOrNull()
|
||||
}
|
||||
if (!isPagingOlder) {
|
||||
val remaining =
|
||||
assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList()
|
||||
for (index in messages.indices.reversed()) {
|
||||
val row = messages[index]
|
||||
if (reasoningSources[index] != null || row.contentText.isBlank() ||
|
||||
row.role?.lowercase() in listOf("user", "system", "tool") ||
|
||||
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val content = HostMediaExtractor.strip(row.contentText).trim()
|
||||
val match = remaining.indexOfLast { it.content.trim() == content }
|
||||
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
|
||||
}
|
||||
|
||||
// Issue #842: REST transcript rows carry the gateway's `tool_call_id`
|
||||
// — prefer matching live bubbles by that 1:1 identity. It works for
|
||||
// EVERY tool shape, including MCP/web rows whose REST copy is raw
|
||||
// `<untrusted_tool_result>` text with no JSON key to canonicalize.
|
||||
val liveToolByCallId = linkedMapOf<String, ChatMessage>()
|
||||
liveMessages
|
||||
.filter { it.role == MessageRole.TOOL && it.toolCallId.isNotBlank() }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
liveToolByCallId.putIfAbsent(msg.toolCallId, msg)
|
||||
}
|
||||
}
|
||||
|
||||
val mapped = mutableListOf<ChatMessage>()
|
||||
// The gateway stores a reasoning-model's thinking as its OWN assistant
|
||||
// row (content = "", reasoning = trace) directly before the answer row.
|
||||
// Rendering that as a standalone empty assistant bubble is the
|
||||
// "reasoning box in a separate bubble" artifact — fold it into the
|
||||
// next assistant message with content instead. Issue #771.
|
||||
var pendingReasoning: String? = null
|
||||
|
||||
messages.forEachIndexed { index, msg ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
@@ -154,47 +128,32 @@ internal fun mapServerMessages(
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
val globalIndex = offset + index
|
||||
// Issue #859: under newest-anchored paging use the server's
|
||||
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
||||
// as the transcript grows and would collide across hydrations
|
||||
// (distinctBy would silently drop the newest copy). Legacy paging
|
||||
// keeps the absolute-position key its count-based sync math needs.
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
|
||||
} else {
|
||||
"rest-$sessionId-$globalIndex"
|
||||
}
|
||||
val restId = restIdAt(index)
|
||||
val timestamp =
|
||||
msg.timestampText
|
||||
?.toDoubleOrNull()
|
||||
?.times(1000)
|
||||
?.toLong()
|
||||
?: existingById[restId]?.timestamp
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val rawContent = msg.contentText
|
||||
val rowReasoning =
|
||||
if (msg.reasoningText.isNotBlank()) {
|
||||
msg.reasoningText
|
||||
} else {
|
||||
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
|
||||
msg.reasoningText.ifBlank {
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
reasoningSources[index]?.reasoningText.orEmpty()
|
||||
} else {
|
||||
existingById[restId]?.reasoningText.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
// Empty assistant row — two cases stored by the gateway:
|
||||
// 1. Reasoning-only: thinking-model split storage (content = "",
|
||||
// reasoning = trace). Stash the trace and fold it into the
|
||||
// next assistant message that has content (issue #771).
|
||||
// 2. Tool-call placeholder: non-reasoning models emit content = ""
|
||||
// with tool_calls metadata and no reasoning. These carry no
|
||||
// user-visible text and must not render as empty bubbles
|
||||
// (issue #956).
|
||||
if (role == MessageRole.ASSISTANT && rawContent.isBlank()) {
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
pendingReasoning = rowReasoning
|
||||
}
|
||||
return@forEachIndexed
|
||||
}
|
||||
// Retain canonical reasoning rows across page boundaries; hide only textless placeholders.
|
||||
if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isBlank()) return@forEachIndexed
|
||||
|
||||
// Context-reference preprocessing can persist model-facing attachment
|
||||
// contents after the user's text. A cold reload / second device has no
|
||||
@@ -232,34 +191,6 @@ internal fun mapServerMessages(
|
||||
}
|
||||
}
|
||||
|
||||
val finalReasoning =
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
rowReasoning
|
||||
} else if (role == MessageRole.ASSISTANT && pendingReasoning != null) {
|
||||
pendingReasoning.also { pendingReasoning = null }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry no tool name. When the
|
||||
// result payload matches a live WS tool message, reuse it whole —
|
||||
// keeps the real name, the rich WS payload, AND the same id so
|
||||
// Room upserts instead of accumulating a duplicate `rest-` row.
|
||||
if (role == MessageRole.TOOL) {
|
||||
// Prefer the gateway call id (1:1, works for every tool
|
||||
// shape), then fall back to result-content matching.
|
||||
liveToolByCallId[msg.toolCallId]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
canonicalToolResultKey(rawContent)?.let { key ->
|
||||
liveToolByResult[key]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val completionId =
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||
@@ -272,7 +203,7 @@ internal fun mapServerMessages(
|
||||
id = restId,
|
||||
role = role,
|
||||
content = finalContent,
|
||||
reasoningText = finalReasoning,
|
||||
reasoningText = rowReasoning,
|
||||
toolCallId = msg.toolCallId,
|
||||
attachments = attachments,
|
||||
timestamp = timestamp,
|
||||
@@ -284,17 +215,12 @@ internal fun mapServerMessages(
|
||||
)
|
||||
}
|
||||
|
||||
// A reasoning-only row with no following answer (interrupted turn):
|
||||
// don't drop the trace — attach it to the last assistant message.
|
||||
if (pendingReasoning != null) {
|
||||
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||
if (lastAssistantIdx >= 0) {
|
||||
val target = mapped[lastAssistantIdx]
|
||||
if (target.reasoningText.isBlank()) {
|
||||
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
|
||||
}
|
||||
}
|
||||
// REST echoes must not reserve a match before the richer WS copy of that tool.
|
||||
val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") }
|
||||
val mappedTools = mapped.filter { it.role == MessageRole.TOOL }
|
||||
val matches = matchTranscriptMessages(mappedTools, liveTools)
|
||||
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
|
||||
return mapped.map { message ->
|
||||
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
@@ -107,48 +107,120 @@ internal fun canonicalToolResultKey(content: String): String? {
|
||||
internal fun sameLogicalMessage(
|
||||
a: ChatMessage,
|
||||
b: ChatMessage,
|
||||
): Boolean {
|
||||
if (a.role != b.role) return false
|
||||
if (a.role == MessageRole.TOOL) {
|
||||
// Issue #842: prefer the gateway's tool call id when both sides carry
|
||||
// it — the REST transcript stores `tool_call_id` and the live WS
|
||||
// bubble keeps it from `tool.start`. Content canonicalization cannot
|
||||
// cover MCP/web tools: the REST side stores the payload as raw
|
||||
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
|
||||
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
|
||||
return a.toolCallId == b.toolCallId
|
||||
): Boolean = TranscriptComparison().same(a, b)
|
||||
|
||||
/** One operation owns this cache; even invalid JSON is parsed at most once per content. */
|
||||
internal class TranscriptComparison(
|
||||
private val canonicalize: (String) -> String? = ::canonicalToolResultKey,
|
||||
) {
|
||||
private val toolKeys = HashMap<String, String?>()
|
||||
private val trimmed = HashMap<String, String>()
|
||||
private val captions = HashMap<String, String>()
|
||||
|
||||
fun toolKey(content: String): String? {
|
||||
if (!toolKeys.containsKey(content)) toolKeys[content] = canonicalize(content)
|
||||
return toolKeys[content]
|
||||
}
|
||||
|
||||
fun same(
|
||||
a: ChatMessage,
|
||||
b: ChatMessage,
|
||||
): Boolean {
|
||||
if (a.role != b.role) return false
|
||||
val aRestId = a.canonicalRestId
|
||||
val bRestId = b.canonicalRestId
|
||||
if (aRestId != null && bRestId != null) return aRestId == bRestId
|
||||
// #129: completion-bearing replies need confirmed identity, not repeated prose.
|
||||
// Older pages deliberately do not acquire a live reply's completion in the mapper.
|
||||
if (a.role == MessageRole.ASSISTANT && (a.completionId != null || b.completionId != null)) {
|
||||
return a.completionId != null && a.completionId == b.completionId
|
||||
}
|
||||
val ka = canonicalToolResultKey(a.content)
|
||||
val kb = canonicalToolResultKey(b.content)
|
||||
return ka != null && ka == kb
|
||||
if (a.role == MessageRole.ASSISTANT && a.content.isBlank() && b.content.isBlank() &&
|
||||
(a.reasoningText.isNotBlank() || b.reasoningText.isNotBlank())
|
||||
) {
|
||||
return a.reasoningText.isNotBlank() && a.reasoningText == b.reasoningText
|
||||
}
|
||||
if (a.role == MessageRole.TOOL) {
|
||||
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) return a.toolCallId == b.toolCallId
|
||||
val key = toolKey(a.content)
|
||||
return key != null && key == toolKey(b.content)
|
||||
}
|
||||
val ta = trimmed.getOrPut(a.content) { a.content.trim() }
|
||||
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
|
||||
if (ta == tb) return true
|
||||
if (a.role == MessageRole.USER &&
|
||||
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
|
||||
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb))
|
||||
}
|
||||
val ta = a.content.trim()
|
||||
val tb = b.content.trim()
|
||||
if (ta == tb) return true
|
||||
// Attachment dedupe: when the user sends an image/file, the backend
|
||||
// persists an ENRICHED copy of the prompt — the real caption plus
|
||||
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
|
||||
// (tui_gateway _build_image_ref_message / run_agent flattening). The
|
||||
// optimistic bubble carries the raw caption only, so the exact compare
|
||||
// above fails and the REST sync renders the same message twice. Compare
|
||||
// USER rows on the caption with injection lines stripped (both sides —
|
||||
// stripping a clean string is a no-op). USER-only so assistant text that
|
||||
// legitimately mentions these tokens is never collapsed.
|
||||
if (a.role == MessageRole.USER) {
|
||||
val ca = stripAttachmentRefLines(ta)
|
||||
val cb = stripAttachmentRefLines(tb)
|
||||
if (ca == cb) return true
|
||||
}
|
||||
|
||||
/** Consume each occurrence once, reserving exact IDs before considering content echoes. */
|
||||
internal fun matchTranscriptMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
existing: List<ChatMessage>,
|
||||
comparison: TranscriptComparison = TranscriptComparison(),
|
||||
allowAssistantContentMatches: Boolean = true,
|
||||
): List<ChatMessage?> {
|
||||
val byId = existing.withIndex().associate { it.value.id to it.index }
|
||||
val byRestId =
|
||||
existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap()
|
||||
val used = BooleanArray(existing.size)
|
||||
val matches = arrayOfNulls<ChatMessage>(incoming.size)
|
||||
incoming.forEachIndexed { index, message ->
|
||||
(byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match ->
|
||||
if (!used[match]) {
|
||||
used[match] = true
|
||||
matches[index] = existing[match]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Issue #842: a seal race can leave the live orphan a few trailing tokens
|
||||
// short of the streamed narration (the last delta was still in the
|
||||
// throttled buffer when tool.start sealed the message). The backend
|
||||
// persists the COMPLETE copy, so the orphan is a strict prefix of the
|
||||
// REST row. Accept prefix-covering only for substantial texts (>=40
|
||||
// chars) so a short reply can never be swallowed by a longer message
|
||||
// that merely starts with it.
|
||||
return ta.length >= 40 &&
|
||||
tb.length >= 40 &&
|
||||
(tb.startsWith(ta) || ta.startsWith(tb))
|
||||
val byRole = existing.indices.groupBy { existing[it].role }
|
||||
val byCall =
|
||||
existing.indices
|
||||
.filter { existing[it].toolCallId.isNotBlank() }
|
||||
.groupBy { existing[it].toolCallId }
|
||||
val byResult =
|
||||
existing.indices
|
||||
.filter { existing[it].role == MessageRole.TOOL }
|
||||
.groupBy { comparison.toolKey(existing[it].content) }
|
||||
val matchOrder =
|
||||
incoming.indices.filter { incoming[it].role != MessageRole.ASSISTANT } +
|
||||
incoming.indices.reversed().filter { incoming[it].role == MessageRole.ASSISTANT }
|
||||
matchOrder.forEach { index ->
|
||||
val message = incoming[index]
|
||||
if (matches[index] != null) return@forEach
|
||||
val candidates =
|
||||
if (message.role == MessageRole.TOOL) {
|
||||
val callMatches = byCall[message.toolCallId].orEmpty()
|
||||
callMatches + byResult[comparison.toolKey(message.content)].orEmpty()
|
||||
} else {
|
||||
// Prefix matching scans only candidates with the same role.
|
||||
byRole[message.role].orEmpty().let {
|
||||
if (message.role == MessageRole.ASSISTANT) it.asReversed() else it
|
||||
}
|
||||
}
|
||||
val match =
|
||||
candidates.firstOrNull { candidate ->
|
||||
val other = existing[candidate]
|
||||
!used[candidate] &&
|
||||
(
|
||||
allowAssistantContentMatches || message.role != MessageRole.ASSISTANT ||
|
||||
message.completionId != null || other.completionId != null
|
||||
) &&
|
||||
// Distinct IDs within the same source are separate occurrences, not echoes.
|
||||
((message.canonicalRestId == null) != (other.canonicalRestId == null)) &&
|
||||
comparison.same(message, other)
|
||||
}
|
||||
if (match != null) {
|
||||
used[match] = true
|
||||
matches[index] = existing[match]
|
||||
}
|
||||
}
|
||||
return matches.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,55 +248,154 @@ internal fun stripAttachmentRefLines(content: String): String =
|
||||
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
||||
* logical message exists (issue #771).
|
||||
*/
|
||||
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
val rest = messages.filter { it.id.startsWith("rest-") }
|
||||
if (rest.isEmpty()) return messages
|
||||
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
||||
if (nonRest.isEmpty()) return messages
|
||||
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
||||
return (nonRest + keepRest).sortedBy { it.timestamp }
|
||||
internal fun dedupeCachedMessages(
|
||||
messages: List<ChatMessage>,
|
||||
confirmedOnly: Boolean = false,
|
||||
): List<ChatMessage> {
|
||||
val unique = messages.dedupeById()
|
||||
val rest = unique.filter { it.id.startsWith("rest-") }
|
||||
val live = unique.filterNot { it.id.startsWith("rest-") }
|
||||
if (rest.isEmpty() || live.isEmpty()) return unique
|
||||
val matches =
|
||||
matchTranscriptMessages(rest, live).mapIndexed { index, match ->
|
||||
match?.takeIf {
|
||||
!confirmedOnly || rest[index].canonicalRestId == it.canonicalRestId ||
|
||||
(rest[index].completionId != null && rest[index].completionId == it.completionId)
|
||||
}
|
||||
}
|
||||
val echoes =
|
||||
rest.indices
|
||||
.filter { matches[it] != null }
|
||||
.map { rest[it].id }
|
||||
.toSet()
|
||||
// #859: retain the REST identity when keeping a rich UUID row, including across cache pages.
|
||||
val aliases =
|
||||
rest.indices
|
||||
.mapNotNull { index ->
|
||||
matches[index]?.id?.let { it to rest[index] }
|
||||
}.toMap()
|
||||
return unique.filterNot { it.id in echoes }.map { message ->
|
||||
aliases[message.id]?.let {
|
||||
message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId)
|
||||
} ?: message
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A transcript reload must NOT yank live WS bubbles the server has not
|
||||
* persisted yet. The gateway stores a tool row only once the tool
|
||||
* COMPLETES server-side, so a reload that lands while a tool is running
|
||||
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
||||
* returns a page without the tool row — replacing the list outright made
|
||||
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
||||
* message to update (issue #771).
|
||||
*
|
||||
* Merge instead of replace: append any current message the REST page does
|
||||
* not already cover (checked by id AND logical content via
|
||||
* [sameLogicalMessage]) and keep chronological order.
|
||||
*/
|
||||
/** Cache pages enrich confirmed identities without changing keys already on screen (#859). */
|
||||
internal fun mergeCachedTranscriptPage(
|
||||
page: List<ChatMessage>,
|
||||
current: List<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val currentById = current.associateBy { it.id }
|
||||
// Legacy cached UUIDs may lack an alias. Restore a known alias before page-local matching
|
||||
// so an older identical REST row cannot claim that UUID again.
|
||||
val incoming =
|
||||
dedupeCachedMessages(
|
||||
page.map { message ->
|
||||
currentById[message.id]?.restId?.let { message.copy(restId = it) } ?: message
|
||||
},
|
||||
)
|
||||
val matches = matchTranscriptMessages(incoming, current)
|
||||
val replacements =
|
||||
incoming
|
||||
.mapIndexedNotNull { index, message ->
|
||||
val match = matches[index] ?: return@mapIndexedNotNull null
|
||||
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match
|
||||
match.id to
|
||||
rich.copy(
|
||||
id = match.id,
|
||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
||||
completionId = match.completionId ?: message.completionId,
|
||||
)
|
||||
}.toMap()
|
||||
return (
|
||||
current.map { replacements[it.id] ?: it } +
|
||||
incoming.filterIndexed { index, _ -> matches[index] == null }
|
||||
).dedupeById().inTranscriptOrder().reconcileReasoningRows()
|
||||
}
|
||||
|
||||
/** Merge one page with the current snapshot without consuming repeated results more than once. */
|
||||
internal fun mergeTranscriptWithLive(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
chronological: Boolean = true,
|
||||
preserveLiveIds: Boolean = false,
|
||||
): List<ChatMessage> {
|
||||
val dedupedRest = restMessages.dedupeById()
|
||||
val dedupedCurrent = currentMessages.dedupeById()
|
||||
// User rows can carry live/cache-only metadata (for example whether the
|
||||
// bubble continues the active turn). Keep that richer copy when REST
|
||||
// returns the same logical row.
|
||||
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
|
||||
val mergedRest =
|
||||
dedupedRest.map { rest ->
|
||||
if (rest.role != MessageRole.USER) return@map rest
|
||||
val matchIndex =
|
||||
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
||||
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
||||
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
||||
val incoming = restMessages.dedupeById()
|
||||
val current = currentMessages.dedupeById()
|
||||
val matches = matchTranscriptMessages(incoming, current, allowAssistantContentMatches = chronological)
|
||||
val consumed = matches.mapNotNull { it?.id }.toSet()
|
||||
val merged =
|
||||
incoming.mapIndexed { index, message ->
|
||||
val match = matches[index]
|
||||
// Keep local user metadata and stable IDs already used by the renderer.
|
||||
when {
|
||||
match?.role == MessageRole.USER -> {
|
||||
match.copy(
|
||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||
)
|
||||
}
|
||||
|
||||
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
|
||||
match.copy(
|
||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
||||
content = message.content,
|
||||
timestamp = message.timestamp,
|
||||
isStreaming = message.isStreaming,
|
||||
reasoningText = message.reasoningText.ifBlank { match.reasoningText },
|
||||
attachments = message.attachments ?: match.attachments,
|
||||
toolName = message.toolName ?: match.toolName,
|
||||
toolCallId = message.toolCallId.ifBlank { match.toolCallId },
|
||||
toolStatus = message.toolStatus ?: match.toolStatus,
|
||||
displayKind = message.displayKind ?: match.displayKind,
|
||||
tokenCount = message.tokenCount ?: match.tokenCount,
|
||||
completionId = message.completionId ?: match.completionId,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
message.copy(completionId = message.completionId ?: match?.completionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
val restIds = dedupedRest.map { it.id }.toSet()
|
||||
val liveTail =
|
||||
dedupedCurrent.filter { old ->
|
||||
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
|
||||
val transcript =
|
||||
if (chronological) {
|
||||
current.filterNot { it.id in consumed } + merged
|
||||
} else {
|
||||
merged + current.filterNot { it.id in consumed }
|
||||
}
|
||||
if (liveTail.isEmpty()) return mergedRest.dedupeById()
|
||||
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
|
||||
// #129: a mapped completion can confirm a cached REST row while its rich UUID
|
||||
// copy is also present. Fold that now-confirmed echo without changing the live key.
|
||||
// The page has already consumed its content matches; consuming another would
|
||||
// collapse a separate repeated occurrence. Only confirmed identities can fold here.
|
||||
return dedupeCachedMessages(transcript.inTranscriptOrder(), confirmedOnly = true).reconcileReasoningRows()
|
||||
}
|
||||
|
||||
/** Numeric canonical history first, then unconfirmed local insertion order; clocks never sort a transcript. */
|
||||
private fun List<ChatMessage>.inTranscriptOrder(): List<ChatMessage> =
|
||||
sortedWith(
|
||||
compareBy<ChatMessage> { it.canonicalOrder == null }
|
||||
.thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE },
|
||||
)
|
||||
|
||||
private val ChatMessage.canonicalOrder: Long?
|
||||
get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
|
||||
|
||||
/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */
|
||||
private fun List<ChatMessage>.reconcileReasoningRows(): List<ChatMessage> =
|
||||
mapIndexed { index, message ->
|
||||
val previous = getOrNull(index - 1)
|
||||
if (previous?.role == MessageRole.ASSISTANT && previous.content.isBlank() &&
|
||||
previous.canonicalRestId != null && previous.reasoningText.isNotBlank() &&
|
||||
message.role == MessageRole.ASSISTANT && message.content.isNotBlank() &&
|
||||
message.canonicalRestId != null && message.reasoningText == previous.reasoningText
|
||||
) {
|
||||
message.copy(reasoningText = "")
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge a REST page without matching it against already-settled transcript rows. */
|
||||
internal fun mergeIncrementalTranscriptPage(
|
||||
restMessages: List<ChatMessage>,
|
||||
@@ -234,7 +405,7 @@ internal fun mergeIncrementalTranscriptPage(
|
||||
): List<ChatMessage> {
|
||||
val settledEnd =
|
||||
currentMessages.indexOfLast { message ->
|
||||
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
||||
serverMessageIndex(message.canonicalRestId ?: message.id, sessionId)?.let { it < offset } == true
|
||||
}
|
||||
return (
|
||||
currentMessages.take(settledEnd + 1) +
|
||||
|
||||
@@ -45,6 +45,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -374,6 +376,7 @@ class ChatViewModel(
|
||||
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
|
||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
|
||||
) : AndroidViewModel(application) {
|
||||
constructor(application: Application) : this(application, startCleanup = true)
|
||||
|
||||
@@ -443,6 +446,15 @@ class ChatViewModel(
|
||||
*/
|
||||
private var latestPaging = false
|
||||
private var isSyncingMessages = false
|
||||
private var cacheCursor: ChatPersistenceRepository.Cursor? = null
|
||||
private var cacheHasOlder = false
|
||||
private var cacheLoaded = false
|
||||
private var serverHasOlder = false
|
||||
private var cacheJob: Job? = null
|
||||
private var hydrationJob: Job? = null
|
||||
private var olderJob: Job? = null
|
||||
private var syncJob: Job? = null
|
||||
private val historyFetchMutex = Mutex()
|
||||
|
||||
// ── Session resume recovery (desktop parity) ────────────────────────
|
||||
// Bounded auto-retry with exponential backoff, mirroring the desktop's
|
||||
@@ -642,7 +654,19 @@ class ChatViewModel(
|
||||
activeHydrationRequestSequence = ++hydrationRequestSequence
|
||||
// Preserve metadata so late results and errors are rejected as stale.
|
||||
sessionGeneration++
|
||||
_uiState.update { it.copy(isSessionReady = false) }
|
||||
// #129: invalidate and cancel paged work together, including its busy flags.
|
||||
hydrationJob?.cancel()
|
||||
olderJob?.cancel()
|
||||
syncJob?.cancel()
|
||||
cacheJob?.cancel()
|
||||
isSyncingMessages = false
|
||||
cancelResumeRetry()
|
||||
_uiState.update {
|
||||
it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false)
|
||||
}
|
||||
_uiState.value.currentSessionId?.let { sessionId ->
|
||||
if (!cacheLoaded) loadCachedMessages(sessionId, sessionGeneration)
|
||||
}
|
||||
// Fail any in-flight awaited RPCs so callers don't hang
|
||||
// across the disconnect (delegated to HermesWsClient, issue #526).
|
||||
wsClient.rejectAllPending()
|
||||
@@ -2904,151 +2928,201 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU work never runs in StateFlow.update: it may retry. If a WS event or another
|
||||
* page changes the transcript during compute, recompute against that newer snapshot.
|
||||
*/
|
||||
private suspend fun mergeHistoryPage(
|
||||
isCurrent: () -> Boolean,
|
||||
cached: Boolean = false,
|
||||
prepend: Boolean = false,
|
||||
mapPage: (List<ChatMessage>) -> List<ChatMessage>,
|
||||
): List<ChatMessage>? {
|
||||
while (isCurrent()) {
|
||||
val snapshot = _uiState.value
|
||||
val computed =
|
||||
withContext(historyDispatcher) {
|
||||
val page = mapPage(snapshot.messages)
|
||||
val merged =
|
||||
if (cached) {
|
||||
mergeCachedTranscriptPage(page, snapshot.messages)
|
||||
} else {
|
||||
mergeTranscriptWithLive(
|
||||
page,
|
||||
snapshot.messages,
|
||||
chronological = !prepend,
|
||||
preserveLiveIds = true,
|
||||
)
|
||||
}
|
||||
val stableMessages = if (merged == snapshot.messages) snapshot.messages else merged
|
||||
Triple(page, stableMessages, hydrateTodosFromMessages(merged).ifEmpty { snapshot.todos })
|
||||
}
|
||||
if (!isCurrent()) return null
|
||||
var applied = false
|
||||
_uiState.update { current ->
|
||||
applied = isCurrent() && current.messages === snapshot.messages && current.todos === snapshot.todos
|
||||
if (applied) current.copy(messages = computed.second, todos = computed.third) else current
|
||||
}
|
||||
if (applied) return computed.first
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun publishHistoryAvailability() {
|
||||
_uiState.update { it.copy(hasOlderMessages = cacheHasOlder || serverHasOlder) }
|
||||
}
|
||||
|
||||
private suspend fun readCachedPage(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
isCurrent: () -> Boolean = { isCurrentSessionRequest(sessionId, generation) },
|
||||
): Boolean {
|
||||
val before = cacheCursor
|
||||
// Room suspend queries own their IO executor; entity mapping/dedup runs on Default.
|
||||
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
||||
val valid = { isCurrent() && cacheCursor == before }
|
||||
if (!valid()) return false
|
||||
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false
|
||||
if (!valid()) return false
|
||||
cacheCursor = page.cursor
|
||||
cacheHasOlder = page.hasOlder
|
||||
cacheLoaded = true
|
||||
publishHistoryAvailability()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun loadCachedMessages(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
): Job =
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId))
|
||||
_uiState.update { state ->
|
||||
// Only paint if still showing this session AND no fresher server
|
||||
// page has landed yet (a fast REST fetch must not be clobbered
|
||||
// by a stale cache read that loses the race).
|
||||
if (isCurrentSessionRequest(sessionId, generation) &&
|
||||
state.messages.isEmpty() &&
|
||||
cachedMessages.isNotEmpty()
|
||||
) {
|
||||
state.copy(
|
||||
messages = cachedMessages,
|
||||
isLoading = false,
|
||||
todos = hydrateTodosFromMessages(cachedMessages),
|
||||
)
|
||||
} else {
|
||||
state
|
||||
): Job {
|
||||
cacheJob?.cancel()
|
||||
return viewModelScope
|
||||
.launch {
|
||||
try {
|
||||
readCachedPage(sessionId, generation)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentSessionRequest(sessionId, generation)) {
|
||||
// The first read can be retried by the same older-history action.
|
||||
cacheHasOlder = true
|
||||
publishHistoryAvailability()
|
||||
_uiState.update { it.copy(errorMessage = "Failed to load cached messages: ${e.message}") }
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentSessionRequest(sessionId, generation) && _uiState.value.messages.isNotEmpty()) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.also { cacheJob = it }
|
||||
}
|
||||
|
||||
private fun loadSessionMessages(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
) {
|
||||
if (activeHydrationRequestSequence != 0L && cacheJob?.isActive == true) {
|
||||
cacheJob?.cancel()
|
||||
if (!cacheLoaded) loadCachedMessages(sessionId, generation)
|
||||
}
|
||||
val requestSequence = ++hydrationRequestSequence
|
||||
activeHydrationRequestSequence = requestSequence
|
||||
viewModelScope.launch {
|
||||
// Initial page: ask for the NEWEST page directly (order=latest —
|
||||
// offset is measured back from the newest message, page returned
|
||||
// chronologically; verified in hermes_state.py get_messages).
|
||||
// No count-based anchor, so a stale session-list message_count can
|
||||
// no longer land the page at the wrong position (issue #859).
|
||||
// Legacy backends without the `order` param ignore it and echo no
|
||||
// `pagination` — detect that and fall back to the count-based
|
||||
// anchor so nothing regresses.
|
||||
val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest")
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
val (result, requestedOffset) =
|
||||
if (latestResult is NetworkResult.Success && latestResult.data.pagination?.order == "latest") {
|
||||
latestPaging = true
|
||||
latestResult to 0
|
||||
} else if (latestResult is NetworkResult.Success) {
|
||||
latestPaging = false
|
||||
val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence)
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset
|
||||
} else {
|
||||
latestPaging = false
|
||||
latestResult to 0
|
||||
}
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
// REST 200 — the gateway has the row for this session.
|
||||
sessionHasServerPresence = true
|
||||
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
||||
val chatMessages =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
serverOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
context = getApplication(),
|
||||
)
|
||||
loadedMessageOffset = serverOffset
|
||||
withContext(ioDispatcher) {
|
||||
repo.persistMessages(chatMessages, sessionId)
|
||||
}
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
_uiState.update { state ->
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update state
|
||||
// Merge, don't replace: a reload mid-turn must not
|
||||
// drop live WS bubbles (running tool call, streaming
|
||||
// answer) the server hasn't persisted yet. Issue #771.
|
||||
val merged = mergeTranscriptWithLive(chatMessages, state.messages)
|
||||
val hasOlder =
|
||||
if (latestPaging) {
|
||||
// Newest-anchored: older messages exist iff the
|
||||
// page came back FULL (a short page means we hit
|
||||
// the oldest boundary).
|
||||
val returned = result.data.pagination?.returned ?: chatMessages.size
|
||||
returned >= MESSAGE_PAGE_SIZE && chatMessages.isNotEmpty()
|
||||
hydrationJob?.cancel()
|
||||
olderJob?.cancel()
|
||||
syncJob?.cancel()
|
||||
isSyncingMessages = false
|
||||
_uiState.update { it.copy(isLoadingOlder = false) }
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
hydrationJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest")
|
||||
if (!valid()) return@launch
|
||||
// Backends without stable latest IDs use the legacy absolute-position protocol.
|
||||
val useLatest =
|
||||
latestResult is NetworkResult.Success &&
|
||||
latestResult.data.pagination?.order == "latest" &&
|
||||
latestResult.data.messages.all { it.id != null }
|
||||
val (result, requestedOffset) =
|
||||
if (useLatest || latestResult is NetworkResult.Failure) {
|
||||
latestResult to 0
|
||||
} else {
|
||||
val count = fetchServerMessageCount(sessionId, generation, requestSequence)
|
||||
if (!valid()) return@launch
|
||||
val offset = (count - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset
|
||||
}
|
||||
if (!valid()) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
||||
val raw = result.data.messages
|
||||
val page =
|
||||
mergeHistoryPage(valid) { current ->
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
raw,
|
||||
serverOffset,
|
||||
useLatest,
|
||||
current,
|
||||
context = getApplication(),
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
if (!valid()) return@launch
|
||||
latestPaging = useLatest
|
||||
// Cursor counts RAW rows, including hidden reasoning/placeholder rows.
|
||||
loadedMessageOffset = if (useLatest) serverOffset + raw.size else serverOffset
|
||||
serverHasOlder = raw.isNotEmpty() &&
|
||||
if (useLatest) raw.size >= MESSAGE_PAGE_SIZE else serverOffset > 0
|
||||
sessionHasServerPresence = true
|
||||
publishHistoryAvailability()
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
val error = result.error.message
|
||||
if (error.contains("404", ignoreCase = true) ||
|
||||
error.contains("not found", ignoreCase = true)
|
||||
) {
|
||||
serverHasOlder = false
|
||||
publishHistoryAvailability()
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
} else {
|
||||
serverOffset > 0 && chatMessages.isNotEmpty()
|
||||
if (hydratedGeneration == generation) hydratedGeneration = -1L
|
||||
handleResumeFailure(sessionId, generation, "Failed to load messages: $error")
|
||||
}
|
||||
state.copy(
|
||||
messages = merged,
|
||||
todos = hydrateTodosFromMessages(merged),
|
||||
isLoading = false,
|
||||
hasOlderMessages = hasOlder,
|
||||
isLoadingOlder = false,
|
||||
)
|
||||
}
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
val errorMsg = result.error.message
|
||||
val is404 =
|
||||
errorMsg.contains("404", ignoreCase = true) ||
|
||||
errorMsg.contains("not found", ignoreCase = true)
|
||||
if (is404) {
|
||||
// A 404 from GET /api/sessions/{id}/messages indicates the session has no
|
||||
// persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history).
|
||||
// It is NOT a fatal error: keep the session open with empty transcript, clear loading,
|
||||
// and mark hydrated cleanly.
|
||||
_uiState.update {
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
)
|
||||
}
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
return@launch
|
||||
}
|
||||
if (hydratedGeneration == generation) hydratedGeneration = -1L
|
||||
_uiState.update {
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingOlder = false,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (valid()) {
|
||||
hydratedGeneration = -1L
|
||||
handleResumeFailure(sessionId, generation, "Failed to load messages: ${e.message}")
|
||||
}
|
||||
// Route the transcript failure through the bounded resume
|
||||
// retry (desktop parity) instead of a one-shot snackbar —
|
||||
// a transient backend/network blip recovers on its own,
|
||||
// and a persistent failure ends in an explicit Retry.
|
||||
handleResumeFailure(
|
||||
sessionId,
|
||||
generation,
|
||||
"Failed to load messages: ${result.error.message}",
|
||||
)
|
||||
} finally {
|
||||
if (valid()) _uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistHistoryPage(
|
||||
page: List<ChatMessage>,
|
||||
sessionId: String,
|
||||
) {
|
||||
// A mapped page can reuse a live WS message. Never overwrite its newer persisted
|
||||
// version with the snapshot used for mapping; WS owns persistence of those IDs.
|
||||
val pageIds = page.mapNotNull { it.canonicalRestId }.toSet()
|
||||
val aliases = _uiState.value.messages.filter { it.restId in pageIds && !it.id.startsWith("rest-") }
|
||||
withContext(historyDispatcher) {
|
||||
repo.persistMessages(
|
||||
page.mapNotNull { message -> message.canonicalRestId?.let { message.copy(id = it, restId = null) } },
|
||||
sessionId,
|
||||
)
|
||||
repo.confirmIdentities(aliases, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3096,6 +3170,15 @@ class ChatViewModel(
|
||||
runtimeSessionId = null
|
||||
pendingInitialPrompt = null
|
||||
ActiveSessionHolder.clear()
|
||||
cacheJob?.cancel()
|
||||
hydrationJob?.cancel()
|
||||
olderJob?.cancel()
|
||||
syncJob?.cancel()
|
||||
activeHydrationRequestSequence = 0L
|
||||
cacheCursor = null
|
||||
cacheHasOlder = false
|
||||
cacheLoaded = false
|
||||
serverHasOlder = false
|
||||
loadedMessageOffset = 0
|
||||
latestPaging = false
|
||||
isSyncingMessages = false
|
||||
@@ -3372,222 +3455,150 @@ class ChatViewModel(
|
||||
fun loadOlderMessages() {
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return
|
||||
if (!state.hasOlderMessages || state.isLoadingOlder || olderJob?.isActive == true) return
|
||||
if (cacheJob?.isActive == true) return
|
||||
val fromCache = cacheHasOlder || !cacheLoaded
|
||||
if (!fromCache && (hydrationJob?.isActive == true || isSyncingMessages || !serverHasOlder)) return
|
||||
val generation = sessionGeneration
|
||||
if (!state.hasOlderMessages || state.isLoadingOlder) return
|
||||
if (!latestPaging && loadedMessageOffset <= 0) return
|
||||
val oldOffset = loadedMessageOffset
|
||||
// latest: offsets count BACK from the newest message, so older pages go
|
||||
// UP; legacy: absolute offsets go DOWN toward 0 (issue #859).
|
||||
val newOffset =
|
||||
if (latestPaging) {
|
||||
oldOffset + MESSAGE_PAGE_SIZE
|
||||
} else {
|
||||
(oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
}
|
||||
// Legacy: the final page can be short; requesting a full page at a
|
||||
// clamped offset OVERLAPS already-loaded rows (duplicate stable keys
|
||||
// crash LazyColumn), so size the request to the real gap. Latest:
|
||||
// pages are disjoint from-end ranges, so a full page never overlaps.
|
||||
val limit = if (latestPaging) MESSAGE_PAGE_SIZE else oldOffset - newOffset
|
||||
val requestSequence = activeHydrationRequestSequence
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
_uiState.update { it.copy(isLoadingOlder = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
fetchMessagePage(
|
||||
sessionId,
|
||||
newOffset,
|
||||
limit,
|
||||
order = if (latestPaging) "latest" else null,
|
||||
)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset
|
||||
val older =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
returnedOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
loadedMessageOffset = returnedOffset
|
||||
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
||||
val hasOlder =
|
||||
if (latestPaging) {
|
||||
// Full page = more older messages behind it; a
|
||||
// short (or empty) page is the oldest boundary.
|
||||
val returned = result.data.pagination?.returned ?: older.size
|
||||
returned >= limit && older.isNotEmpty()
|
||||
} else {
|
||||
returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0
|
||||
olderJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
while (cacheHasOlder || !cacheLoaded) {
|
||||
val beforeMessages = _uiState.value.messages
|
||||
if (!readCachedPage(sessionId, generation, valid)) return@launch
|
||||
val afterMessages = _uiState.value.messages
|
||||
val addedRows =
|
||||
withContext(historyDispatcher) {
|
||||
val previousIds = beforeMessages.mapTo(HashSet()) { it.id }
|
||||
afterMessages.any { it.id !in previousIds }
|
||||
}
|
||||
current.copy(
|
||||
messages = (older + current.messages).distinctBy { it.id },
|
||||
isLoadingOlder = false,
|
||||
hasOlderMessages = hasOlder,
|
||||
)
|
||||
if (!valid()) return@launch
|
||||
if (addedRows) return@launch
|
||||
// Hydration persists canonical rows behind the local cache cursor. Consume
|
||||
// those already-visible echoes without spending another older-history action.
|
||||
}
|
||||
}
|
||||
if (hydrationJob?.isActive == true || isSyncingMessages || !serverHasOlder) return@launch
|
||||
// Cache reads may suspend through hydration/sync; use the current server cursor.
|
||||
val useLatest = latestPaging
|
||||
val oldOffset = loadedMessageOffset
|
||||
val offset = if (useLatest) oldOffset else (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
val limit = if (useLatest) MESSAGE_PAGE_SIZE else oldOffset - offset
|
||||
if (limit <= 0) return@launch
|
||||
val result = fetchMessagePage(sessionId, offset, limit, order = if (useLatest) "latest" else null)
|
||||
if (!valid()) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: offset
|
||||
val raw = result.data.messages
|
||||
val page =
|
||||
mergeHistoryPage(valid, prepend = true) { current ->
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
raw,
|
||||
returnedOffset,
|
||||
useLatest,
|
||||
current,
|
||||
isPagingOlder = true,
|
||||
context = getApplication(),
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
if (!valid()) return@launch
|
||||
loadedMessageOffset = if (useLatest) returnedOffset + raw.size else returnedOffset
|
||||
serverHasOlder = raw.isNotEmpty() &&
|
||||
if (useLatest) {
|
||||
loadedMessageOffset > oldOffset && raw.size >= limit
|
||||
} else {
|
||||
returnedOffset < oldOffset && returnedOffset > 0
|
||||
}
|
||||
publishHistoryAvailability()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
if (isCurrentSessionRequest(sessionId, generation)) {
|
||||
it.copy(isLoadingOlder = false)
|
||||
} else {
|
||||
it
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Failed to load older messages: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (valid()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Failed to load older messages: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (valid()) _uiState.update { it.copy(isLoadingOlder = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncCurrentSession() {
|
||||
// Issue #840: a session created but never prompted has no server row
|
||||
// yet — the REST transcript 404s and the resume-retry machinery spins
|
||||
// forever (visible in logcat as repeated /messages 404s). MessageStart
|
||||
// flips the flag once the first prompt persists the row.
|
||||
if (!sessionHasServerPresence) return
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return
|
||||
val generation = sessionGeneration
|
||||
if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping ||
|
||||
_streamingState.value.streamingMessage != null
|
||||
if (isSyncingMessages || hydrationJob?.isActive == true || state.isLoading || state.isLoadingOlder ||
|
||||
state.isAgentTyping || _streamingState.value.streamingMessage != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
val generation = sessionGeneration
|
||||
val requestSequence = activeHydrationRequestSequence
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
val useLatest = latestPaging
|
||||
val nextOffset =
|
||||
if (latestPaging) {
|
||||
// Newest-anchored paging can't compute an absolute
|
||||
// "after my last row" offset (from-end offsets shift as the
|
||||
// transcript grows), so refetch the newest page; the logical
|
||||
// merge below keeps existing copies and only adds new rows
|
||||
// (issue #859).
|
||||
if (useLatest) {
|
||||
0
|
||||
} else {
|
||||
state.messages
|
||||
.mapNotNull { serverMessageIndex(it.id, sessionId) }
|
||||
.mapNotNull { serverMessageIndex(it.canonicalRestId ?: it.id, sessionId) }
|
||||
.maxOrNull()
|
||||
?.plus(1)
|
||||
?: loadedMessageOffset
|
||||
}
|
||||
isSyncingMessages = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result =
|
||||
fetchMessagePage(
|
||||
sessionId,
|
||||
nextOffset,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
order = if (latestPaging) "latest" else null,
|
||||
)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val incoming =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
nextOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
if (incoming.isEmpty()) return@launch
|
||||
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
||||
// Issue #771: the sync merge was dropping the
|
||||
// newest tool bubble — the incoming REST page
|
||||
// didn't include it yet (server persists tool rows
|
||||
// at completion, but the sync offset may predate
|
||||
// that), and the fragile toolName/content match
|
||||
// consumed the WRONG incoming tool for an existing
|
||||
// one, leaving the newest WS tool with no match
|
||||
// → dropped. Use sameLogicalMessage (canonical
|
||||
// result-key match) and always preserve any WS
|
||||
// message that has no REST counterpart.
|
||||
val unmatchedIncoming: MutableList<ChatMessage?> = incoming.toMutableList()
|
||||
val incomingById = HashMap<String, Int>(unmatchedIncoming.size)
|
||||
for (i in unmatchedIncoming.indices) {
|
||||
val id = unmatchedIncoming[i]?.id
|
||||
if (id != null && !incomingById.containsKey(id)) {
|
||||
incomingById[id] = i
|
||||
}
|
||||
}
|
||||
|
||||
val mergedList = mutableListOf<ChatMessage>()
|
||||
|
||||
for (existing in current.messages) {
|
||||
val existingServerIndex = serverMessageIndex(existing.id, sessionId)
|
||||
if (existingServerIndex != null) {
|
||||
val matchIdx = incomingById[existing.id]
|
||||
if (matchIdx != null && unmatchedIncoming[matchIdx] != null) {
|
||||
mergedList.add(unmatchedIncoming[matchIdx]!!)
|
||||
unmatchedIncoming[matchIdx] = null
|
||||
} else if (latestPaging) {
|
||||
// Newest-anchored paging re-keys rows by
|
||||
// from-end position, so a transcript that
|
||||
// grew since the last fetch shifted ids —
|
||||
// match by logical content and keep the
|
||||
// existing copy (issue #859).
|
||||
val logicalIdx =
|
||||
unmatchedIncoming.indexOfFirst { inc ->
|
||||
inc != null && sameLogicalMessage(inc, existing)
|
||||
}
|
||||
if (logicalIdx >= 0) {
|
||||
mergedList.add(existing)
|
||||
unmatchedIncoming[logicalIdx] = null
|
||||
} else {
|
||||
mergedList.add(existing)
|
||||
}
|
||||
} else {
|
||||
mergedList.add(existing)
|
||||
}
|
||||
} else {
|
||||
// WS message (UUID id, no server index):
|
||||
// match by canonical content, not fragile
|
||||
// toolName/content equality.
|
||||
val matchIdx =
|
||||
unmatchedIncoming.indexOfFirst { inc ->
|
||||
inc != null && sameLogicalMessage(inc, existing)
|
||||
}
|
||||
if (matchIdx >= 0) {
|
||||
// Prefer the WS copy (richer payload,
|
||||
// real tool name) when available.
|
||||
mergedList.add(existing)
|
||||
unmatchedIncoming[matchIdx] = null
|
||||
} else {
|
||||
// No REST counterpart (server hasn't
|
||||
// persisted yet) — KEEP the WS message.
|
||||
mergedList.add(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (inc in unmatchedIncoming) {
|
||||
if (inc != null) {
|
||||
mergedList.add(inc)
|
||||
}
|
||||
}
|
||||
val merged = mergedList.distinctBy { it.id }
|
||||
if (sameMessages(current.messages, merged)) {
|
||||
current
|
||||
} else {
|
||||
current.copy(messages = merged)
|
||||
}
|
||||
}
|
||||
syncJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result =
|
||||
fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE, if (useLatest) "latest" else null)
|
||||
if (!valid()) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
val offset = result.data.pagination?.offset ?: result.data.offset ?: nextOffset
|
||||
val page =
|
||||
mergeHistoryPage(valid) { current ->
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages,
|
||||
offset,
|
||||
useLatest,
|
||||
current,
|
||||
// Sync fetches recent replies, so confirm live completion identities.
|
||||
isPagingOlder = false,
|
||||
context = getApplication(),
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
||||
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
||||
// request may overlap, but stable IDs remove echoes without skipping any history.
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (valid()) _uiState.update { it.copy(errorMessage = "Failed to sync messages: ${e.message}") }
|
||||
} finally {
|
||||
if (valid()) isSyncingMessages = false
|
||||
}
|
||||
} finally {
|
||||
if (generation == sessionGeneration) isSyncingMessages = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onModelSwitchInitiated() {
|
||||
@@ -3926,15 +3937,17 @@ class ChatViewModel(
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
order: String? = null,
|
||||
) = withContext(ioDispatcher) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
includeCompacted = true,
|
||||
order = order,
|
||||
)
|
||||
) = historyFetchMutex.withLock {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
includeCompacted = true,
|
||||
order = order,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
/** One older-page request per deliberate upward gesture, including an overscroll at index zero. */
|
||||
internal class ChatHistoryPrefetch {
|
||||
private var upwardPixels = 0f
|
||||
private var requested = false
|
||||
|
||||
fun onScroll(
|
||||
firstVisibleIndex: Int,
|
||||
deltaY: Float,
|
||||
userInput: Boolean,
|
||||
canLoad: Boolean,
|
||||
): Boolean {
|
||||
if (userInput && deltaY < 0f) upwardPixels = 0f
|
||||
if (deltaY <= 0f || !canLoad || requested) return false
|
||||
// A fling retains the drag's intent until scrolling stops. Programmatic
|
||||
// scrolling without an upward user gesture must never load history.
|
||||
if (!userInput && upwardPixels == 0f) return false
|
||||
upwardPixels += deltaY
|
||||
if (firstVisibleIndex > 25 || upwardPixels < 24f) return false
|
||||
requested = true
|
||||
return true
|
||||
}
|
||||
|
||||
fun endGesture() {
|
||||
upwardPixels = 0f
|
||||
requested = false
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,9 @@ import kotlinx.coroutines.yield
|
||||
* 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the
|
||||
* number of tail updates; shows on the FAB. Tapping the FAB resumes following
|
||||
* and clears the count.
|
||||
* 6. **Paging anchor preservation** — capture the first visible item + offset
|
||||
* with [captureAnchor] before prepending older history, then
|
||||
* [restoreAnchor] after insertion so the same content stays under the eye.
|
||||
* 6. **Paging anchors** — the full-bleed renderer preserves the live lazy row
|
||||
* key and offset when older history is inserted; this controller owns no
|
||||
* fetch-time anchor or animated compensation.
|
||||
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
|
||||
* search navigation, auto-follow) is launched from [scope] so animations
|
||||
* don't compete.
|
||||
@@ -189,30 +189,17 @@ class ChatScrollController(
|
||||
}
|
||||
}
|
||||
|
||||
/** An explicit history gesture must not be undone by a short list's bottom-follow. */
|
||||
fun pauseFollowing() {
|
||||
isFollowingBottom = false
|
||||
}
|
||||
|
||||
/** FAB tap: resume following + clear unread. */
|
||||
fun resumeFollowing() = jumpToBottom(animated = true)
|
||||
|
||||
/** True when the FAB should be visible (not following bottom + content exists). */
|
||||
fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent
|
||||
|
||||
/**
|
||||
* Current pixel scroll offset of the first visible item — captured before
|
||||
* prepending older history so it can be restored after insertion.
|
||||
*/
|
||||
fun captureAnchorOffset(): Int = listState.firstVisibleItemScrollOffset
|
||||
|
||||
/**
|
||||
* Restore the viewport to a specific item (resolved by id in the caller, so
|
||||
* the restore is robust even if streaming tokens arrive during the page
|
||||
* insert). Keeps the reader's eye on the same content after prepend.
|
||||
*/
|
||||
fun scrollToItem(
|
||||
index: Int,
|
||||
offset: Int,
|
||||
) {
|
||||
scope.launch { listState.scrollToItem(index, offset) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a search match (serialized through [scope]).
|
||||
*
|
||||
@@ -258,7 +245,7 @@ fun tailContentKey(
|
||||
clarifyRequest: Any?,
|
||||
): Any =
|
||||
listOf(
|
||||
messages.size,
|
||||
messages.lastOrNull(),
|
||||
streamingMessage?.hashCode() ?: 0,
|
||||
isThinking,
|
||||
subagentIndicators.size,
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -12,9 +11,17 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Density
|
||||
@@ -32,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
@@ -93,6 +101,9 @@ fun FullBleedChatList(
|
||||
savingAttachmentPath: String? = null,
|
||||
openingAttachmentPath: String? = null,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
hasOlderMessages: Boolean = false,
|
||||
pagingSessionId: String? = null,
|
||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
||||
Box(
|
||||
@@ -105,19 +116,133 @@ fun FullBleedChatList(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
||||
// Keep only the incoming older prefix out of layout during a drag/fling.
|
||||
// The input remains authoritative: overlap updates, new messages and streaming
|
||||
// stay live, and a session change resets the boundary immediately.
|
||||
val previousFirstId = remember(listState, pagingSessionId) { mutableStateOf(messages.firstOrNull()?.id) }
|
||||
val firstRenderedId = previousFirstId.value
|
||||
val olderPrefixSize =
|
||||
remember(messages, firstRenderedId) {
|
||||
if (firstRenderedId != null && firstRenderedId != messages.firstOrNull()?.id) {
|
||||
messages.indexOfFirst { it.id == firstRenderedId }.coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
val hiddenPrefixSize = if (olderPrefixSize > 0 && listState.isScrollInProgress) olderPrefixSize else 0
|
||||
val renderedMessages =
|
||||
remember(messages, hiddenPrefixSize) {
|
||||
if (hiddenPrefixSize > 0) messages.subList(hiddenPrefixSize, messages.size) else messages
|
||||
}
|
||||
val toolMilestones = remember(renderedMessages) { toolCallMilestones(renderedMessages) }
|
||||
val settledTurns = remember(renderedMessages) { groupIntoTurns(renderedMessages) }
|
||||
val settledIds = remember(renderedMessages) { renderedMessages.mapTo(HashSet()) { it.id } }
|
||||
val turns =
|
||||
remember(messages, streamingState.streamingMessage) {
|
||||
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
|
||||
remember(settledTurns, streamingState.streamingMessage) {
|
||||
appendStreamingTurn(settledTurns, settledIds, streamingState.streamingMessage)
|
||||
}
|
||||
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
|
||||
// messageId → LazyColumn item index. The lazy list has EXTRA items
|
||||
// vs the message list (reasoning hoists, tool rows), so search-match
|
||||
// scrolling must resolve the lazy index, not use the message index.
|
||||
val lazyIndexById =
|
||||
remember(turns, isLoadingOlder) {
|
||||
messageIdToLazyIndex(turns, leadingItems = if (isLoadingOlder) 1 else 0)
|
||||
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, renderedMessages)
|
||||
// A single ordered definition supplies both emitted tail rows and anchor keys.
|
||||
val tailItems =
|
||||
buildMap<String, @Composable () -> Unit> {
|
||||
agentStatus?.let { status ->
|
||||
put("agent_status") {
|
||||
AgentStatusIndicator(status = status)
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify bubble — rendered at the very bottom
|
||||
if (clarifyRequest != null) {
|
||||
put("clarify_bubble") {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = clarifyRequest,
|
||||
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
|
||||
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
|
||||
onDismiss = { onDismissClarify?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultUnlockPrompt != null) {
|
||||
put("vault_unlock_card") {
|
||||
VaultUnlockCard(
|
||||
prompt = vaultUnlockPrompt,
|
||||
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
|
||||
onDismiss = { onDismissVaultUnlock?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultSaveLoginPrompt != null) {
|
||||
put("vault_save_login_card") {
|
||||
VaultSaveLoginCard(
|
||||
prompt = vaultSaveLoginPrompt,
|
||||
onConfirm = { identifier, password ->
|
||||
onRespondVaultSaveLogin?.invoke(identifier, password)
|
||||
},
|
||||
onDismiss = { onDismissVaultSaveLogin?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultCodePrompt != null) {
|
||||
put("vault_code_card") {
|
||||
VaultCodeCard(
|
||||
prompt = vaultCodePrompt,
|
||||
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
|
||||
onDismiss = { onDismissVaultCode?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// custom23 prepend regression: a 150-row page can move the anchor beyond
|
||||
// Compose's nearest-key lookup window. Capture the LIVE layout at insertion,
|
||||
// not when fetching starts, and request its new index before the next measure.
|
||||
// A staged prefix only reaches here at idle, so this cannot stop its drag/fling.
|
||||
SideEffect {
|
||||
val oldFirstId = previousFirstId.value
|
||||
val firstId = renderedMessages.firstOrNull()?.id
|
||||
if (oldFirstId != null && oldFirstId != firstId && renderedMessages.any { it.id == oldFirstId }) {
|
||||
val firstVisibleIndex = listState.firstVisibleItemIndex
|
||||
val anchor = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == firstVisibleIndex }
|
||||
if (anchor != null) {
|
||||
val newIndex = (fullBleedItemKeys(turns) + tailItems.keys).indexOf(anchor.key)
|
||||
if (newIndex >= 0 && newIndex != anchor.index) {
|
||||
listState.requestScrollToItem(newIndex, listState.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
previousFirstId.value = firstId
|
||||
}
|
||||
val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() }
|
||||
val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading)
|
||||
val loadOlder = rememberUpdatedState(onLoadOlder)
|
||||
val prefetchConnection =
|
||||
remember(listState, prefetch, scrollController) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
if (prefetch.onScroll(
|
||||
firstVisibleIndex = listState.firstVisibleItemIndex,
|
||||
deltaY = available.y,
|
||||
userInput = source == NestedScrollSource.UserInput,
|
||||
canLoad = canLoad.value,
|
||||
)
|
||||
) {
|
||||
scrollController.pauseFollowing()
|
||||
loadOlder.value()
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(listState, prefetch) {
|
||||
snapshotFlow { listState.isScrollInProgress }.collect { scrolling ->
|
||||
if (!scrolling) prefetch.endGesture()
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll the current search match into view, word-focused. Lives here
|
||||
// (not in ChatLifecycleEffects) because only this composable knows
|
||||
@@ -128,6 +253,7 @@ fun FullBleedChatList(
|
||||
searchState.currentIndex,
|
||||
searchState.matchIndices,
|
||||
searchState.matchOffsets,
|
||||
renderedMessages.firstOrNull()?.id,
|
||||
) {
|
||||
if (searchState.isActive &&
|
||||
searchState.currentIndex >= 0 &&
|
||||
@@ -135,6 +261,9 @@ fun FullBleedChatList(
|
||||
) {
|
||||
val messageIndex = searchState.matchIndices[searchState.currentIndex]
|
||||
if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect
|
||||
// Search indices refer to incoming messages; resolve by id into the
|
||||
// rendered rows. A hit in a staged prefix is retried when it becomes visible.
|
||||
val lazyIndexById = messageIdToLazyIndex(turns)
|
||||
val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect
|
||||
val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
|
||||
val contentLength = messages[messageIndex].content.length
|
||||
@@ -153,202 +282,163 @@ fun FullBleedChatList(
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
if (isLoadingOlder) {
|
||||
item(key = "loading-older") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
// Eager captures: item lambda reads these at
|
||||
// composition time (lazy), so capture now.
|
||||
val userMessage = turn.message
|
||||
item(key = "user-${userMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
message = userMessage,
|
||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||
isCurrentMatch =
|
||||
searchState.currentMatchId != null &&
|
||||
searchState.currentMatchId == userMessage.id,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
// Reasoning hoist: the turn's reasoning renders at the
|
||||
// TOP of the turn — above tool rows — so thinking
|
||||
// leads, then the tool work, then the answer. The
|
||||
// matching prose entry renders without its own card.
|
||||
val turnReasoning =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
||||
if (turnReasoning != null) {
|
||||
val reasoning = turnReasoning.message
|
||||
item(key = "reasoning-${reasoning.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
ReasoningCard(
|
||||
reasoningText = reasoning.reasoningText,
|
||||
isStreaming = reasoning.isStreaming,
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(prefetchConnection),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
// Eager captures: item lambda reads these at
|
||||
// composition time (lazy), so capture now.
|
||||
val userMessage = turn.message
|
||||
item(key = "user-${userMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
message = userMessage,
|
||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||
isCurrentMatch =
|
||||
searchState.currentMatchId != null &&
|
||||
searchState.currentMatchId == userMessage.id,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
turn.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is AgentEntry.Prose -> {
|
||||
val proseMessage = entry.message
|
||||
val hoistedReasoning =
|
||||
turnReasoning != null &&
|
||||
proseMessage.id == turnReasoning.message.id
|
||||
if (proseMessage.hasVisibleAgentContent()) {
|
||||
item(key = "prose-${proseMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||
StreamingFullBleedWithTypingEffect(
|
||||
streaming = proseMessage,
|
||||
typingDelayMs = typingEffectDelayMs,
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
// Reasoning hoist: the turn's reasoning renders at the
|
||||
// TOP of the turn — above tool rows — so thinking
|
||||
// leads, then the tool work, then the answer. The
|
||||
// matching prose entry renders without its own card.
|
||||
val turnReasoning =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
||||
if (turnReasoning != null) {
|
||||
val reasoning = turnReasoning.message
|
||||
item(key = "reasoning-${reasoning.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
ReasoningCard(
|
||||
reasoningText = reasoning.reasoningText,
|
||||
isStreaming = reasoning.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
turn.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is AgentEntry.Prose -> {
|
||||
val proseMessage = entry.message
|
||||
val hoistedReasoning =
|
||||
turnReasoning != null &&
|
||||
proseMessage.id == turnReasoning.message.id
|
||||
if (!proseMessage.hasVisibleAgentContent() && !hoistedReasoning &&
|
||||
proseMessage.reasoningText.isNotBlank()
|
||||
) {
|
||||
item(key = "reasoning-${proseMessage.id}") {
|
||||
ReasoningCard(
|
||||
reasoningText = proseMessage.reasoningText,
|
||||
isStreaming = proseMessage.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (proseMessage.hasVisibleAgentContent()) {
|
||||
item(key = "prose-${proseMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||
StreamingFullBleedWithTypingEffect(
|
||||
streaming = proseMessage,
|
||||
typingDelayMs = typingEffectDelayMs,
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
} else {
|
||||
FullBleedAgentMessage(
|
||||
message = proseMessage,
|
||||
// Highlight only bubbles that actually contain a match —
|
||||
// the rest skip the highlight scan entirely.
|
||||
searchQuery =
|
||||
if (searchState.isActive &&
|
||||
proseMessage.id in searchState.matchedIds
|
||||
) {
|
||||
searchState.query
|
||||
} else {
|
||||
""
|
||||
},
|
||||
isCurrentMatch =
|
||||
searchState.currentMatchId != null &&
|
||||
searchState.currentMatchId == proseMessage.id,
|
||||
showReasoning = !hoistedReasoning,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AgentEntry.ToolRow -> {
|
||||
val toolMessage = entry.message
|
||||
val milestone = toolMilestones[toolMessage.id]
|
||||
item(key = "tool-${toolMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
FullBleedToolRow(toolMessage)
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AgentEntry.SystemEvent -> {
|
||||
val sysMessage = entry.message
|
||||
item(key = "sys-${sysMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
if (sysMessage.displayKind != null) {
|
||||
// Timeline marker (issue #904):
|
||||
// model/personality switches and
|
||||
// auto-continues render as a chip.
|
||||
TimelineMarkerChip(message = sysMessage)
|
||||
} else {
|
||||
FullBleedAgentMessage(
|
||||
message = proseMessage,
|
||||
// Highlight only bubbles that actually contain a match —
|
||||
// the rest skip the highlight scan entirely.
|
||||
searchQuery =
|
||||
if (searchState.isActive &&
|
||||
proseMessage.id in searchState.matchedIds
|
||||
) {
|
||||
searchState.query
|
||||
} else {
|
||||
""
|
||||
},
|
||||
isCurrentMatch =
|
||||
searchState.currentMatchId != null &&
|
||||
searchState.currentMatchId == proseMessage.id,
|
||||
showReasoning = !hoistedReasoning,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
FullBleedSystemEvent(
|
||||
message = sysMessage,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AgentEntry.ToolRow -> {
|
||||
val toolMessage = entry.message
|
||||
val milestone = toolMilestones[toolMessage.id]
|
||||
item(key = "tool-${toolMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
FullBleedToolRow(toolMessage)
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AgentEntry.SystemEvent -> {
|
||||
val sysMessage = entry.message
|
||||
item(key = "sys-${sysMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
if (sysMessage.displayKind != null) {
|
||||
// Timeline marker (issue #904):
|
||||
// model/personality switches and
|
||||
// auto-continues render as a chip.
|
||||
TimelineMarkerChip(message = sysMessage)
|
||||
} else {
|
||||
FullBleedSystemEvent(
|
||||
message = sysMessage,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agentStatus?.let { status ->
|
||||
item(key = "agent_status") {
|
||||
AgentStatusIndicator(status = status)
|
||||
tailItems.forEach { (key, content) ->
|
||||
item(key = key) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify bubble — rendered at the very bottom
|
||||
if (clarifyRequest != null) {
|
||||
item(key = "clarify_bubble") {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = clarifyRequest,
|
||||
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
|
||||
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
|
||||
onDismiss = { onDismissClarify?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultUnlockPrompt != null) {
|
||||
item(key = "vault_unlock_card") {
|
||||
VaultUnlockCard(
|
||||
prompt = vaultUnlockPrompt,
|
||||
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
|
||||
onDismiss = { onDismissVaultUnlock?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultSaveLoginPrompt != null) {
|
||||
item(key = "vault_save_login_card") {
|
||||
VaultSaveLoginCard(
|
||||
prompt = vaultSaveLoginPrompt,
|
||||
onConfirm = { identifier, password ->
|
||||
onRespondVaultSaveLogin?.invoke(identifier, password)
|
||||
},
|
||||
onDismiss = { onDismissVaultSaveLogin?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultCodePrompt != null) {
|
||||
item(key = "vault_code_card") {
|
||||
VaultCodeCard(
|
||||
prompt = vaultCodePrompt,
|
||||
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
|
||||
onDismiss = { onDismissVaultCode?.invoke() },
|
||||
)
|
||||
}
|
||||
// Loading history must never become the list's first visible anchor.
|
||||
if (isLoadingOlder) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(12.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +128,22 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
||||
fun groupIntoTurnsWithStreaming(
|
||||
messages: List<ChatMessage>,
|
||||
streamingMessage: ChatMessage?,
|
||||
): List<ChatTurn> = appendStreamingTurn(groupIntoTurns(messages), messages.mapTo(HashSet()) { it.id }, streamingMessage)
|
||||
|
||||
/** Reuse every settled turn; only the last agent turn needs a changing streaming entry. */
|
||||
internal fun appendStreamingTurn(
|
||||
settled: List<ChatTurn>,
|
||||
settledIds: Set<String>,
|
||||
streamingMessage: ChatMessage?,
|
||||
): List<ChatTurn> {
|
||||
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) {
|
||||
return groupIntoTurns(messages)
|
||||
}
|
||||
val turns = groupIntoTurns(messages).toMutableList()
|
||||
if (streamingMessage == null || streamingMessage.id in settledIds) return settled
|
||||
val prose = AgentEntry.Prose(streamingMessage)
|
||||
val last = turns.lastOrNull()
|
||||
if (last is ChatTurn.Agent) {
|
||||
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose)
|
||||
val last = settled.lastOrNull()
|
||||
return if (last is ChatTurn.Agent) {
|
||||
settled.dropLast(1) + ChatTurn.Agent(last.entries + prose)
|
||||
} else {
|
||||
turns += ChatTurn.Agent(listOf(prose))
|
||||
settled + ChatTurn.Agent(listOf(prose))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,8 +159,9 @@ fun groupIntoTurnsWithStreaming(
|
||||
*
|
||||
* Mirrors the item emission order in [FullBleedChatList] exactly:
|
||||
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
||||
* visible entry. [leadingItems] accounts for fixed items emitted before the
|
||||
* turns (e.g. the `loading-older` spinner when paging).
|
||||
* visible entry, including later reasoning-only rows at their own positions.
|
||||
* [leadingItems] accounts for fixed items emitted before the
|
||||
* turns. The history spinner is an overlay and consumes no index.
|
||||
*
|
||||
* @return messageId → LazyColumn item index of the message's content item
|
||||
* (prose items for agent messages; user items for user messages).
|
||||
@@ -165,48 +169,54 @@ fun groupIntoTurnsWithStreaming(
|
||||
fun messageIdToLazyIndex(
|
||||
turns: List<ChatTurn>,
|
||||
leadingItems: Int = 0,
|
||||
): Map<String, Int> {
|
||||
val map = mutableMapOf<String, Int>()
|
||||
var itemIndex = leadingItems
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
map[turn.message.id] = itemIndex
|
||||
itemIndex++
|
||||
): Map<String, Int> =
|
||||
fullBleedItemKeys(turns)
|
||||
.mapIndexedNotNull { index, key ->
|
||||
if (key.startsWith("user-") || key.startsWith("prose-")) {
|
||||
key.substringAfter('-') to index + leadingItems
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
val hasReasoning =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.any { it.message.reasoningText.isNotBlank() }
|
||||
if (hasReasoning) {
|
||||
// The reasoning-<id> hoist item occupies one slot.
|
||||
itemIndex++
|
||||
/** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
|
||||
internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
|
||||
buildList {
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
add("user-${turn.message.id}")
|
||||
}
|
||||
turn.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is AgentEntry.Prose -> {
|
||||
if (entry.message.hasVisibleAgentContent()) {
|
||||
map[entry.message.id] = itemIndex
|
||||
itemIndex++
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
val hoisted =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
||||
hoisted?.let { add("reasoning-${it.message.id}") }
|
||||
turn.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is AgentEntry.Prose -> {
|
||||
if (entry.message.hasVisibleAgentContent()) {
|
||||
add("prose-${entry.message.id}")
|
||||
} else if (entry != hoisted && entry.message.reasoningText.isNotBlank()) {
|
||||
add("reasoning-${entry.message.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AgentEntry.ToolRow -> {
|
||||
itemIndex++
|
||||
}
|
||||
is AgentEntry.ToolRow -> {
|
||||
add("tool-${entry.message.id}")
|
||||
}
|
||||
|
||||
is AgentEntry.SystemEvent -> {
|
||||
itemIndex++
|
||||
is AgentEntry.SystemEvent -> {
|
||||
add("sys-${entry.message.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the message id of the CURRENT search match once, so per-item
|
||||
|
||||
Reference in new issue
Block a user