chore: promote dev to main (#1455)

Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
m57andAlexDev_ authored and GitHub committed 2026-10-03 00:51:11 +03:00
1 parent 62fcf07e1b
commit 944390bec1
56 files changed
+3914 -304

No files matched your search

@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -28,6 +29,10 @@ class HermesControlApp :
override fun onCreate() {
super.onCreate()
val startupTimeMs = System.currentTimeMillis()
appScope.launch(Dispatchers.IO) {
cleanStalePastedImages(cacheDir, startupTimeMs)
}
AuthManager.init(this)
NetworkMonitor.init(this)
SessionListCacheStore.init(this)
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
/**
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
messageProvenance =
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
?: MessageProvenance.UNKNOWN,
)
).normalizedGatewayNotice()
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
ChatMessageEntity(
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
@@ -100,20 +101,10 @@ data class SessionMessage(
}
val contentText: String
get() =
when (content) {
is JsonPrimitive -> content.content
null -> ""
else -> content.toString()
}
get() = content?.transcriptText().orEmpty()
val displayContentText: String?
get() =
when (display_content) {
is JsonPrimitive -> display_content.content
null -> null
else -> display_content.toString()
}
get() = display_content?.transcriptText()
val reasoningText: String
get() =
@@ -160,4 +151,25 @@ data class SessionMessage(
get() = tool_call_id.orEmpty()
}
// #1432: model-facing multipart payloads contain inline image bytes, not displayable JSON.
private fun JsonElement.transcriptText(): String =
when (this) {
is JsonPrimitive -> {
content
}
is JsonArray -> {
mapNotNull { part ->
val block = part as? JsonObject ?: return@mapNotNull null
val type = block["type"]
if (type != null && (type as? JsonPrimitive)?.content != "text") return@mapNotNull null
(block["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
}.joinToString("\n")
}
else -> {
toString()
}
}
private val WHITESPACE = Regex("\\s+")
@@ -279,7 +279,7 @@ object EventParser {
"status.update" -> {
val status = payload?.get("status") as? String
WsEvent.StatusUpdate(status, payload)
WsEvent.StatusUpdate(status, payload, sessionId)
}
"error" -> {
@@ -205,6 +205,7 @@ sealed class WsEvent {
data class StatusUpdate(
val status: String?,
val data: Map<String, Any?>?,
val sessionId: String? = null,
) : WsEvent()
data class SessionUpdated(
@@ -11,9 +11,21 @@ data class SessionCreateParams(
@SerialName("hidden") val hidden: Boolean? = null,
)
/**
* The `source` this client declares for every session it mints or resumes.
*
* Mobile presents the desktop surface, so it claims `"desktop"` on BOTH `session.create` and
* `session.resume`. The gateway resolves an omitted `source` from the host environment
* (`_resolve_session_source`), which on a headless backend is `"tui"`; a resumed runtime then
* reads as a surface switch and loses the `desktop_ui` toolset (#1450). One constant so the
* create and resume call sites cannot drift apart again.
*/
const val DESKTOP_SESSION_SOURCE = "desktop"
@Serializable
data class SessionResumeParams(
@SerialName("session_id") val sessionId: String,
@SerialName("source") val source: String? = null,
@SerialName("omit_messages") val omitMessages: Boolean? = null,
@SerialName("profile") val profile: String? = null,
)
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val params =
SessionResumeParams(
sessionId = storedSessionId,
source = DESKTOP_SESSION_SOURCE,
omitMessages = true,
profile = profile,
)
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
@@ -564,7 +565,7 @@ class GroupChatViewModel(
method = RpcMethods.SESSION_CREATE,
params =
SessionCreateParams(
source = "desktop",
source = DESKTOP_SESSION_SOURCE,
profile = bot.name,
title = title,
hidden = true,
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.ui.chat
/** Captured before asynchronous clipboard access; never retarget a late image to a new chat. */
internal data class ChatAttachmentTarget(
val sessionId: String,
val generation: Long,
val baseUrl: String,
val connectionProfileId: String?,
val agentProfileId: String?,
)
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
/**
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
toolCount = 0
}
message.role == MessageRole.TOOL -> {
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[message.id] = toolCount
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
toolCount = 0
}
message.role == MessageRole.TOOL -> {
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount
@@ -0,0 +1,175 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import com.m57.hermescontrol.data.model.Attachment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.UUID
private const val PASTED_IMAGE_DIRECTORY = "pasted_images"
private const val PASTED_IMAGE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000
/**
* Startup-only sweep (#1444). Keep recent files for transcript previews/retries;
* never run during a live session where an old attachment may still be uploading.
*/
internal fun cleanStalePastedImages(
cacheDir: File,
nowMs: Long = System.currentTimeMillis(),
) {
val directory = File(cacheDir, PASTED_IMAGE_DIRECTORY)
val files = runCatching { directory.listFiles() }.getOrNull() ?: return
val cutoff = nowMs - PASTED_IMAGE_MAX_AGE_MS
for (file in files) {
runCatching {
// Do not traverse subdirectories or touch any other cache namespace.
if (file.isFile && file.lastModified() < cutoff) file.delete()
}
}
}
internal class PastedImageTooLargeException : IOException("Pasted image exceeds attachment limit")
/** A private cache file owned by the caller after [ChatPastedImageStore.importImage] returns. */
class PastedImage(
val attachment: Attachment,
val file: File,
) {
/** Call if the import cannot be queued (for example, after a session switch). */
fun discard() {
file.delete()
}
}
/** Explicit clipboard/IME image importer; it never observes the clipboard itself. */
class ChatPastedImageStore(
private val context: Context,
) {
/** Returns null for unsupported, missing, or empty input; oversized streams fail explicitly. */
suspend fun importImage(
uri: Uri,
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
): PastedImage? {
var pending: PastedImage? = null
var delivered = false
try {
val result =
withContext(Dispatchers.IO) {
if (uri.scheme != "content") return@withContext null
val resolver = context.contentResolver
val mimeType =
resolver
.getType(uri)
?.takeIf { it.startsWith("image/", ignoreCase = true) && it.length > "image/".length }
?: return@withContext null
val extension =
MimeTypeMap
.getSingleton()
.getExtensionFromMimeType(mimeType)
?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,10}")) }
?: "img"
val input = resolver.openInputStream(uri) ?: return@withContext null
val activeContext = currentCoroutineContext()
val file =
stagePastedImage(
input = input,
directory = File(context.cacheDir, PASTED_IMAGE_DIRECTORY),
extension = extension,
maxBytes = maxBytes,
checkActive = { activeContext.ensureActive() },
) ?: return@withContext null
try {
activeContext.ensureActive()
val privateUri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
PastedImage(
attachment =
Attachment(
uri = privateUri.toString(),
name = file.name,
mimeType = mimeType,
size = file.length(),
),
file = file,
).also { pending = it }
} catch (failure: Throwable) {
file.delete()
throw failure
}
}
delivered = true
return result
} finally {
// withContext may discard its completed IO result if the parent was cancelled
// during dispatch back; in that case ownership never reached the caller.
if (!delivered) pending?.discard()
}
}
}
/** JVM-testable, exclusive-create bounded copy. Null means empty input. */
internal fun stagePastedImage(
input: InputStream,
directory: File,
extension: String,
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
checkActive: () -> Unit = {},
newId: () -> String = { UUID.randomUUID().toString() },
): File? {
require(maxBytes > 0) { "maxBytes must be positive" }
val effectiveMaxBytes = minOf(maxBytes, MAX_CHAT_ATTACHMENT_BYTES)
require(extension.matches(Regex("[A-Za-z0-9]{1,10}"))) { "Invalid extension" }
var owned: File? = null
var keep = false
try {
return input
.use source@{ source ->
if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
throw IOException("Cannot create pasted image cache directory")
}
val file = File(directory, "${UUID.fromString(newId())}.$extension")
if (!file.createNewFile()) throw IOException("Pasted image cache name already exists")
owned = file
var total = 0L
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
FileOutputStream(file).use { output ->
while (true) {
checkActive()
// Probe one byte beyond the limit without trusting provider-reported size.
val remaining = effectiveMaxBytes - total
val requested = if (remaining < buffer.size) (remaining + 1).toInt() else buffer.size
val count = source.read(buffer, 0, requested)
if (count < 0) break
if (count > remaining) throw PastedImageTooLargeException()
if (count == 0) {
val byte = source.read()
if (byte < 0) break
if (remaining == 0L) throw PastedImageTooLargeException()
output.write(byte)
total++
continue
}
output.write(buffer, 0, count)
total += count
}
checkActive()
}
if (total == 0L) null else file
}.also { keep = it != null }
} finally {
if (!keep) owned?.delete()
}
}
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
@@ -70,7 +72,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -118,9 +119,12 @@ import com.m57.hermescontrol.ui.chat.components.SpeechRequest
import com.m57.hermescontrol.ui.chat.components.SpeechText
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
import com.m57.hermescontrol.ui.chat.components.rememberChatImagePaste
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
import com.m57.hermescontrol.ui.chat.components.replaceComposerDraft
import com.m57.hermescontrol.ui.chat.components.restoreRejectedComposerDraft
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
@@ -338,20 +342,18 @@ fun ChatScreen(
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
}
}
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
val inputState = rememberTextFieldState()
LaunchedEffect(state.pendingPrefillText) {
val prefill = state.pendingPrefillText
if (prefill != null) {
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
inputState.replaceComposerDraft(prefill)
viewModel.consumePendingPrefill()
}
}
LaunchedEffect(state.composerTextToRestore) {
state.composerTextToRestore?.let { text ->
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
inputState.restoreRejectedComposerDraft(text)
viewModel.consumeComposerTextRestore()
}
}
@@ -364,6 +366,10 @@ fun ChatScreen(
}
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val imagePaste =
rememberChatImagePaste(viewModel) { message ->
scrollScope.launch { snackbarHostState.showSnackbar(message) }
}
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
@@ -411,8 +417,7 @@ fun ChatScreen(
val mediaLaunchers =
rememberChatMediaLaunchers(
inputFieldValue = inputFieldValue,
onInputFieldValueChange = { inputFieldValue = it },
inputState = inputState,
onAddAttachment = { uri, name, mimeType, size ->
viewModel.addAttachment(uri, name, mimeType, size)
},
@@ -865,11 +870,12 @@ fun ChatScreen(
}
ChatInputBar(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
inputState = inputState,
receiveContentListener = imagePaste,
isReceivingContent = imagePaste.isReceiving,
onSend = {
if (viewModel.sendMessage(inputFieldValue.text)) {
inputFieldValue = TextFieldValue("")
if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
inputState.clearText()
// Jump only after an accepted send. A readiness race keeps the draft intact.
scrollController.jumpToBottom(animated = true)
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
@@ -100,7 +101,7 @@ class ChatSearchController {
}
MessageRole.TOOL -> {
scan(idx, searchableToolText(message), SearchTarget.TOOL)
if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
}
MessageRole.SYSTEM -> {
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
.toSet()
}
/**
* #1427: the `prompt.submit` `user_row_id` is the exact gateway row for that send, so a history
* page containing it proves delivery even when the merge could not alias the local bubble.
*/
internal fun pendingSendIdsConfirmedByRowIds(
pageRowIds: Set<Long>,
pending: List<PendingSend>,
): Set<String> =
pending
.asSequence()
.filter {
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
}.filter { it.userRowId != null && it.userRowId in pageRowIds }
.map { it.id }
.toSet()
/** #1427: a receipt holding a gateway `user_row_id` is stored server-side and must never become UNKNOWN. */
internal fun canDemoteAcceptedReceipt(receipt: PendingSend): Boolean = receipt.userRowId == null
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
class ChatSendStore(
private val prefs: SharedPreferences? = null,
@@ -232,7 +232,9 @@ internal fun mapServerMessages(
rawContent
}
var attachments: List<Attachment>? = null
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
if (role == MessageRole.USER) {
attachments = userImageAttachments(finalContent, mediaUrl).takeIf { it.isNotEmpty() }
} else if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent)
if (items.isNotEmpty()) {
finalContent = HostMediaExtractor.strip(rawContent)
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
completionId = completionId,
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
reactions = msg.reactions,
),
).normalizedGatewayNotice(),
)
}
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
match.id to
rich.copy(
id = match.id,
attachments =
if (rich.role == MessageRole.USER) {
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
} else {
rich.attachments
},
content = preservedContent ?: rich.content,
restId = match.canonicalRestId ?: message.canonicalRestId,
serverRowId = match.serverRowId ?: message.serverRowId,
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
when {
match?.role == MessageRole.USER -> {
match.copy(
// #1432: cached user rows lack attachment metadata; hydrate from REST,
// while preserving richer optimistic/local attachments when present.
attachments = match.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments,
content =
if (match.isHistoricalCache && !message.attachments.isNullOrEmpty()) {
message.content
} else {
match.content
},
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
serverRowId = message.serverRowId ?: match.serverRowId,
reactions = message.reactions.ifEmpty { match.reactions },
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
.mapNotNull { index ->
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
}.toMap()
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
.reconcileReasoningRows()
return dedupeCachedMessages(
transcript.inTranscriptOrder(
previous = current,
resolvedOrders = resolvedOrders,
observedSuccessorAnchors = current,
),
confirmedOnly = true,
).reconcileReasoningRows()
}
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
private fun List<ChatMessage>.inTranscriptOrder(
previous: List<ChatMessage>,
resolvedOrders: Map<String, Long>,
observedSuccessorAnchors: List<ChatMessage>? = null,
): List<ChatMessage> {
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
// Session-start markers use -1 and must remain before unresolved cached history.
@@ -549,6 +571,21 @@ private fun List<ChatMessage>.inTranscriptOrder(
var pendingLocalOrder: Long? = null
val localAnchors = mutableMapOf<String, Long>()
val pendingOrderByLocal = mutableMapOf<String, Long>()
// #1451: an observed live block must stay before its later confirmed USER successor,
// even when its own REST echoes are absent. Successor anchors derive only from genuinely
// observed current lists (never cache concatenations) and only from later USER prompts.
val nextCanonicalOrder = mutableMapOf<String, Long>()
val anchorSource = observedSuccessorAnchors ?: emptyList()
var followingUserCanonical: Long? = null
for (i in anchorSource.indices.reversed()) {
val message = anchorSource[i]
val order = resolvedOrders[message.id] ?: message.canonicalOrder
if (order != null && message.role == MessageRole.USER) {
followingUserCanonical = order
} else if (followingUserCanonical != null) {
nextCanonicalOrder[message.id] = followingUserCanonical
}
}
previous.forEach { message ->
val order = resolvedOrders[message.id] ?: message.canonicalOrder
if (order != null) {
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
} else if (message.isPermanentlyLocal()) {
localAnchors[message.id] =
if (hasPendingPredecessor) {
Long.MAX_VALUE
nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
} else {
precedingCanonical?.takeIf { it >= 0L }
?: if (message.role == MessageRole.USER) {
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
} else {
hasPendingPredecessor = true
pendingLocalOrder = message.localOrder
nextCanonicalOrder[message.id]?.let { successor ->
localAnchors[message.id] = successor - 1L
}
}
}
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.data.ws.contract.CommandDispatchParams
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
@@ -1511,6 +1512,10 @@ class ChatViewModel(
viewModelScope.launch { fetchContextUsage() }
}
is ReducerEffect.DeleteLocalMessage -> {
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
}
is ReducerEffect.AttachHostMedia -> {
// Issue #724: turn host-path MEDIA: directives into real
// attachments (images inline, every other file tappable)
@@ -1804,6 +1809,8 @@ class ChatViewModel(
contextBreakdown = null,
compressionCount = null,
sessionUsage = null,
isCompressing = false,
compressionStatus = null,
)
}
publishPendingSends()
@@ -3676,6 +3683,34 @@ class ChatViewModel(
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
/** Clipboard reads are asynchronous: bind them to this exact session generation and server. */
internal fun captureAttachmentTarget(): ChatAttachmentTarget? {
val state = _uiState.value
val sessionId = state.currentSessionId ?: return null
// Connection status is combined into public uiState, not written to _uiState.
if (wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ||
!state.isSessionReady || _timelineState.value.isHistorical
) {
return null
}
return ChatAttachmentTarget(
sessionId = sessionId,
generation = sessionGeneration,
baseUrl = AuthManager.getBaseUrl(),
connectionProfileId = AuthManager.getSelectedProfileId(),
agentProfileId = AuthManager.activeProfileId.value,
)
}
internal fun addPastedAttachments(
target: ChatAttachmentTarget,
attachments: List<Attachment>,
): Boolean {
if (captureAttachmentTarget() != target) return false
attachmentsDelegate.addAttachments(attachments)
return true
}
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
@@ -4127,44 +4162,46 @@ class ChatViewModel(
val arg = parts.getOrElse(1) { "" }
viewModelScope.launch(ioDispatcher) {
try {
// Primary path: command.dispatch handles quick/plugin/bundle/
// skill commands + a few hardcoded ones. It returns a hard 4018
// "not a ... command" for everything that lives only in the TUI
// slash worker (the 29 commands that 4018'd on mobile — issue
// #576). For those we fall back to slash.exec, which runs the
// full COMMAND_REGISTRY through the worker.
// Desktop parity: slash.exec owns generic commands. Handle errors here,
// not again through the shared RpcError banner (/usage regression).
val result =
wsClient.call(
RpcMethods.COMMAND_DISPATCH,
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
RpcMethods.SLASH_EXEC,
SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
suppressErrorEvent = true,
)
handleDispatchResult(result.toAny())
} catch (e: HermesWsClient.HermesRpcException) {
val msg = e.message.orEmpty()
// Registry miss on command.dispatch: the backend emits exactly
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
// server.py L12408). Match that precise phrase so unrelated
// errors can't accidentally trigger the slash.exec fallback.
if (msg.contains("not a quick/plugin/bundle/skill command")) {
// Registry miss on command.dispatch -> retry via slash.exec,
// which routes the full CLI command set through the worker.
try {
val result =
wsClient.call(
RpcMethods.SLASH_EXEC,
SlashExecParams(
sessionId = sessionId,
command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
),
)
val output = (result.toAny() as? Map<*, *>)?.get("output") as? String
if (!output.isNullOrBlank()) addAssistantMessage(output)
} catch (e2: HermesWsClient.HermesRpcException) {
addAssistantMessage("/$name: ${e2.message}")
}
val map = result.toAny() as? Map<*, *>
if (map?.get("type") is String) {
handleDispatchResult(map)
} else {
// Legit error from command.dispatch (busy, no history, etc.)
addAssistantMessage("/$name: ${e.message}")
val output = (map?.get("output") as? String).orEmpty().ifBlank { "/$name: no output" }
val warning = (map?.get("warning") as? String).orEmpty()
addAssistantMessage(if (warning.isBlank()) output else "warning: $warning\n$output")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
try {
val result =
wsClient.call(
RpcMethods.COMMAND_DISPATCH,
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
suppressErrorEvent = true,
)
val map = result.toAny() as? Map<*, *>
if (map?.get("type") is String) {
handleDispatchResult(map)
} else {
addAssistantMessage("/$name: invalid response: command.dispatch")
}
} catch (fallback: CancellationException) {
throw fallback
} catch (fallback: Exception) {
val registryMiss =
Regex("not a quick/plugin/(?:bundle/)?skill command", RegexOption.IGNORE_CASE)
.containsMatchIn(fallback.message.orEmpty())
val failure = if (registryMiss) e else fallback
addAssistantMessage("/$name: ${failure.message}")
}
}
}
@@ -4534,7 +4571,7 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) {
wsClient.send(
RpcMethods.SESSION_CREATE,
SessionCreateParams(source = "desktop"),
SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
)
}
@@ -5070,7 +5107,22 @@ class ChatViewModel(
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
mergeHistoryPage(valid, cached = true) {
// #1432: Room retains host references, not connection-bound download URLs.
page.messages.map { message ->
if (message.role == MessageRole.USER && message.attachments.isNullOrEmpty()) {
message.copy(
attachments =
userImageAttachments(
message.content,
::gatewayMediaUrl,
).takeIf { it.isNotEmpty() },
)
} else {
message
}
}
} ?: return false
if (!valid()) return false
cacheCursor = page.cursor
cacheHasOlder = page.hasOlder
@@ -5216,7 +5268,11 @@ class ChatViewModel(
val scope = sendScope()
val scopedPending =
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend)
val pageRowIds = page.mapNotNull { it.serverRowId }.toSet()
(
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending) +
pendingSendIdsConfirmedByRowIds(pageRowIds, scopedPending)
).forEach(::removePendingSend)
publishPendingSends()
drainPendingQueue()
}
@@ -5324,6 +5380,9 @@ class ChatViewModel(
contextBreakdown = null,
compressionCount = null,
sessionUsage = null,
// #1433: compaction state belongs to the session being left.
isCompressing = false,
compressionStatus = null,
pendingAttachments = emptyList(),
composerTextToRestore = null,
reactionKind = null,
@@ -5352,6 +5411,7 @@ class ChatViewModel(
val params =
SessionResumeParams(
sessionId = sessionId,
source = DESKTOP_SESSION_SOURCE,
omitMessages = true,
profile = profile?.takeIf { it.isNotBlank() },
)
@@ -5709,7 +5769,8 @@ class ChatViewModel(
persistHistoryPage(page, sessionId)
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
unverifiedAccepted.forEach { receipt ->
if (sendStore.all().any { it == receipt }) {
// A gateway-issued user_row_id means the row is stored: never walk back to UNKNOWN.
if (canDemoteAcceptedReceipt(receipt) && sendStore.all().any { it == receipt }) {
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
}
}
@@ -136,6 +136,7 @@ object ChatWsEventReducer {
is WsEvent.VaultSaveLoginExpire -> event.sessionId
is WsEvent.VaultCodeRequest -> event.sessionId
is WsEvent.VaultCodeExpire -> event.sessionId
is WsEvent.StatusUpdate -> event.sessionId
else -> null
}
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
@@ -457,6 +458,7 @@ object ChatWsEventReducer {
): ReducerResult {
val failure = replyFailureFromPayload(event.rawPayload, event.text)
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
if (GatewayNotice.isInterruptSentinel(event.text)) return onInterruptNotice(state, streamingState, event)
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
@@ -1052,36 +1054,107 @@ object ChatWsEventReducer {
event: WsEvent.StatusUpdate,
): ReducerResult {
val kind = event.data?.get("kind") as? String
val text = event.data?.get("text") as? String
val text = (event.data?.get("text") as? String)?.trim()
return when (kind) {
// In-progress compaction is live state: the tail indicator shows only while it runs.
"compressing", "compacting" -> {
ReducerResult(
state =
state.copy(
isCompressing = true,
compressionStatus = text ?: "⏳ Compressing context...",
compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
),
streamingState = streamingState,
)
}
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
"compacted" -> {
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
}
"ready" -> {
ReducerResult(
state =
state.copy(
isCompressing = false,
compressionStatus = text,
),
state = state.copy(isCompressing = false, compressionStatus = null),
streamingState = streamingState,
)
}
// Durable notices the desktop/TUI also paint into the transcript.
"goal", "loop", "warn", "fallback" -> {
appendGatewayNotice(state, streamingState, text)
}
else -> {
ReducerResult(state = state, streamingState = streamingState)
}
}
}
/**
* Appends a gateway notice as a persisted SYSTEM row (#1433). SYSTEM rows are permanently local, so
* `inTranscriptOrder` anchors them after their preceding confirmed row instead of the transcript tail.
* A replayed event (reconnect `session.events.since`) re-sends the same text within the current turn;
* skip it rather than stacking a duplicate.
*/
private fun appendGatewayNotice(
state: ChatUiState,
streamingState: StreamingState,
text: String?,
extraEffects: List<ReducerEffect> = emptyList(),
): ReducerResult {
if (text.isNullOrBlank()) {
return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
}
val turnStart = state.messages.indexOfLast { it.isUserTurnBoundary() } + 1
val duplicate =
state.messages.drop(turnStart).any { it.role == MessageRole.SYSTEM && it.content == text }
if (duplicate) return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
val notice = ChatMessage(role = MessageRole.SYSTEM, content = text)
val sid = state.currentSessionId
return ReducerResult(
state = state.copy(messages = state.messages + notice),
streamingState = streamingState,
effects = extraEffects + listOfNotNull(sid?.let { ReducerEffect.PersistMessage(notice, it) }),
)
}
/**
* A turn whose final text is a gateway interrupt sentinel ends with a notice, not assistant prose (#1433).
* For the repetition-loop sentinel the gateway hides the looped partial; drop it here too, including a
* copy already sealed by Stop, or it lingers as a live-only row that jumps to the tail on every merge.
*/
private fun onInterruptNotice(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageComplete,
): ReducerResult {
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
val text = event.text.trim()
val effects = mutableListOf<ReducerEffect>()
var messages = usageState.messages
if (text == GatewayNotice.REPETITION_LOOP_INTERRUPTED) {
val looped = setOfNotNull(streamingState.interruptedMessage?.id, streamingState.streamingMessage?.id)
if (looped.isNotEmpty()) {
messages = messages.filterNot { it.id in looped }
looped.forEach { effects += ReducerEffect.DeleteLocalMessage(it) }
}
} else {
// Other exits keep any real partial the user already saw.
streamingState.streamingMessage
?.takeIf { it.content.isNotBlank() || it.reasoningText.isNotBlank() }
?.copy(isStreaming = false, finishTimestamp = System.currentTimeMillis())
?.let { partial ->
messages = messages.upsertById(partial)
usageState.currentSessionId?.let { effects += ReducerEffect.PersistMessage(partial, it) }
}
}
effects += ReducerEffect.RefreshSessions
effects += ReducerEffect.RefreshContextUsage
val settled = usageState.copy(messages = messages, isAgentTyping = false, clarifyRequest = null)
return appendGatewayNotice(settled, StreamingState(), text, effects)
}
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
private fun onMessageReaction(
state: ChatUiState,
@@ -1293,6 +1366,11 @@ sealed class ReducerEffect {
data object RefreshContextUsage : ReducerEffect()
/** Drop a never-confirmed local row from Room (the DAO refuses rows that carry a REST identity). */
data class DeleteLocalMessage(
val messageId: String,
) : ReducerEffect()
/**
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
* just-finalized assistant message into real [Attachment]s (images inline,
@@ -0,0 +1,32 @@
package com.m57.hermescontrol.ui.chat
/**
* Gateway turn-outcome sentinels that arrive as assistant text but are notices, not prose (#1433).
*
* Sources in hermes-agent: `agent/repetition_guard.py` (`REPETITION_LOOP_INTERRUPTED`) and the
* `interrupt_text` / `close_interrupted_tool_sequence` exits ("Operation interrupted…"). They have no
* matching visible REST row (or a model-facing closing row), so as assistant prose they never get a
* canonical position and jump to the transcript tail on every merge. They render as system rows instead.
*/
internal object GatewayNotice {
const val REPETITION_LOOP_INTERRUPTED = "[the reply degenerated into a repetition loop and was interrupted]"
// "Operation interrupted." / "Operation interrupted: <reason>." / "Operation interrupted during retry (…)."
private val OPERATION_INTERRUPTED = Regex("""^Operation interrupted(?:\.|(?::| during) [^\n]*\.)$""")
fun isInterruptSentinel(text: String): Boolean {
val trimmed = text.trim()
return trimmed == REPETITION_LOOP_INTERRUPTED || OPERATION_INTERRUPTED.matches(trimmed)
}
}
/**
* The gateway persists an interrupted turn's closing row as `role=assistant` with the sentinel text. Map it
* (REST and Room alike) to the same SYSTEM notice the live path appends, so every copy matches one row.
*/
internal fun ChatMessage.normalizedGatewayNotice(): ChatMessage =
if (role == MessageRole.ASSISTANT && attachments.isNullOrEmpty() && GatewayNotice.isInterruptSentinel(content)) {
copy(role = MessageRole.SYSTEM, content = content.trim(), reasoningText = "", completionId = null)
} else {
this
}
@@ -106,7 +106,7 @@ sealed class SlashResult {
/** Create a new session (client-side immediate). */
data object NewSession : SlashResult()
/** Forward to command.dispatch via WebSocket. */
/** Execute through slash.exec, with command.dispatch as the desktop-compatible fallback. */
data object RpcDispatch : SlashResult()
/** Fork the active conversation via the session.branch WebSocket RPC. */
@@ -25,6 +25,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -58,6 +60,7 @@ import com.m57.hermescontrol.theme.DarkOnSurface
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.launch
@@ -100,18 +103,36 @@ fun UserBubble(
var copied by rememberCopyFeedback()
val statusColors = LocalHermesStatusColors.current
val deliveryState =
when {
pendingSendState == PendingSendState.SENDING -> DeliveryState.SENT
pendingSendState == PendingSendState.ACCEPTED ||
message.canonicalRestId != null || message.serverRowId != null -> DeliveryState.DELIVERED
else -> null
}
// #1432: also protect cached/legacy rows and unexpectedly large plain-string payloads.
// Keep the original content for Copy; only the layout input is bounded.
val displayContent =
remember(message.content, message.attachments) {
// #1432: the path is only hidden when its image renders as an attachment below.
val visible =
if (message.attachments.isNullOrEmpty()) message.content else hideImageRefLines(message.content)
ToolJson.clampForDisplay(visible)
}
val highlightedText =
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
if (searchQuery.isNotBlank()) {
buildHighlightedString(
message.content,
displayContent,
searchQuery,
isCurrentMatch,
statusColors,
)
} else {
AnnotatedString(message.content)
AnnotatedString(displayContent)
}
}
Box(
@@ -153,7 +174,7 @@ fun UserBubble(
color = Color.Transparent,
tonalElevation = 0.dp,
) {
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
val isRtl = remember(displayContent) { BidiUtils.isRtlText(displayContent) }
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
@@ -183,23 +204,6 @@ fun UserBubble(
canSave = canSaveAttachment,
onImageClick = onImageClick,
)
// #1427: keep the live text and media visible with an honest delivery label.
if (pendingSendState == PendingSendState.SENDING ||
pendingSendState == PendingSendState.ACCEPTED
) {
Text(
stringResource(
if (pendingSendState == PendingSendState.SENDING) {
R.string.chat_pending_sending
} else {
R.string.chat_send_accepted
},
),
color = userBubbleTextColor,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.testTag("user_send_status_${message.id}"),
)
}
if (!message.isStreaming) {
FlowRow(
modifier =
@@ -236,6 +240,11 @@ fun UserBubble(
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
DeliveryChecks(
state = deliveryState,
tint = userBubbleTextColor,
messageId = message.id,
)
if (messageStatsEnabled && showUserMessageTokens &&
message.tokenCount != null && message.tokenCount > 0
) {
@@ -268,3 +277,24 @@ fun UserBubble(
}
}
}
/** WhatsApp-style delivery ticks: one dim check while sending, two once the server has the prompt. */
private enum class DeliveryState { SENT, DELIVERED }
@Composable
private fun DeliveryChecks(
state: DeliveryState?,
tint: Color,
messageId: String,
) {
if (state == null) return
val delivered = state == DeliveryState.DELIVERED
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = if (delivered) Icons.Filled.DoneAll else Icons.Filled.Done,
contentDescription =
stringResource(if (delivered) R.string.chat_send_accepted else R.string.chat_pending_sending),
modifier = Modifier.size(14.dp).testTag("user_send_status_$messageId"),
tint = tint.copy(alpha = if (delivered) 1f else 0.6f),
)
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
/**
* Parses user-authored or server-persisted `@image:` directives from message content into
* gateway-backed [Attachment]s.
*
* Requirements:
* - Recognizes whole trimmed lines `@image:/absolute/path` (spaces in path allowed).
* - Ignores inline prose refs (e.g. "look at @image:/path in the middle of a sentence").
* - Ignores empty or relative paths (must start with '/').
* - Preserves unique paths across lines (deduplicates repeated references to the same path).
* - Resolves each path via [mediaUrl]; drops attachments where [mediaUrl] returns null.
* - Constructs [Attachment]s with source = [AttachmentSource.GATEWAY], resolved uri/gatewayUrl,
* size = 0, name = [mediaNameFromPath], mimeType = [mediaMimeForPath].
*/
internal fun userImageAttachments(
content: String,
mediaUrl: (String) -> String?,
): List<Attachment> {
if (!content.contains("@image:")) return emptyList()
val seenPaths = mutableSetOf<String>()
val attachments = mutableListOf<Attachment>()
for (rawLine in content.lines()) {
val line = rawLine.trim()
if (!line.startsWith("@image:")) continue
val path = line.removePrefix("@image:").trim()
if (!path.startsWith("/")) continue
if (!seenPaths.add(path)) continue
val url = mediaUrl(path) ?: continue
attachments.add(
Attachment(
uri = url,
name = mediaNameFromPath(path),
mimeType = mediaMimeForPath(path),
size = 0,
gatewayUrl = url,
source = AttachmentSource.GATEWAY,
),
)
}
return attachments
}
/** Hides whole-line `@image:/abs/path` refs from bubble text once they render as attachments. */
internal fun hideImageRefLines(content: String): String {
if (!content.contains("@image:")) return content
return content
.lines()
.filterNot { line ->
line.trim().let {
it.startsWith("@image:") &&
it.removePrefix("@image:").trim().startsWith("/")
}
}.joinToString("\n")
.trim()
}
@@ -10,8 +10,11 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -32,6 +35,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Close
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -64,8 +71,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
@@ -86,9 +94,9 @@ import com.m57.hermescontrol.util.BidiUtils
* controls row (attach, model/reasoning pill, mic, send) inside it below.
*/
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ChatInputBar(
inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit,
inputState: TextFieldState,
onSend: () -> Unit,
onMicTap: () -> Unit,
isListening: Boolean,
@@ -127,15 +135,18 @@ fun ChatInputBar(
isRecordingVoice: Boolean = false,
amplitudeProvider: () -> Float = { 0f },
onStopGeneration: () -> Unit = {},
receiveContentListener: ReceiveContentListener? = null,
isReceivingContent: Boolean = false,
) {
val inputText = inputState.text.toString()
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
!isReceivingContent && pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
@@ -232,6 +243,16 @@ fun ChatInputBar(
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
) {
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
if (isReceivingContent) {
val loadingLabel = stringResource(R.string.chat_image_paste_loading)
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.testTag("chat_image_paste_progress")
.semantics { contentDescription = loadingLabel },
)
}
// Commands hidden from the suggestion menu — desktop/CLI-only and
// TUI-only commands that don't function on mobile (issue #574).
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
@@ -245,13 +266,13 @@ fun ChatInputBar(
.filter { it.lowercase() !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility(
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
visible = inputText.startsWith("/") && !inputText.contains(" "),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) {
val filteredCommands =
ChatInputPolicy.sortSlashSuggestions(
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
slashUsageCounts,
)
if (filteredCommands.isNotEmpty()) {
@@ -280,7 +301,7 @@ fun ChatInputBar(
color = MaterialTheme.colorScheme.primary,
)
},
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
onClick = { inputState.replaceComposerDraft(cmd) },
)
}
}
@@ -341,7 +362,7 @@ fun ChatInputBar(
stringResource(R.string.chat_input_placeholder_not_connected)
}
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
stringResource(R.string.chat_input_placeholder_queue)
}
@@ -356,9 +377,9 @@ fun ChatInputBar(
val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) {
remember(inputText, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection(
inputFieldValue.text,
inputText,
fallback = ambientLayoutDirection,
)
}
@@ -381,8 +402,7 @@ fun ChatInputBar(
.heightIn(min = 42.dp),
) {
BasicTextField(
value = inputFieldValue,
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
state = inputState,
modifier =
Modifier
.fillMaxWidth()
@@ -392,18 +412,28 @@ fun ChatInputBar(
.focusRequester(inputFocusRequester)
.onFocusChanged { focusState ->
inputFocused = focusState.isFocused
}.testTag("chat_input"),
}.testTag("chat_input")
.then(
if (receiveContentListener != null) {
Modifier.contentReceiver(receiveContentListener)
} else {
Modifier
},
),
enabled = isConnected,
inputTransformation =
InputTransformation {
if (isRecordingVoice) revertAllChanges()
},
textStyle =
MaterialTheme.typography.bodyLarge.copy(
color = palette.text,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
),
singleLine = false,
maxLines = 8,
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
cursorBrush = SolidColor(palette.text),
decorationBox = { innerTextField ->
decorator = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment =
@@ -416,7 +446,7 @@ fun ChatInputBar(
CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
if (inputText.isEmpty() && !isRecordingVoice) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyLarge,
@@ -0,0 +1,31 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.input.TextFieldValue
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
/** Apply an intentional external draft replacement, never a value mirror during ordinary typing. */
internal fun TextFieldState.replaceComposerDraft(text: String) {
val replacement = ChatInputPolicy.commandFieldValue(text)
edit {
replace(0, length, replacement.text)
selection = replacement.selection
}
}
/** Recheck the live draft when a rejected send is returned; it may have changed since dispatch. */
internal fun TextFieldState.restoreRejectedComposerDraft(rejectedText: String) {
val current = TextFieldValue(text.toString(), selection)
val restored = ChatInputPolicy.restoreRejectedText(rejectedText, current)
edit {
replace(0, length, restored.text)
selection = restored.selection
}
}
/** Speech results arrive asynchronously: append to the draft as it exists at result time. */
internal fun TextFieldState.appendSpeechComposerDraft(spokenText: String) {
if (spokenText.isBlank()) return
val current = text.toString()
replaceComposerDraft(if (current.isBlank()) spokenText else "$current $spokenText")
}
@@ -0,0 +1,195 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.TransferableContent
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.MAX_CHAT_ATTACHMENT_BYTES
import com.m57.hermescontrol.ui.chat.PastedImage
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
internal const val MAX_PASTED_IMAGE_COUNT = 8
internal const val MAX_PASTED_IMAGE_BATCH_BYTES = 20L * 1024L * 1024L
private class PastedImageBatchLimitException : IOException("Pasted image batch exceeds its limit")
/** Receives explicit paste/IME commits only; never reads or observes the system clipboard. */
@OptIn(ExperimentalFoundationApi::class)
internal class ChatImagePasteController(
private val scope: CoroutineScope,
private val importImage: suspend (Uri, Long) -> PastedImage?,
private val captureTarget: () -> ChatAttachmentTarget?,
private val commit: (ChatAttachmentTarget, List<Attachment>) -> Boolean,
private val onError: (Int) -> Unit,
private val isImageUri: (Uri) -> Boolean = { false },
) : ReceiveContentListener {
var isReceiving by mutableStateOf(false)
private set
private var closed = false
private var job: Job? = null
private val retainedPayloads = mutableSetOf<Any>()
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
if (!transferableContent.hasMediaType(MediaType.Image)) return transferableContent
// Drag grants have a different lifecycle; this feature handles paste and IME commits only.
if (transferableContent.source != TransferableContent.Source.Clipboard &&
transferableContent.source != TransferableContent.Source.Keyboard
) {
return transferableContent
}
val clip = transferableContent.clipEntry.clipData
val uris = ArrayList<Uri>(MAX_PASTED_IMAGE_COUNT + 1)
for (index in 0 until clip.itemCount) {
val uri = clip.getItemAt(index).uri
if (uri?.scheme == "content" && isImageUri(uri)) {
uris.add(uri)
if (uris.size > MAX_PASTED_IMAGE_COUNT) break
}
}
if (!receiveImages(uris, transferableContent)) return transferableContent
// Only accepted image candidates are consumed. Text and non-image URIs
// remain available to the ordinary text field/other receivers.
val accepted = uris.toSet()
// An item can carry both image URI and text. Keep that item for the text
// field after importing its image; consuming it would drop its text.
return transferableContent.consume { it.uri in accepted && it.text == null && it.htmlText == null }
}
/** [payload] strongly retains Compose's temporary InputContentInfo permission until IO ends. */
internal fun receiveImages(
uris: List<Uri>,
payload: Any,
): Boolean {
if (closed || !scope.isActive || uris.isEmpty()) return false
if (isReceiving) {
onError(R.string.chat_image_paste_busy)
return true
}
if (uris.size > MAX_PASTED_IMAGE_COUNT) {
onError(R.string.chat_image_paste_batch_limit)
return true
}
val target = captureTarget()
if (target == null) {
onError(R.string.chat_session_not_ready)
return true
}
isReceiving = true
retainedPayloads.add(payload)
job =
scope.launch {
val images = mutableListOf<PastedImage>()
var remainingBytes = MAX_PASTED_IMAGE_BATCH_BYTES
var committed = false
try {
for (uri in uris) {
if (remainingBytes == 0L) throw PastedImageBatchLimitException()
val cap = minOf(remainingBytes, MAX_CHAT_ATTACHMENT_BYTES)
val image = importImage(uri, cap) ?: throw IOException("Unreadable pasted image")
images += image
remainingBytes -= image.file.length()
if (remainingBytes < 0L) throw PastedImageBatchLimitException()
}
currentCoroutineContext().ensureActive()
if (!closed && captureTarget() == target) {
committed = commit(target, images.map { it.attachment })
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
if (!closed && captureTarget() == target) {
onError(
if (failure is PastedImageBatchLimitException ||
(failure is PastedImageTooLargeException && remainingBytes < MAX_CHAT_ATTACHMENT_BYTES)
) {
R.string.chat_image_paste_batch_limit
} else if (failure is PastedImageTooLargeException) {
R.string.chat_image_paste_too_large
} else {
R.string.chat_image_paste_failed
},
)
}
} finally {
if (!committed) {
withContext(NonCancellable + Dispatchers.IO) { images.forEach { it.discard() } }
}
retainedPayloads.remove(payload)
isReceiving = false
}
}
// Covers a scope cancelled before the launched coroutine ever starts.
job?.invokeOnCompletion { retainedPayloads.remove(payload) }
return true
}
fun close() {
closed = true
job?.cancel()
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun rememberChatImagePaste(
viewModel: ChatViewModel,
onShowMessage: (String) -> Unit,
): ChatImagePasteController {
val context = LocalContext.current
val resources by rememberUpdatedState(LocalResources.current)
val scope = rememberCoroutineScope()
val showMessage by rememberUpdatedState(onShowMessage)
val controller =
remember(viewModel, context, scope) {
val store = ChatPastedImageStore(context.applicationContext)
ChatImagePasteController(
scope = scope,
importImage = store::importImage,
isImageUri = { uri ->
try {
context.contentResolver.getType(uri)?.let { type ->
type.startsWith("image/", ignoreCase = true) && type.length > "image/".length
} == true
} catch (_: RuntimeException) {
// Provider failures must never consume an unclassified URI.
false
}
},
captureTarget = viewModel::captureAttachmentTarget,
commit = viewModel::addPastedAttachments,
onError = { showMessage(resources.getString(it)) },
)
}
DisposableEffect(controller) { onDispose { controller.close() } }
return controller
}
@@ -11,6 +11,7 @@ import android.speech.RecognizerIntent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
import kotlinx.coroutines.delay
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
@Composable
fun rememberChatMediaLaunchers(
inputFieldValue: TextFieldValue,
onInputFieldValueChange: (TextFieldValue) -> Unit,
inputState: TextFieldState,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List<Attachment>) -> Unit,
onVoiceNoteRecorded: (file: File) -> Unit,
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
var isListening by remember { mutableStateOf(false) }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentInputState by rememberUpdatedState(inputState)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
?.firstOrNull()
.orEmpty()
if (spokenText.isNotBlank()) {
val currentText = currentInputFieldValue.text
val merged =
if (currentText.isBlank()) {
spokenText
} else {
"$currentText $spokenText"
}
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
currentInputState.appendSpeechComposerDraft(spokenText)
}
}
}
@@ -58,6 +58,23 @@ private object FullBleedContentType {
const val SYSTEM_EVENT: String = "system_event"
}
/** Only show the placeholder when there are no messages or renderable live tail items. */
internal fun shouldShowChatEmptyState(
transcript: TranscriptUiState,
hasReplyError: Boolean,
): Boolean =
transcript.messages.isEmpty() &&
transcript.streamingState.streamingMessage == null &&
!transcript.isLoading &&
!transcript.isAgentTyping &&
!hasReplyError &&
transcript.clarifyRequest == null &&
transcript.vaultUnlockPrompt == null &&
transcript.vaultSaveLoginPrompt == null &&
transcript.vaultCodePrompt == null &&
!transcript.isCompressing &&
transcript.compressionStatus == null
/**
* The chat message list for FULL-BLEED style (issue #866) — the single chat
* surface since the bubble renderer was removed. User messages keep their
@@ -105,7 +122,7 @@ fun FullBleedChatList(
val isCompressing = transcript.isCompressing
val compressionStatus = transcript.compressionStatus
val speakingMessageId = transcript.speakingMessageId
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
private const val HIDDEN_TOOL_NAME = "react_to_message"
/** Tool rows that never get a transcript bubble. */
internal fun ChatMessage.isHiddenTool(): Boolean = role == MessageRole.TOOL && toolName == HIDDEN_TOOL_NAME
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
/**
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
agentEntries += AgentEntry.Prose(message)
}
message.isHiddenTool() -> {
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
}
MessageRole.TOOL == message.role -> {
agentEntries += AgentEntry.ToolRow(message)
}