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.SlashUsageStore
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.ModelProvider
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.parseUsageSnapshot
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.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
@@ -524,6 +519,14 @@ class ChatViewModel(
get() = searchDelegate.searchState
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
* 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
// remote phone too.
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 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()
private fun handleSlashCommand(command: String) {
@@ -2126,7 +2142,14 @@ class ChatViewModel(
// 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)
val chatMessages =
mapServerMessages(
sessionId,
result.data.messages.orEmpty(),
serverOffset,
latestPaging,
_uiState.value.messages,
)
loadedMessageOffset = serverOffset
withContext(ioDispatcher) {
repo.persistMessages(chatMessages, sessionId)
@@ -2483,7 +2506,14 @@ class ChatViewModel(
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)
val older =
mapServerMessages(
sessionId,
result.data.messages.orEmpty(),
returnedOffset,
latestPaging,
_uiState.value.messages,
)
loadedMessageOffset = returnedOffset
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
_uiState.update { current ->
@@ -2560,7 +2590,14 @@ class ChatViewModel(
when (result) {
is NetworkResult.Success -> {
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
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
_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 ───────────────────────────────────────────────────────
/**