refactor: extract media + server-message-mapping from ChatViewModel (#955)

* refactor: extract ChatMediaDelegate from ChatViewModel (verbatim move)

* refactor: extract mapServerMessages to ChatServerMessageMapper (verbatim move)

* style: remove orphaned comments after media extraction, ktlint format

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-22 20:44:43 +03:00
1 parent bb400881e6
commit 69578e7818
3 files changed
+522 -459

No files matched your search

@@ -0,0 +1,274 @@
package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.remote.GatewayFile
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.data.remote.GatewayFileResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Owns gateway/host media handling for [ChatViewModel]: attaching MEDIA paths
* extracted from message content, opening and saving attachments through the
* gateway file client, and the FileProvider/ACTION_VIEW plumbing.
*
* Verbatim extraction of the inline implementation (corral 1 of the 2026-08
* ChatViewModel delegate refactor) — same collaborators, same behavior: the
* shared `_uiState` flow, an application context provider, and a scope for
* background fetches are supplied by the ViewModel at construction.
*/
class ChatMediaDelegate(
private val uiState: MutableStateFlow<ChatUiState>,
private val getApplication: () -> Application,
private val scope: kotlinx.coroutines.CoroutineScope,
private val ioDispatcher: kotlin.coroutines.CoroutineContext,
) {
fun attachHostMedia(
sessionId: String,
messageId: String,
) {
val current = uiState.value.messages.find { it.id == messageId } ?: return
val content = current.content
val items = HostMediaExtractor.extract(content)
if (items.isEmpty()) return
val baseUrl = AuthManager.getBaseUrl()
if (baseUrl.isBlank()) return
// NOTE: no token gate here. Gated (basic-auth) dashboards download via
// the session cookie, and even loopback hosts can serve files with an
// empty query token — requiring a non-blank token dropped every MEDIA
// attachment on gate-auth connections where getToken() returns null.
val existingUrls =
current.attachments
.orEmpty()
.mapNotNull { it.gatewayUrl }
.toSet()
val newAttachments =
items.mapNotNull { item ->
val token = AuthManager.getToken().orEmpty()
val url = GatewayFileClient.buildMediaUrl(baseUrl, token, item.path) ?: return@mapNotNull null
if (url in existingUrls) return@mapNotNull null
Attachment(
uri = url,
name = mediaNameFromPath(item.path),
mimeType = mediaMimeForPath(item.path),
size = 0,
gatewayUrl = url,
source = AttachmentSource.GATEWAY,
)
}
if (newAttachments.isEmpty()) return
val stripped = HostMediaExtractor.strip(content)
uiState.update { state ->
state.copy(
messages =
state.messages.map { msg ->
if (msg.id == messageId) {
msg.copy(
content = stripped,
attachments =
(msg.attachments.orEmpty() + newAttachments)
.distinctBy { it.gatewayUrl ?: it.uri },
)
} else {
msg
}
},
)
}
}
/**
* Open an attachment when its chip/thumbnail is tapped.
*
* - LOCAL (user-picked) files: open the original `content://` URI
* directly via [android.content.Intent.ACTION_VIEW] — the resolver
* already grants read access for the picked document. If that fails
* (e.g. the permission lapsed), we copy to cache and retry via
* FileProvider so the tap is never a silent no-op.
* - GATEWAY (agent `MEDIA:`) files: stream the file to cache via
* [GatewayFileClient] (chunked — never held in memory), then open with
* [android.content.Intent.ACTION_VIEW] through FileProvider — so a
* remote phone can view agent-delivered files in-place.
*
* Failures surface through [ChatUiState.openError] (non-blocking
* snackbar); the tap is never swallowed.
*/
fun openAttachment(attachment: Attachment) {
val ctx = getApplication().applicationContext
if (attachment.source == AttachmentSource.LOCAL) {
// Best-effort direct open of the picked content URI.
runCatching { openWithView(ctx, android.net.Uri.parse(attachment.uri), attachment.mimeType) }
.onSuccess { return }
.onFailure { /* fall through to cache-copy below */ }
}
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
val path = gatewayPathFor(attachment)
// Show a loading indicator on the attachment card while the file
// streams down (mirrors the save spinner; cleared in all outcomes).
uiState.update { it.copy(openingAttachmentPath = path) }
val cacheDir = java.io.File(ctx.cacheDir, "gateway_files")
scope.launch(ioDispatcher) {
try {
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
is GatewayFileResult.Success -> {
openBytes(ctx, result.file)
}
is GatewayFileResult.NotFound -> {
showOpenError("File not found on gateway: ${attachment.name}")
}
is GatewayFileResult.Forbidden -> {
showOpenError("Access denied: ${attachment.name}")
}
is GatewayFileResult.TooLarge -> {
showOpenError("File too large to open: ${attachment.name}")
}
is GatewayFileResult.Unauthorized -> {
showOpenError("Session expired — reconnect to open: ${attachment.name}")
}
is GatewayFileResult.Failure -> {
showOpenError("Could not open ${attachment.name}: ${result.throwable.message}")
}
}
} finally {
uiState.update {
if (it.openingAttachmentPath == path) it.copy(openingAttachmentPath = null) else it
}
}
}
}
/** Save an agent-delivered file through Android's system document picker. */
fun saveAttachment(
attachment: Attachment,
destination: android.net.Uri,
) {
if (uiState.value.savingAttachmentPath != null) return
val path = gatewayPathFor(attachment)
val cacheDir =
java.io.File(getApplication().applicationContext.cacheDir, "gateway_files")
uiState.update { it.copy(savingAttachmentPath = path) }
scope.launch(ioDispatcher) {
try {
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
is GatewayFileResult.Success -> {
val resolver = getApplication().contentResolver
runCatching {
resolver
.openOutputStream(destination, "wt")
?.use { output ->
result.file.cacheFile.inputStream().use { input ->
GatewayFileClient.copyChunked(input, output)
}
}
?: error("destination is unavailable")
}.onSuccess {
showOpenError("Saved ${result.file.name}")
}.onFailure {
showOpenError("Could not save ${attachment.name}: ${it.message}")
}
}
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
is GatewayFileResult.Unauthorized ->
showOpenError(
"Session expired — reconnect to save: ${attachment.name}",
)
is GatewayFileResult.Failure ->
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
}
} finally {
uiState.update {
if (it.savingAttachmentPath == path) {
it.copy(savingAttachmentPath = null)
} else {
it
}
}
}
}
}
fun gatewayPathFor(attachment: Attachment): String =
attachment.gatewayUrl?.let(::gatewayPathFromUrl)
?: attachment.uri.removePrefix("gateway:").takeIf { it != attachment.uri }
?: attachment.name
/** Open a gateway file already streamed to cache via FileProvider + ACTION_VIEW. */
private fun openBytes(
ctx: android.content.Context,
file: GatewayFile,
) {
runCatching {
val uri =
androidx.core.content.FileProvider.getUriForFile(
ctx,
"${ctx.packageName}.fileprovider",
file.cacheFile,
)
openWithView(ctx, uri, file.mimeType)
}.onFailure { showOpenError("Could not open ${file.name}: ${it.message}") }
}
/** Fire an ACTION_VIEW intent; throws if no activity can handle the type. */
private fun openWithView(
ctx: android.content.Context,
uri: android.net.Uri,
mimeType: String,
) {
val viewIntent =
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType.ifBlank { "*/*" })
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
ctx.startActivity(viewIntent)
} catch (e: Throwable) {
val fallbackIntent =
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, "*/*")
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooser =
android.content.Intent.createChooser(fallbackIntent, "Open file").apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
ctx.startActivity(chooser)
}
}
private fun showOpenError(message: String) {
uiState.update { it.copy(openError = message) }
}
fun clearOpenError() {
uiState.update { it.copy(openError = null) }
}
}
internal fun sameMessages(
left: List<ChatMessage>,
right: List<ChatMessage>,
): Boolean =
left.size == right.size &&
left.zip(right).all { (a, b) ->
a.id == b.id &&
a.role == b.role &&
a.content == b.content &&
a.reasoningText == b.reasoningText
}
@@ -0,0 +1,202 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.GatewayFileClient
/**
* 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.
*/
internal fun mapServerMessages(
sessionId: String,
messages: List<SessionMessage>,
offset: Int,
latestPaging: Boolean,
liveMessages: List<ChatMessage>,
): List<ChatMessage> {
val existingReasoningMap =
liveMessages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
// 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)
}
}
// 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()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"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 timestamp =
msg.timestampText
?.toDoubleOrNull()
?.times(1000)
?.toLong()
?: System.currentTimeMillis()
val rawContent = msg.contentText
val rowReasoning =
if (msg.reasoningText.isNotBlank()) {
msg.reasoningText
} else {
existingReasoningMap[rawContent]?.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
}
var finalContent = rawContent
var attachments: List<Attachment>? = null
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent)
if (items.isNotEmpty()) {
val baseUrl = AuthManager.getBaseUrl()
val token = AuthManager.getToken().orEmpty()
finalContent = HostMediaExtractor.strip(rawContent)
attachments =
items
.mapNotNull { item ->
val url =
GatewayFileClient.buildMediaUrl(
baseUrl,
token,
item.path,
) ?: return@mapNotNull null
Attachment(
uri = url,
name = mediaNameFromPath(item.path),
mimeType = mediaMimeForPath(item.path),
size = 0,
gatewayUrl = url,
source = AttachmentSource.GATEWAY,
)
}.takeIf { it.isNotEmpty() }
}
}
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
}
}
}
mapped.add(
ChatMessage(
id = restId,
role = role,
content = finalContent,
reasoningText = finalReasoning,
toolCallId = msg.toolCallId,
attachments = attachments,
timestamp = timestamp,
isStreaming = false,
displayKind = msg.display_kind,
),
)
}
// 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)
}
}
}
return mapped
}
@@ -11,17 +11,12 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.SlashUsageStore import com.m57.hermescontrol.data.local.SlashUsageStore
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseContextBreakdown
import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.GatewayFile
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.data.remote.GatewayFileResult
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
@@ -524,6 +519,14 @@ class ChatViewModel(
get() = searchDelegate.searchState get() = searchDelegate.searchState
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState) private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
private val mediaDelegate =
ChatMediaDelegate(
uiState = _uiState,
getApplication = { getApplication() },
scope = viewModelScope,
ioDispatcher = ioDispatcher,
)
/** /**
* Model options cached from GET /api/model/options so the in-session model * Model options cached from GET /api/model/options so the in-session model
* picker (issue #589) opens instantly when the user types /model or taps the * picker (issue #589) opens instantly when the user types /model or taps the
@@ -797,7 +800,7 @@ class ChatViewModel(
// via the gateway /api/files/download endpoint. Works on a // via the gateway /api/files/download endpoint. Works on a
// remote phone too. // remote phone too.
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
attachHostMedia(effect.sessionId, effect.messageId) mediaDelegate.attachHostMedia(effect.sessionId, effect.messageId)
} }
} }
} }
@@ -1435,6 +1438,19 @@ class ChatViewModel(
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index) fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
fun saveAttachment(
attachment: Attachment,
destination: android.net.Uri,
) = mediaDelegate.saveAttachment(attachment, destination)
fun clearOpenError() {
_uiState.update { it.copy(openError = null) }
}
fun gatewayPathFor(attachment: Attachment): String = mediaDelegate.gatewayPathFor(attachment)
fun clearAttachments() = attachmentsDelegate.clearAttachments() fun clearAttachments() = attachmentsDelegate.clearAttachments()
private fun handleSlashCommand(command: String) { private fun handleSlashCommand(command: String) {
@@ -2126,7 +2142,14 @@ class ChatViewModel(
// REST 200 — the gateway has the row for this session. // REST 200 — the gateway has the row for this session.
sessionHasServerPresence = true sessionHasServerPresence = true
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), serverOffset) val chatMessages =
mapServerMessages(
sessionId,
result.data.messages.orEmpty(),
serverOffset,
latestPaging,
_uiState.value.messages,
)
loadedMessageOffset = serverOffset loadedMessageOffset = serverOffset
withContext(ioDispatcher) { withContext(ioDispatcher) {
repo.persistMessages(chatMessages, sessionId) repo.persistMessages(chatMessages, sessionId)
@@ -2483,7 +2506,14 @@ class ChatViewModel(
is NetworkResult.Success -> { is NetworkResult.Success -> {
if (!isCurrentSessionRequest(sessionId, generation)) return@launch if (!isCurrentSessionRequest(sessionId, generation)) return@launch
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset) val older =
mapServerMessages(
sessionId,
result.data.messages.orEmpty(),
returnedOffset,
latestPaging,
_uiState.value.messages,
)
loadedMessageOffset = returnedOffset loadedMessageOffset = returnedOffset
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) } withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
_uiState.update { current -> _uiState.update { current ->
@@ -2560,7 +2590,14 @@ class ChatViewModel(
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
if (!isCurrentSessionRequest(sessionId, generation)) return@launch if (!isCurrentSessionRequest(sessionId, generation)) return@launch
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset) val incoming =
mapServerMessages(
sessionId,
result.data.messages.orEmpty(),
nextOffset,
latestPaging,
_uiState.value.messages,
)
if (incoming.isEmpty()) return@launch if (incoming.isEmpty()) return@launch
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) } withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
_uiState.update { current -> _uiState.update { current ->
@@ -2846,456 +2883,6 @@ class ChatViewModel(
} }
} }
private fun mapServerMessages(
sessionId: String,
messages: List<SessionMessage>,
offset: Int,
): List<ChatMessage> {
val existingReasoningMap =
_uiState.value.messages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
// 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>()
_uiState.value.messages
.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)
}
}
// 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>()
_uiState.value.messages
.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()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"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 timestamp =
msg.timestampText
?.toDoubleOrNull()
?.times(1000)
?.toLong()
?: System.currentTimeMillis()
val rawContent = msg.contentText
val rowReasoning =
if (msg.reasoningText.isNotBlank()) {
msg.reasoningText
} else {
existingReasoningMap[rawContent]?.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
}
var finalContent = rawContent
var attachments: List<Attachment>? = null
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent)
if (items.isNotEmpty()) {
val baseUrl = AuthManager.getBaseUrl()
val token = AuthManager.getToken().orEmpty()
finalContent = HostMediaExtractor.strip(rawContent)
attachments =
items
.mapNotNull { item ->
val url =
GatewayFileClient.buildMediaUrl(
baseUrl,
token,
item.path,
) ?: return@mapNotNull null
Attachment(
uri = url,
name = mediaNameFromPath(item.path),
mimeType = mediaMimeForPath(item.path),
size = 0,
gatewayUrl = url,
source = AttachmentSource.GATEWAY,
)
}.takeIf { it.isNotEmpty() }
}
}
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
}
}
}
mapped.add(
ChatMessage(
id = restId,
role = role,
content = finalContent,
reasoningText = finalReasoning,
toolCallId = msg.toolCallId,
attachments = attachments,
timestamp = timestamp,
isStreaming = false,
displayKind = msg.display_kind,
),
)
}
// 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)
}
}
}
return mapped
}
// ── Issue #724: attach host-path MEDIA: files as real attachments ────
//
// The gateway's WebSocket stream delivers the raw `MEDIA:<path>` directive
// the desktop app turns into an authenticated `/api/files/download?...`
// URL. We parse every directive, build the download URL via
// [GatewayFileClient], classify it (image / audio / video / file) using
// [mediaKindForPath], and attach it to the message. Images render inline
// (Coil loads the URL); every other type becomes a tappable, fetchable
// attachment. The directive text is stripped from the message body. Works
// on a remote phone (real HTTP). Mobile-only; backend untouched. Pure
// parsing lives in [HostMediaExtractor].
/**
* ViewModel-side handler for [ReducerEffect.AttachHostMedia]: find the local
* message by id, convert any `MEDIA:<path>` directives into [Attachment]s
* (via the gateway download URL) and strip them from the text. Role,
* reasoning, timestamp and existing attachments are preserved; new gateway
* attachments are appended. Idempotent — skips if gateway attachments for
* the same paths already exist.
*/
private fun attachHostMedia(
sessionId: String,
messageId: String,
) {
val current = _uiState.value.messages.find { it.id == messageId } ?: return
val content = current.content
val items = HostMediaExtractor.extract(content)
if (items.isEmpty()) return
val baseUrl = AuthManager.getBaseUrl()
if (baseUrl.isBlank()) return
// NOTE: no token gate here. Gated (basic-auth) dashboards download via
// the session cookie, and even loopback hosts can serve files with an
// empty query token — requiring a non-blank token dropped every MEDIA
// attachment on gate-auth connections where getToken() returns null.
val existingUrls =
current.attachments
.orEmpty()
.mapNotNull { it.gatewayUrl }
.toSet()
val newAttachments =
items.mapNotNull { item ->
val token = AuthManager.getToken().orEmpty()
val url = GatewayFileClient.buildMediaUrl(baseUrl, token, item.path) ?: return@mapNotNull null
if (url in existingUrls) return@mapNotNull null
Attachment(
uri = url,
name = mediaNameFromPath(item.path),
mimeType = mediaMimeForPath(item.path),
size = 0,
gatewayUrl = url,
source = AttachmentSource.GATEWAY,
)
}
if (newAttachments.isEmpty()) return
val stripped = HostMediaExtractor.strip(content)
_uiState.update { state ->
state.copy(
messages =
state.messages.map { msg ->
if (msg.id == messageId) {
msg.copy(
content = stripped,
attachments =
(msg.attachments.orEmpty() + newAttachments)
.distinctBy { it.gatewayUrl ?: it.uri },
)
} else {
msg
}
},
)
}
}
/**
* Open an attachment when its chip/thumbnail is tapped.
*
* - LOCAL (user-picked) files: open the original `content://` URI
* directly via [android.content.Intent.ACTION_VIEW] — the resolver
* already grants read access for the picked document. If that fails
* (e.g. the permission lapsed), we copy to cache and retry via
* FileProvider so the tap is never a silent no-op.
* - GATEWAY (agent `MEDIA:`) files: stream the file to cache via
* [GatewayFileClient] (chunked — never held in memory), then open with
* [android.content.Intent.ACTION_VIEW] through FileProvider — so a
* remote phone can view agent-delivered files in-place.
*
* Failures surface through [ChatUiState.openError] (non-blocking
* snackbar); the tap is never swallowed.
*/
fun openAttachment(attachment: Attachment) {
val ctx = getApplication<Application>().applicationContext
if (attachment.source == AttachmentSource.LOCAL) {
// Best-effort direct open of the picked content URI.
runCatching { openWithView(ctx, android.net.Uri.parse(attachment.uri), attachment.mimeType) }
.onSuccess { return }
.onFailure { /* fall through to cache-copy below */ }
}
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
val path = gatewayPathFor(attachment)
// Show a loading indicator on the attachment card while the file
// streams down (mirrors the save spinner; cleared in all outcomes).
_uiState.update { it.copy(openingAttachmentPath = path) }
val cacheDir = java.io.File(ctx.cacheDir, "gateway_files")
viewModelScope.launch(ioDispatcher) {
try {
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
is GatewayFileResult.Success -> {
openBytes(ctx, result.file)
}
is GatewayFileResult.NotFound -> {
showOpenError("File not found on gateway: ${attachment.name}")
}
is GatewayFileResult.Forbidden -> {
showOpenError("Access denied: ${attachment.name}")
}
is GatewayFileResult.TooLarge -> {
showOpenError("File too large to open: ${attachment.name}")
}
is GatewayFileResult.Unauthorized -> {
showOpenError("Session expired — reconnect to open: ${attachment.name}")
}
is GatewayFileResult.Failure -> {
showOpenError("Could not open ${attachment.name}: ${result.throwable.message}")
}
}
} finally {
_uiState.update {
if (it.openingAttachmentPath == path) it.copy(openingAttachmentPath = null) else it
}
}
}
}
/** Save an agent-delivered file through Android's system document picker. */
fun saveAttachment(
attachment: Attachment,
destination: android.net.Uri,
) {
if (_uiState.value.savingAttachmentPath != null) return
val path = gatewayPathFor(attachment)
val cacheDir =
java.io.File(getApplication<Application>().applicationContext.cacheDir, "gateway_files")
_uiState.update { it.copy(savingAttachmentPath = path) }
viewModelScope.launch(ioDispatcher) {
try {
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
is GatewayFileResult.Success -> {
val resolver = getApplication<Application>().contentResolver
runCatching {
resolver
.openOutputStream(destination, "wt")
?.use { output ->
result.file.cacheFile.inputStream().use { input ->
GatewayFileClient.copyChunked(input, output)
}
}
?: error("destination is unavailable")
}.onSuccess {
showOpenError("Saved ${result.file.name}")
}.onFailure {
showOpenError("Could not save ${attachment.name}: ${it.message}")
}
}
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
is GatewayFileResult.Unauthorized ->
showOpenError(
"Session expired — reconnect to save: ${attachment.name}",
)
is GatewayFileResult.Failure ->
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
}
} finally {
_uiState.update {
if (it.savingAttachmentPath == path) {
it.copy(savingAttachmentPath = null)
} else {
it
}
}
}
}
}
fun gatewayPathFor(attachment: Attachment): String =
attachment.gatewayUrl?.let(::gatewayPathFromUrl)
?: attachment.uri.removePrefix("gateway:").takeIf { it != attachment.uri }
?: attachment.name
/** Open a gateway file already streamed to cache via FileProvider + ACTION_VIEW. */
private fun openBytes(
ctx: android.content.Context,
file: GatewayFile,
) {
runCatching {
val uri =
androidx.core.content.FileProvider.getUriForFile(
ctx,
"${ctx.packageName}.fileprovider",
file.cacheFile,
)
openWithView(ctx, uri, file.mimeType)
}.onFailure { showOpenError("Could not open ${file.name}: ${it.message}") }
}
/** Fire an ACTION_VIEW intent; throws if no activity can handle the type. */
private fun openWithView(
ctx: android.content.Context,
uri: android.net.Uri,
mimeType: String,
) {
val viewIntent =
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType.ifBlank { "*/*" })
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
ctx.startActivity(viewIntent)
} catch (e: Throwable) {
val fallbackIntent =
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, "*/*")
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooser =
android.content.Intent.createChooser(fallbackIntent, "Open file").apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
ctx.startActivity(chooser)
}
}
private fun showOpenError(message: String) {
_uiState.update { it.copy(openError = message) }
}
fun clearOpenError() {
_uiState.update { it.copy(openError = null) }
}
private fun sameMessages(
left: List<ChatMessage>,
right: List<ChatMessage>,
): Boolean =
left.size == right.size &&
left.zip(right).all { (a, b) ->
a.id == b.id &&
a.role == b.role &&
a.content == b.content &&
a.reasoningText == b.reasoningText
}
// ── UI actions ─────────────────────────────────────────────────────── // ── UI actions ───────────────────────────────────────────────────────
/** /**