diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt index 2961cbc1..712baa96 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreMigration.kt @@ -55,7 +55,7 @@ class ServerStoreMigration( runCatching { ThemePreference.valueOf(themePrefString!!) } .getOrDefault(ThemePreference.SYSTEM) - val useDynamicColors = prefs.getBoolean("use_dynamic_colors", true) + val useDynamicColors = prefs.getBoolean("use_dynamic_colors", false) val themePresetString = prefs.getString("theme_preset", ThemePreset.DEFAULT.name) val themePreset = diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index daa7a714..2139218c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -13,7 +13,7 @@ data class ServerStoreState( val baseUrl: String? = null, val autoReconnect: Boolean = true, val themePreference: ThemePreference = ThemePreference.SYSTEM, - val useDynamicColors: Boolean = true, + val useDynamicColors: Boolean = false, val themePreset: ThemePreset = ThemePreset.DEFAULT, val connectionProfiles: List = emptyList(), val selectedProfileId: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index a671101c..ac2e6d77 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -68,7 +68,7 @@ object AuthManager { private val _themePreferenceFlow = MutableStateFlow(ThemePreference.SYSTEM) val themePreferenceFlow: StateFlow = _themePreferenceFlow.asStateFlow() - private val _useDynamicColorsFlow = MutableStateFlow(true) + private val _useDynamicColorsFlow = MutableStateFlow(false) val useDynamicColorsFlow: StateFlow = _useDynamicColorsFlow.asStateFlow() private val _themePresetFlow = MutableStateFlow(ThemePreset.DEFAULT) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt index 9c110c13..a1040019 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt @@ -18,4 +18,7 @@ interface ChatMessageDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(messages: List) + + @Query("DELETE FROM chat_messages WHERE session_id = :sessionId") + suspend fun deleteMessagesForSession(sessionId: String) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 3be6c802..a9408f2b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -194,6 +194,7 @@ interface HermesApiService { // legacy backends that don't know the param — they ignore it. @Query("order") order: String? = null, @Query("include_compacted") includeCompacted: Boolean? = true, + @Query("profile") profile: String? = null, ): Response @GET("api/sessions/stats") diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index c85fe7ea..4a19bd98 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -134,17 +134,90 @@ object EventParser { "clarify.request" -> { // Gateway sends "question"/"choices" — fall back to "text"/"options" for any // older client or test that still uses the legacy field names. (Issue #206) - val text = - payload?.get("question") as? String - ?: payload?.get("text") as? String - val rawOptions = - payload?.get("choices") - ?: payload?.get("options") + // Batch clarify (issue #18450): gateway sends "questions" array of {qid, question, choices, multi_select}. + val rawQuestions = payload?.get("questions") as? List<*> val clarifyId = payload?.get("clarify_id") as? String ?: payload?.get("request_id") as? String + val parsedQuestions = + if (rawQuestions != null && rawQuestions.isNotEmpty()) { + rawQuestions.mapIndexedNotNull { index, item -> + val map = item as? Map<*, *> ?: return@mapIndexedNotNull null + val qText = map["question"] as? String ?: return@mapIndexedNotNull null + val qid = map["qid"] as? String ?: "q$index" + + @Suppress("UNCHECKED_CAST") + val qChoices = (map["choices"] as? List<*>)?.filterIsInstance() ?: emptyList() + val qMulti = map["multi_select"] as? Boolean ?: false + WsEvent.ClarifyQuestion( + qid = qid, + question = qText, + choices = qChoices, + multiSelect = qMulti, + ) + } + } else { + val text = + payload?.get("question") as? String + ?: payload?.get("text") as? String + val rawOptions = payload?.get("choices") ?: payload?.get("options") + + @Suppress("UNCHECKED_CAST") + val options = (rawOptions as? List<*>)?.filterIsInstance() ?: emptyList() + val qid = payload?.get("qid") as? String ?: payload?.get("question_id") as? String + val multi = payload?.get("multi_select") as? Boolean ?: false + if ((!text.isNullOrBlank() || options.isNotEmpty()) && qid != null) { + listOf( + WsEvent.ClarifyQuestion( + qid = qid, + question = text.orEmpty(), + choices = options, + multiSelect = multi, + ), + ) + } else { + emptyList() + } + } + + val primary = parsedQuestions.firstOrNull() + val legacyText = + if (parsedQuestions.size > 1) { + parsedQuestions.mapIndexed { index, q -> "${index + 1}. ${q.question}" }.joinToString("\n\n") + } else if (primary != null) { + primary.question + } else { + payload?.get("question") as? String + ?: payload?.get("text") as? String + } + @Suppress("UNCHECKED_CAST") - val options = (rawOptions as? List<*>)?.filterIsInstance() - WsEvent.ClarifyRequest(text, options, clarifyId, sessionId) + val legacyOptions = + primary?.choices + ?: (payload?.get("choices") ?: payload?.get("options")) + .let { (it as? List<*>)?.filterIsInstance() } + val legacyQid = + primary?.qid + ?: payload?.get("qid") as? String + ?: payload?.get("question_id") as? String + val legacyMulti = + primary?.multiSelect + ?: payload?.get("multi_select") as? Boolean + ?: false + + WsEvent.ClarifyRequest( + text = legacyText, + options = legacyOptions, + clarifyId = clarifyId, + sessionId = sessionId, + questionId = legacyQid, + multiSelect = legacyMulti, + questions = parsedQuestions, + ) + } + + "clarify.expire" -> { + val clarifyId = payload?.get("request_id") as? String ?: payload?.get("clarify_id") as? String + WsEvent.ClarifyExpire(clarifyId, sessionId) } "status.update" -> { @@ -210,7 +283,40 @@ object EventParser { @Suppress("UNCHECKED_CAST") val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance() - WsEvent.ApprovalRequest(command, description, patternKeys, sessionId) + val requestId = payload?.get("request_id") as? String + + @Suppress("UNCHECKED_CAST") + val rawChoices = (payload?.get("choices") as? List<*>)?.filterIsInstance() + val allowPermanent = payload?.get("allow_permanent") as? Boolean + val allowSession = payload?.get("allow_session") as? Boolean + val smartDenied = payload?.get("smart_denied") as? Boolean + // Backend default (server.py `_approval_request_payload`): + // smart-denied → once/deny only; else once + session? + always? + deny. + val choices = + rawChoices ?: run { + if (smartDenied == true) { + listOf("once", "deny") + } else { + buildList { + add("once") + if (allowSession != false) { + add("session") + if (allowPermanent != false) add("always") + } + add("deny") + } + } + } + WsEvent.ApprovalRequest( + command, + description, + patternKeys, + sessionId, + requestId, + choices, + allowPermanent, + smartDenied, + ) } "sudo.request" -> { @@ -218,9 +324,21 @@ object EventParser { WsEvent.SudoRequest(requestId, sessionId) } + "sudo.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.SudoExpire(requestId, sessionId) + } + "secret.request" -> { val requestId = payload?.get("request_id") as? String - WsEvent.SecretRequest(requestId, sessionId) + val envVar = payload?.get("env_var") as? String + val prompt = payload?.get("prompt") as? String + WsEvent.SecretRequest(requestId, sessionId, envVar, prompt) + } + + "secret.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.SecretExpire(requestId, sessionId) } else -> { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index a1d9c710..80dde78e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -1226,9 +1226,7 @@ object HermesWsClient { disconnectIfIdleInBackground() } - else -> { - Unit - } + else -> {} } // tryEmit on a DROP_OLDEST flow only returns false when the // buffer is full AND no subscriber is draining; with extraBuffer=512 diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 4b0a4ab3..7f1cff0f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -135,11 +135,26 @@ sealed class WsEvent { // ── Interactive ────────────────────────────────────────────────────── + data class ClarifyQuestion( + val qid: String = "q0", + val question: String = "", + val choices: List = emptyList(), + val multiSelect: Boolean = false, + ) + data class ClarifyRequest( val text: String?, val options: List?, val clarifyId: String? = null, val sessionId: String? = null, + val questionId: String? = null, + val multiSelect: Boolean = false, + val questions: List = emptyList(), + ) : WsEvent() + + data class ClarifyExpire( + val clarifyId: String? = null, + val sessionId: String? = null, ) : WsEvent() /** @@ -215,6 +230,30 @@ sealed class WsEvent { val description: String?, val patternKeys: List?, val sessionId: String?, + /** + * Backend `request_id` — unique across sessions. Sent back as + * `request_id` in `approval.respond` so the gateway resolves the + * EXACT pending instead of FIFO-oldest (desktop parity: + * `prompts.ts` `receiveApprovalRequest` + `approval.tsx` respond). + * Null on legacy payloads → omit, FIFO-compatible. + */ + val requestId: String? = null, + /** + * Backend-advertised choices (e.g. `["once","session","always","deny"]`, + * smart-denied → `["once","deny"]`). Null = legacy → UI falls back + * to Run/Deny. + */ + val choices: List? = null, + /** + * False when the backend won't honor a permanent allow (tirith + * warning) → hide "Always allow" (desktop `allowPermanent` parity). + */ + val allowPermanent: Boolean? = null, + /** + * True when this is an owner override of a Smart DENY — backend + * restricts to once/deny only (desktop `smartDenied` parity). + */ + val smartDenied: Boolean? = null, ) : WsEvent() // ── Sudo / secret requests ───────────────────────────────────────── @@ -229,14 +268,37 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + /** + * Backend sudo timeout (120s `_block`) — clears the matching dialog. + * Payload: `{ request_id }`. Only clears when ids match so a late + * expire for an old prompt never kills the current one (desktop parity). + */ + data class SudoExpire( + val requestId: String?, + val sessionId: String?, + ) : WsEvent() + /** * Backend needs a secret value (password / token) to continue a turn * (desktop: `secret.request` → `secret.respond {request_id, value}`). * Mobile previously dropped this and the agent hung forever. + * `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle, + * body = prompt ?: secretDesc. */ data class SecretRequest( val requestId: String?, val sessionId: String?, + val envVar: String? = null, + val prompt: String? = null, + ) : WsEvent() + + /** + * Backend secret timeout — clears the matching dialog. + * Payload: `{ request_id }`. Match-only clear like [SudoExpire]. + */ + data class SecretExpire( + val requestId: String?, + val sessionId: String?, ) : WsEvent() // ── Gateway-level errors ─────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index dd00f6b5..c8e00fb4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -34,6 +34,12 @@ object WsMethods { const val PROMPT_BTW = "prompt.btw" const val CLARIFY_RESPOND = "clarify.respond" const val APPROVAL_RESPOND = "approval.respond" + + /** Ack that the client rendered a pending approval (desktop parity). */ + const val APPROVAL_RECEIVED = "approval.received" + + /** List unresolved approvals for a session (reconnect replay). */ + const val APPROVAL_PENDING = "approval.pending" const val SUDO_RESPOND = "sudo.respond" const val SECRET_RESPOND = "secret.respond" diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 4e171590..0d3d1503 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -119,6 +119,26 @@ class ChatNotificationService : Service() { showReplyNotification(getString(R.string.notif_clarification_needed), null) } + is WsEvent.SudoRequest -> { + showReplyNotification(getString(R.string.notif_input_needed), null) + } + + is WsEvent.SecretRequest -> { + val body = + event.prompt?.takeIf { it.isNotBlank() } + ?: event.envVar?.takeIf { it.isNotBlank() } + ?: getString(R.string.notif_input_needed) + showReplyNotification(body, null) + } + + is WsEvent.ApprovalRequest -> { + val preview = + event.description?.takeIf { it.isNotBlank() } + ?: event.command?.takeIf { it.isNotBlank() } + ?: getString(R.string.notif_input_needed) + showReplyNotification(preview.take(100), null) + } + else -> {} } } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md index a19be635..73f63da2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -95,8 +95,8 @@ private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) { } ``` -Dynamic (Material You) color on API 31+ overrides the preset scheme when -`useDynamicColors = true`. Semantic status colors are always resolved from the +Dynamic (Material You) color on API 31+ can optionally override the preset scheme when +`useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the active preset via `LocalHermesStatusColors`. ## Adding a new theme diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 5767ec7e..f9ce67ff 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -74,7 +74,7 @@ internal fun resolveStatusColors( @Composable fun HermesControlTheme( themePreference: ThemePreference = LocalThemePreference.current, - useDynamicColors: Boolean = true, + useDynamicColors: Boolean = false, themePreset: ThemePreset = ThemePreset.DEFAULT, chatFontScale: Float = LocalChatFontScale.current, content: @Composable () -> Unit, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt index b6eb32da..95600291 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -12,6 +12,10 @@ data class GroupChatToolCall( val name: String, val summary: String? = null, val isRunning: Boolean = false, + val command: String? = null, + val output: String? = null, + val exitCode: Int? = null, + val isError: Boolean = false, ) /** @@ -28,9 +32,13 @@ data class GroupChatMessage( val isStreaming: Boolean = false, val thread: String? = null, val isSystem: Boolean = false, + val isPass: Boolean = false, val toolCalls: List = emptyList(), ) +const val STOPPED_SYSTEM_TEXT = "Discussion stopped" +const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue" + /** * Pure helper for mention extraction and responder resolution matching Desktop parity. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index fc22d011..948ed979 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -2,7 +2,13 @@ package com.m57.hermescontrol.ui.bots.group import android.content.ClipData import android.text.format.DateFormat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,6 +24,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,12 +38,18 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -44,6 +57,7 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -65,17 +79,23 @@ import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction 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 import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale +import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController @@ -85,6 +105,7 @@ import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.util.BidiUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -254,6 +275,8 @@ fun GroupChatScreen( state.activeSpeaker?.let { speaker -> item(key = "active_speaker") { + val thinkingText = stringResource(R.string.group_chat_thinking, speaker) + val isThinkingRtl = remember(thinkingText) { BidiUtils.isRtlText(thinkingText) } Row( modifier = Modifier.padding(start = 8.dp, top = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -264,8 +287,16 @@ fun GroupChatScreen( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.group_chat_thinking, speaker), - style = MaterialTheme.typography.bodySmall, + text = thinkingText, + style = + MaterialTheme.typography.bodySmall.copy( + textDirection = + if (isThinkingRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), color = MaterialTheme.colorScheme.primary, ) } @@ -283,104 +314,237 @@ fun GroupChatScreen( } } - // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(20.dp), - border = - BorderStroke( - width = if (isFocused) 2.dp else 1.dp, - color = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - }, - ), - color = MaterialTheme.colorScheme.surface, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(modifier = Modifier.weight(1f)) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = - stringResource( - R.string.group_chat_composer_hint, - state.groupName.ifBlank { groupName }, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - BasicTextField( - value = inputFieldValue, - onValueChange = { inputFieldValue = it }, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 24.dp, max = 120.dp) - .onFocusChanged { isFocused = it.isFocused } - .testTag("group_chat_input"), - textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = false, - maxLines = 4, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { handleSend() }), - ) - } + // ── MENTION SUGGESTION CHIP BAR (Horizontal scrollable chips) ── + val mentionQuery = + remember(inputFieldValue.text, inputFieldValue.selection.end) { + ChatInputPolicy.extractMentionQuery( + inputFieldValue.text, + inputFieldValue.selection.end, + ) + } - Spacer(modifier = Modifier.width(4.dp)) + val groupBots = + remember(state.members) { + state.members.distinctBy { it.name.lowercase() } + } - if (state.activeSpeaker != null) { - IconButton( - onClick = { viewModel.stopGeneration() }, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_stop_button"), - ) { - Icon( - imageVector = Icons.Filled.Stop, - contentDescription = stringResource(R.string.group_chat_action_stop), - modifier = Modifier.size(18.dp), - ) - } + val showAllOption = + remember(mentionQuery, groupBots) { + if (mentionQuery == null || groupBots.isEmpty()) { + false } else { - IconButton( - onClick = handleSend, - enabled = inputFieldValue.text.isNotBlank(), - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - modifier = Modifier.size(18.dp), - ) + "all".startsWith(mentionQuery, ignoreCase = true) || + "everyone".startsWith(mentionQuery, ignoreCase = true) + } + } + + val filteredBots = + remember(mentionQuery, groupBots) { + if (mentionQuery == null) { + emptyList() + } else { + groupBots.filter { bot -> + bot.name.startsWith(mentionQuery, ignoreCase = true) || + bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) } } } + + AnimatedVisibility( + visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .testTag("group_chat_mention_row"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showAllOption) { + item(key = "mention_all") { + SuggestionChip( + onClick = { + inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, "all") + }, + label = { + Text( + text = "@all", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + icon = { + Icon( + imageVector = Icons.Filled.Groups, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("mention_chip_all"), + ) + } + } + + items(filteredBots, key = { it.name }) { bot -> + SuggestionChip( + onClick = { + inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, bot.name) + }, + label = { + Text( + text = "@${bot.name}", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelMedium, + ) + }, + icon = { + BotAvatar( + name = bot.name, + avatar = bot.botMeta()?.avatar, + size = 18.dp, + showPresence = false, + ) + }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("mention_chip_${bot.name}"), + ) + } + } + } + + // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl + + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = { inputFieldValue = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .onFocusChanged { isFocused = it.isFocused } + .testTag("group_chat_input"), + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 4, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { handleSend() }), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Surface( + shape = RoundedCornerShape(20.dp), + border = + BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + }, + ), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = + stringResource( + R.string.group_chat_composer_hint, + state.groupName.ifBlank { groupName }, + ), + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f, + ), + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + if (state.activeSpeaker != null) { + IconButton( + onClick = { viewModel.stopGeneration() }, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_stop_button"), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.group_chat_action_stop), + modifier = Modifier.size(18.dp), + ) + } + } else { + IconButton( + onClick = handleSend, + enabled = inputFieldValue.text.isNotBlank(), + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + }, + ) } } } @@ -410,6 +574,11 @@ private fun GroupChatSettingsDialog( var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + var showHighLimitsConfirmation by remember { mutableStateOf(false) } + + val isHighLimit = + maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || + maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES AlertDialog( onDismissRequest = onDismiss, @@ -440,7 +609,12 @@ private fun GroupChatSettingsDialog( Text( text = "${maxMessages.roundToInt()}", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + color = + if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, fontWeight = FontWeight.Bold, ) } @@ -456,8 +630,8 @@ private fun GroupChatSettingsDialog( Slider( value = maxMessages, onValueChange = { maxMessages = it }, - valueRange = 1f..20f, - steps = 18, + valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(), + steps = MAX_ALLOWED_BOT_MESSAGES - 2, modifier = Modifier.testTag("max_messages_slider"), ) } @@ -477,7 +651,12 @@ private fun GroupChatSettingsDialog( Text( text = "${maxPasses.roundToInt()}", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + color = + if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, fontWeight = FontWeight.Bold, ) } @@ -493,12 +672,52 @@ private fun GroupChatSettingsDialog( Slider( value = maxPasses, onValueChange = { maxPasses = it }, - valueRange = 0f..6f, - steps = 5, + valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(), + steps = MAX_ALLOWED_CONTINUATION_PASSES - 1, modifier = Modifier.testTag("max_handoffs_slider"), ) } + // Live In-Dialog Warning Banner (Option C) + AnimatedVisibility( + visible = isHighLimit, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), + modifier = + Modifier + .fillMaxWidth() + .testTag("high_limits_warning_banner"), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Text( + text = + stringResource( + R.string.group_chat_settings_high_limits_warning_desc, + maxMessages.roundToInt(), + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + // Room Instructions / System Prompt Column { Text( @@ -535,11 +754,15 @@ private fun GroupChatSettingsDialog( confirmButton = { Button( onClick = { - onSave( - maxMessages.roundToInt(), - maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, - ) + if (isHighLimit) { + showHighLimitsConfirmation = true + } else { + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + } }, modifier = Modifier.testTag("save_settings_button"), ) { @@ -563,6 +786,65 @@ private fun GroupChatSettingsDialog( } }, ) + + if (showHighLimitsConfirmation) { + AlertDialog( + onDismissRequest = { showHighLimitsConfirmation = false }, + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text( + text = stringResource(R.string.group_chat_settings_high_limits_warning_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + text = + stringResource( + R.string.group_chat_settings_high_limits_warning_desc, + maxMessages.roundToInt(), + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + Button( + onClick = { + showHighLimitsConfirmation = false + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.testTag("confirm_high_limits_button"), + ) { + Text(stringResource(R.string.group_chat_settings_high_limits_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showHighLimitsConfirmation = false }, + modifier = Modifier.testTag("dismiss_high_limits_button"), + ) { + Text(stringResource(R.string.group_chat_settings_high_limits_adjust)) + } + }, + ) + } } @Composable @@ -582,24 +864,70 @@ private fun GroupMessageCard( } if (message.isSystem) { + val displayText = + when { + message.isPass -> { + val name = message.senderDisplayName.ifBlank { message.senderName } + stringResource(R.string.group_chat_bot_passed, name) + } + + message.text == STOPPED_SYSTEM_TEXT -> { + stringResource(R.string.group_chat_stopped_by_user) + } + + message.text == CAPPED_SYSTEM_TEXT -> { + stringResource(R.string.group_chat_capped_limit) + } + + else -> { + message.text + } + } + val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) } + val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current Box( modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = Alignment.Center, ) { Surface( shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), ) { - Text( - text = message.text, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) + CompositionLocalProvider(LocalLayoutDirection provides systemDirection) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (message.isPass) { + Icon( + imageVector = Icons.Filled.FastForward, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + Text( + text = displayText, + style = + MaterialTheme.typography.labelSmall.copy( + textDirection = + if (isSystemRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } else if (message.isUser) { + val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) } + val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -609,52 +937,63 @@ private fun GroupMessageCard( color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth(0.85f), ) { - Column(modifier = Modifier.padding(12.dp)) { - SelectionContainer { - Text( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary, - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.text)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), + CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) { + Column(modifier = Modifier.padding(12.dp)) { + SelectionContainer { + Text( + text = message.text, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = + if (isUserRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, ) } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - ) } } } } } else { + val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) } Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start, @@ -674,7 +1013,15 @@ private fun GroupMessageCard( Column(modifier = Modifier.padding(12.dp)) { Text( text = message.senderDisplayName, - style = MaterialTheme.typography.labelMedium, + style = + MaterialTheme.typography.labelMedium.copy( + textDirection = + if (isBotNameRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) @@ -762,49 +1109,161 @@ private fun GroupChatToolChip( tool: GroupChatToolCall, modifier: Modifier = Modifier, ) { + var expanded by rememberSaveable(tool.id) { mutableStateOf(false) } + val isTerminal = + tool.name.equals("terminal", ignoreCase = true) || + tool.name.equals("execute_code", ignoreCase = true) + Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = modifier, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val icon = - when (tool.name.lowercase()) { - "terminal" -> Icons.Filled.Terminal - "web_search", "search_files" -> Icons.Filled.Search - "read_file", "write_file", "patch" -> Icons.Filled.Description - else -> Icons.Filled.Build - } - Icon( - imageVector = icon, - contentDescription = tool.name, - modifier = Modifier.size(13.dp), - tint = MaterialTheme.colorScheme.primary, - ) - val label = - if (!tool.summary.isNullOrBlank()) { - "${tool.name}: ${tool.summary}" + border = + BorderStroke( + 1.dp, + if (tool.isError) { + MaterialTheme.colorScheme.error.copy(alpha = 0.5f) } else { - tool.name - } - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (tool.isRunning) { - CircularProgressIndicator( - modifier = Modifier.size(10.dp), - strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + }, + ), + modifier = + modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + val icon = + when (tool.name.lowercase()) { + "terminal", "execute_code" -> Icons.Filled.Terminal + "web_search", "search_files" -> Icons.Filled.Search + "read_file", "write_file", "patch" -> Icons.Filled.Description + else -> Icons.Filled.Build + } + Icon( + imageVector = icon, + contentDescription = tool.name, + modifier = Modifier.size(13.dp), + tint = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, ) + val label = + if (!tool.summary.isNullOrBlank()) { + "${tool.name}: ${tool.summary}" + } else { + tool.name + } + val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) } + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (tool.isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!tool.command.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = if (isTerminal) "$ ${tool.command}" else tool.command, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + textDirection = TextDirection.Ltr, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } + + val outputText = + when { + tool.isRunning -> stringResource(R.string.group_chat_tool_running) + !tool.output.isNullOrBlank() -> tool.output.trim() + tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) + else -> stringResource(R.string.group_chat_tool_no_output) + } + val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) } + + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = outputText, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + textDirection = + if (isOutputRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 15, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 4b0157b7..65bc2243 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.toJsonElement +import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -52,11 +53,14 @@ data class MemberSession( const val DEFAULT_MAX_BOT_MESSAGES = 6 const val DEFAULT_MAX_CONTINUATION_PASSES = 2 -private const val LEASE_STEP_TTL_MS = 45_000L -private const val TURN_TIMEOUT_MS = 45_000L +const val MAX_ALLOWED_BOT_MESSAGES = 50 +const val MAX_ALLOWED_CONTINUATION_PASSES = 20 +const val WARN_MAX_BOT_MESSAGES = 20 +const val WARN_MAX_CONTINUATION_PASSES = 6 +private const val LEASE_STEP_TTL_MS = 180_000L +private const val GROUP_TURN_TIMEOUT_MS = 180_000L +private const val GROUP_TURN_HARD_CAP_MS = 20 * 60_000L private const val HISTORY_LIMIT = 10 -private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue" -private const val STOPPED_SYSTEM_TEXT = "Discussion stopped" class GroupChatViewModel( private var groupName: String = "", @@ -84,6 +88,9 @@ class GroupChatViewModel( // Map of session_id -> bot profile private val sessionToBot = mutableMapOf() + // Map of session_id -> last activity epoch ms (streaming token, tool call, thinking) + private val sessionLastActivity = mutableMapOf() + init { if (groupName.isNotBlank()) { loadGroup() @@ -104,10 +111,26 @@ class GroupChatViewModel( inFlightTurns.clear() activeStreamMsgId.clear() sessionToBot.clear() + sessionLastActivity.clear() _uiState.value = GroupChatUiState(groupName = name) loadGroup() } + private fun extractToolCommand(data: Map?): String? { + if (data == null) return null + val cmd = (data["command"] as? String)?.trim() + if (!cmd.isNullOrBlank()) return cmd + val code = (data["code"] as? String)?.trim() + if (!code.isNullOrBlank()) return code + val query = (data["query"] as? String)?.trim() + if (!query.isNullOrBlank()) return query + val path = (data["path"] as? String)?.trim() + if (!path.isNullOrBlank()) return path + val pattern = (data["pattern"] as? String)?.trim() + if (!pattern.isNullOrBlank()) return pattern + return null + } + private fun extractToolSummary(data: Map?): String? { if (data == null) return null val cmd = (data["command"] as? String)?.trim() @@ -121,6 +144,21 @@ class GroupChatViewModel( return null } + private fun extractToolOutput(data: Map?): String? { + if (data == null) return null + val rawStr = + (data["output"] as? String) + ?: (data["stdout"] as? String) + ?: (data["result"] as? String) + if (!rawStr.isNullOrBlank()) { + return rawStr + } + + val resultElement = data["result"]?.toJsonElement() ?: data["output"]?.toJsonElement() ?: data.toJsonElement() + val summary = ToolResultSummary.formatToolResultSummary(resultElement) + return summary.ifBlank { null } + } + private fun observeWsEvents() { viewModelScope.launch(ioDispatcher) { HermesWsClient.events.collect { event -> @@ -128,6 +166,7 @@ class GroupChatViewModel( is WsEvent.MessageToken -> { val sid = event.sessionId?.trim('\"', ' ') if (sid != null) { + sessionLastActivity[sid] = System.currentTimeMillis() val streamId = activeStreamMsgId[sid] val bot = sessionToBot[sid] if (streamId != null && bot != null && event.token.isNotEmpty()) { @@ -163,6 +202,7 @@ class GroupChatViewModel( is WsEvent.ToolStart -> { val sid = event.sessionId?.trim('\"', ' ') if (sid != null) { + sessionLastActivity[sid] = System.currentTimeMillis() val streamId = activeStreamMsgId[sid] val bot = sessionToBot[sid] val toolName = event.name ?: "tool" @@ -170,11 +210,13 @@ class GroupChatViewModel( (event.data?.get("tool_id") as? String)?.ifBlank { null } ?: UUID.randomUUID().toString() val summary = extractToolSummary(event.data) + val command = extractToolCommand(event.data) val toolCall = GroupChatToolCall( id = toolId, name = toolName, summary = summary, + command = command, isRunning = true, ) if (streamId != null && bot != null) { @@ -211,9 +253,21 @@ class GroupChatViewModel( is WsEvent.ToolComplete -> { val sid = event.sessionId?.trim('\"', ' ') if (sid != null) { + sessionLastActivity[sid] = System.currentTimeMillis() val streamId = activeStreamMsgId[sid] val toolId = event.data?.get("tool_id") as? String val toolName = event.name + val output = extractToolOutput(event.data) + val exitCode = + when (val rawCode = event.data?.get("exit_code")) { + is Number -> rawCode.toInt() + is String -> rawCode.toIntOrNull() + else -> null + } + val isError = + (exitCode != null && exitCode != 0) || + (event.data?.get("is_error") as? Boolean == true) || + (event.data?.get("error") != null) if (streamId != null) { _uiState.update { state -> val existing = state.messages.find { it.id == streamId } @@ -224,7 +278,12 @@ class GroupChatViewModel( (toolId == null && tool.name == toolName && tool.isRunning) || (toolId == null && toolName == null && tool.isRunning) ) { - tool.copy(isRunning = false) + tool.copy( + isRunning = false, + output = output ?: tool.output, + exitCode = exitCode ?: tool.exitCode, + isError = isError || tool.isError, + ) } else { tool } @@ -424,8 +483,8 @@ class GroupChatViewModel( maxPasses: Int, systemPrompt: String? = null, ) { - val clampedMessages = maxMessages.coerceIn(1, 20) - val clampedPasses = maxPasses.coerceIn(0, 10) + val clampedMessages = maxMessages.coerceIn(1, MAX_ALLOWED_BOT_MESSAGES) + val clampedPasses = maxPasses.coerceIn(0, MAX_ALLOWED_CONTINUATION_PASSES) val cleanPrompt = systemPrompt?.trim()?.ifBlank { null } _uiState.update { @@ -681,10 +740,34 @@ class GroupChatViewModel( } } - var replyText = - withTimeoutOrNull(TURN_TIMEOUT_MS) { - turnDeferred.await() + var replyText: String? = null + var deadline = turnStartTime + GROUP_TURN_TIMEOUT_MS + val hardCapDeadline = turnStartTime + GROUP_TURN_HARD_CAP_MS + + while (System.currentTimeMillis() < deadline) { + val pollResult = + withTimeoutOrNull(2000L) { + turnDeferred.await() + } + if (pollResult != null) { + replyText = pollResult + break } + if (activeEpoch != epoch) break + + // Adaptive extension: if bot is actively working (tokens/tools streamed in last 60s), extend deadline + val lastActivity = + listOfNotNull( + activeRuntimeId?.let { sessionLastActivity[it] }, + activeStoredId?.let { sessionLastActivity[it] }, + ).maxOrNull() ?: turnStartTime + + val isBusy = (System.currentTimeMillis() - lastActivity) < 60_000L + if (isBusy) { + deadline = + minOf(hardCapDeadline, maxOf(deadline, System.currentTimeMillis() + GROUP_TURN_TIMEOUT_MS)) + } + } inFlightTurns.remove(runtimeId) storedId?.let { inFlightTurns.remove(it) } @@ -698,6 +781,7 @@ class GroupChatViewModel( sessionId = tid, limit = 10, order = "latest", + profile = bot.name, ) } if (res is NetworkResult.Success) { @@ -751,6 +835,28 @@ class GroupChatViewModel( state.copy(messages = updatedList) } return replyText.trim() + } else if (replyText != null && isPass(replyText)) { + _uiState.update { state -> + val existing = state.messages.find { it.id == streamMsgId } + val passMsg = + GroupChatMessage( + id = streamMsgId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + isSystem = true, + isPass = true, + text = "${bot.effectiveTitle} passed", + ) + val updatedList = + if (existing != null) { + state.messages.map { if (it.id == streamMsgId) passMsg else it } + } else { + state.messages + passMsg + } + state.copy(messages = updatedList) + } + return null } else { _uiState.update { state -> state.copy(messages = state.messages.filter { it.id != streamMsgId }) @@ -768,11 +874,13 @@ class GroupChatViewModel( activeStreamMsgId.remove(it) sessionToBot.remove(it) inFlightTurns.remove(it) + sessionLastActivity.remove(it) } activeStoredId?.let { activeStreamMsgId.remove(it) sessionToBot.remove(it) inFlightTurns.remove(it) + sessionLastActivity.remove(it) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 241bd522..779788d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -41,7 +44,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,6 +63,7 @@ import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -65,8 +71,10 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment @@ -75,6 +83,7 @@ import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LightOnSurface import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.onColorFor +import com.m57.hermescontrol.util.BidiUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.Instant @@ -176,67 +185,79 @@ fun ChatBubble( color = Color.Transparent, tonalElevation = 0.dp, ) { - Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { - SelectionContainer { - Text( - text = highlightedText, - color = userBubbleTextColor, - style = MaterialTheme.typography.bodyMedium, - ) - } - // Render inline attachments - if (!message.attachments.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - message.attachments.forEach { attachment -> - InlineAttachment( - attachment = attachment, - textColor = userBubbleTextColor, - onOpen = { onOpenAttachment(it) }, - onSave = { onSaveAttachment(it) }, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, + val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) } + val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + SelectionContainer { + Text( + text = highlightedText, + color = userBubbleTextColor, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = + if (isRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), ) - Spacer(modifier = Modifier.height(4.dp)) } - } - if (!message.isStreaming) { - Row( - modifier = - Modifier - .align(Alignment.End) - .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.content)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), + // Render inline attachments + if (!message.attachments.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + message.attachments.forEach { attachment -> + InlineAttachment( + attachment = attachment, + textColor = userBubbleTextColor, + onOpen = { onOpenAttachment(it) }, + onSave = { onSaveAttachment(it) }, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } + if (!message.isStreaming) { + Row( + modifier = + Modifier + .align(Alignment.End) + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = userBubbleTextColor.copy(alpha = 0.7f), + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.content)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = userBubbleTextColor.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = userBubbleTextColor.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, ) } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = userBubbleTextColor.copy(alpha = 0.6f), - style = MaterialTheme.typography.labelSmall, - ) } } } @@ -304,6 +325,7 @@ private fun SelfImprovementReviewCard( } } +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun SystemBubble( message: ChatMessage, @@ -315,6 +337,9 @@ internal fun SystemBubble( return } + val approvalInfo = message.approvalInfo + var confirmAlways by remember(message.id) { mutableStateOf(false) } + Column( modifier = modifier @@ -331,52 +356,145 @@ internal fun SystemBubble( ), ) - // Approval action buttons - if (message.approvalInfo != null) { + // Approval action buttons — dynamic from backend `choices` + // (desktop `approval.tsx` parity: once/session/always/deny, + // smart-denied → once/deny only, allowPermanent=false hides Always). + if (approvalInfo != null) { Spacer(Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + val rawChoices = approvalInfo.choices ?: listOf("once", "deny") + val visibleChoices = + rawChoices.filter { choice -> + when (choice) { + "always" -> approvalInfo.allowPermanent != false + else -> true + } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - FilledTonalButton( - onClick = { onRespondApproval("approve") }, - modifier = - Modifier - .height(36.dp) - .testTag("approve_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Approve") - } + for (choice in visibleChoices) { + when (choice) { + "deny" -> { + FilledTonalButton( + onClick = { onRespondApproval("deny") }, + modifier = + Modifier + .height(36.dp) + .testTag("deny_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Deny") + } + } - FilledTonalButton( - onClick = { onRespondApproval("deny") }, - modifier = - Modifier - .height(36.dp) - .testTag("deny_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Deny") + "session" -> { + FilledTonalButton( + onClick = { onRespondApproval("session") }, + modifier = + Modifier + .height(36.dp) + .testTag("session_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Allow session") + } + } + + "always" -> { + FilledTonalButton( + onClick = { confirmAlways = true }, + modifier = + Modifier + .height(36.dp) + .testTag("always_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Always allow") + } + } + + else -> { + // "once" (+ legacy "approve" → normalized to once in VM) + FilledTonalButton( + onClick = { onRespondApproval(choice) }, + modifier = + Modifier + .height(36.dp) + .testTag("approve_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(if (choice == "approve") "Approve" else "Run") + } + } + } } } + // "Always allow" persists the pattern permanently — confirm first + // (desktop confirm-modal parity). + if (confirmAlways) { + AlertDialog( + onDismissRequest = { confirmAlways = false }, + title = { Text("Always allow this command?") }, + text = { + Text( + "This persists the pattern permanently so future " + + "matching commands run without asking.", + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmAlways = false + onRespondApproval("always") + }, + ) { + Text("Always allow") + } + }, + dismissButton = { + TextButton(onClick = { confirmAlways = false }) { + Text("Cancel") + } + }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index a437b894..55b49a1f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -5,13 +5,21 @@ import java.util.UUID /** * Metadata for a [ChatMessage] that represents an approval request. - * When present, the UI renders Approve/Deny buttons inline. + * When present, the UI renders approval buttons inline. * Transient — not persisted to SQLite. */ data class ApprovalInfo( val command: String?, val description: String?, val patternKeys: List?, + /** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */ + val requestId: String? = null, + /** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */ + val choices: List? = null, + /** False → hide "Always allow" (tirith warning, desktop parity). */ + val allowPermanent: Boolean? = null, + /** True → Smart DENY override, once/deny only. */ + val smartDenied: Boolean? = null, ) /** @@ -66,6 +74,11 @@ data class ChatMessage( * them as centered timeline chips instead of user bubbles. */ val displayKind: String? = null, + /** + * Timestamp (ms) when the assistant message finished streaming. + * When present, rendered at the bottom beside the copy button. + */ + val finishTimestamp: Long? = null, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt index fc643e97..be0a197a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -33,4 +33,9 @@ open class ChatPersistenceRepository( /** Load cached messages for a session from Room. */ suspend fun loadMessages(sessionId: String): List = dao.getMessagesForSession(sessionId).map { it.toUiModel() } + + /** Clear all cached messages for a session (e.g. after /undo rewind). */ + suspend fun clearMessagesForSession(sessionId: String) { + dao.deleteMessagesForSession(sessionId) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 9e91a1b2..68d4f47f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -289,6 +290,14 @@ fun ChatScreen( var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + + LaunchedEffect(state.pendingPrefillText) { + val prefill = state.pendingPrefillText + if (prefill != null) { + inputFieldValue = ChatInputPolicy.commandFieldValue(prefill) + viewModel.consumePendingPrefill() + } + } var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } @@ -711,6 +720,7 @@ fun ChatScreen( viewModel = viewModel, clarifyRequest = state.clarifyRequest, onRespondClarify = viewModel::respondToClarify, + onRespondClarifyBatch = viewModel::respondToClarifyBatch, onDismissClarify = viewModel::dismissClarify, onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, @@ -806,7 +816,6 @@ fun ChatScreen( isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, - availableBots = state.availableBots, slashUsageCounts = state.slashUsageCounts, pendingAttachments = state.pendingAttachments, onCameraTap = { @@ -911,6 +920,25 @@ fun ChatScreen( ) } + // Expensive / Data-policy model confirmation dialog (issue #589 follow-up) + if (state.modelSwitchConfirmMessage != null) { + AlertDialog( + onDismissRequest = { viewModel.dismissModelSwitchConfirm() }, + title = { Text(stringResource(R.string.model_expensive_title)) }, + text = { Text(state.modelSwitchConfirmMessage!!) }, + confirmButton = { + Button(onClick = { viewModel.confirmModelSwitchExpensive() }) { + Text(stringResource(R.string.model_confirm_continue)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissModelSwitchConfirm() }) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + if (showContextSheet && state.contextBreakdown != null) { ContextDetailSheet( breakdown = state.contextBreakdown!!, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index c96f4924..3c51ef67 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -312,7 +312,6 @@ data class ChatUiState( val typingEffectDelayMs: Int = 30, // Commands catalog val commandCatalog: CommandCatalog = CommandCatalog(), - val availableBots: List = emptyList(), // Per-command usage counts for the slash-autocomplete ranking (issue // #865). Empty until the local store loads; commands without recorded // usage keep their catalog order. @@ -327,6 +326,7 @@ data class ChatUiState( val modelPickerProviders: List = emptyList(), val modelPickerPinned: List = emptyList(), val modelPickerLoading: Boolean = false, + val modelSwitchConfirmMessage: String? = null, // Current session's active model label (provider/model), shown in the chip val currentSessionModel: String? = null, // Per-model reasoning capabilities for the current session's model @@ -362,6 +362,8 @@ data class ChatUiState( // Session resume recovery (desktop parity: bounded auto-retry + error UI) val resumeError: String? = null, val isResumeRetrying: Boolean = false, + /** Text staged to prefill the composer (e.g. from /undo). */ + val pendingPrefillText: String? = null, ) { /** Convenience — derived from [connectionStatus]. */ val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED @@ -375,11 +377,40 @@ data class SessionUi( val depth: Int = 0, ) +data class ClarifyQuestionUi( + val qid: String = "q0", + val question: String = "", + val choices: List = emptyList(), + val multiSelect: Boolean = false, +) + data class ClarifyUi( val text: String, - val options: List, + val options: List = emptyList(), val clarifyId: String? = null, -) + val questionId: String? = null, + val multiSelect: Boolean = false, + val questions: List = emptyList(), +) { + /** + * Normalized list of questions to display. Guarantees at least one question + * entry even for legacy single-question clarify events. + */ + val resolvedQuestions: List + get() = + if (questions.isNotEmpty()) { + questions + } else { + listOf( + ClarifyQuestionUi( + qid = questionId ?: "q0", + question = text, + choices = options, + multiSelect = multiSelect, + ), + ) + } +} /** * State for the context-aware side-question bottom sheet (issue #1015, `/btw`). @@ -413,6 +444,8 @@ data class SudoPromptUi( data class SecretPromptUi( val requestId: String?, val sessionId: String?, + val envVar: String? = null, + val prompt: String? = null, ) /** @@ -462,6 +495,15 @@ class ChatViewModel( private val sessionRequestById = ConcurrentHashMap() private var sessionGeneration = 0L + + private data class ActiveModelSwitch( + val spec: String, + val previousModel: String?, + ) + + private val pendingModelSwitchRequests = ConcurrentHashMap() + private var activeModelSwitchConfirmation: ActiveModelSwitch? = null + private var optimisticPreviousModel: String? = null private var resumeRequestSequence = 0L private var activeResumeRequestSequence = 0L private var hydrationRequestSequence = 0L @@ -788,9 +830,7 @@ class ChatViewModel( streamingController.flushPendingTokens() } - else -> { - Unit - } + else -> {} } // First, let the reducer compute the new state and any effects @@ -898,6 +938,23 @@ class ChatViewModel( // chip stays hidden until the real window lands. viewModelScope.launch { fetchContextUsage(skipRestFallback = true) } } + // Session.info can carry `pending_approval` (reconnect + // reconciliation) — surface it unless already on screen. + val pendingApproval = info["pending_approval"] as? Map<*, *> + if (pendingApproval != null) { + val rid = pendingApproval["request_id"] as? String + val alreadyShown = + rid != null && + _uiState.value.messages.any { it.approvalInfo?.requestId == rid } + if (!alreadyShown) { + handleApprovalRequest( + parseApprovalMap( + pendingApproval, + runtimeSessionId ?: _uiState.value.currentSessionId, + ), + ) + } + } } } @@ -969,10 +1026,18 @@ class ChatViewModel( handleSudoRequest(event) } + is WsEvent.SudoExpire -> { + handleSudoExpire(event) + } + is WsEvent.SecretRequest -> { handleSecretRequest(event) } + is WsEvent.SecretExpire -> { + handleSecretExpire(event) + } + is WsEvent.GatewayError -> { // Reducer already set errorMessage; no extra VM work needed. } @@ -1171,6 +1236,26 @@ class ChatViewModel( val generation = request?.generation ?: sessionGeneration resumedGeneration = generation finishResumeWhenHydrated(generation) + // Reconnect replay: resume payload can carry `pending_approval` + // (server `_session_info_payload`); surface it, then ask for + // the full queue in case more are parked. + val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *> + if (pendingApproval != null) { + val rid = pendingApproval["request_id"] as? String + val alreadyShown = + rid != null && + _uiState.value.messages.any { it.approvalInfo?.requestId == rid } + if (!alreadyShown) { + handleApprovalRequest( + parseApprovalMap( + pendingApproval, + runtimeSessionId ?: sessionId, + ), + ) + } + } + val activeSessionId = runtimeSessionId ?: sessionId + if (activeSessionId != null) replayPendingApproval(activeSessionId) } WsMethods.SESSION_INTERRUPT -> { @@ -1209,6 +1294,29 @@ class ChatViewModel( addSystemMessage("Approval submitted") } } + + WsMethods.APPROVAL_PENDING -> { + handleApprovalPendingResult(result) + } + + WsMethods.CONFIG_SET -> { + val pending = pendingModelSwitchRequests.remove(id) + val map = result as? Map<*, *> ?: return + val key = map["key"] as? String + if (key == "model") { + val confirmRequired = map["confirm_required"] as? Boolean ?: false + if (confirmRequired) { + val confirmMessage = + (map["confirm_message"] as? String) + ?: (map["warning"] as? String) + ?: "This model requires confirmation to switch. Continue?" + activeModelSwitchConfirmation = pending + _uiState.update { + it.copy(modelSwitchConfirmMessage = confirmMessage) + } + } + } + } } } @@ -1242,6 +1350,12 @@ class ChatViewModel( handleSlashCommand(target) } + "prefill" -> { + val message = map["message"] as? String ?: "" + val notice = map["notice"] as? String ?: "" + handlePrefillResult(message, notice) + } + else -> { val output = map["output"] as? String ?: map.toString() addAssistantMessage(output) @@ -1583,6 +1697,11 @@ class ChatViewModel( } } + if (result is SlashResult.Undo) { + handleUndoCommand(result.count) + return + } + // Block desktop/CLI-only + TUI-only commands that don't function on // mobile (issue #576, deliverable #3). These are also hidden from the // suggestion menu, but a user can still type one — intercept it here @@ -1654,6 +1773,10 @@ class ChatViewModel( handleSideQuestionCommand(result.question) } + is SlashResult.Undo -> { + handleUndoCommand(result.count) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -1890,7 +2013,10 @@ class ChatViewModel( * ` --provider --session` * (matching the TUI client's `modelValueForConfigSet`). */ - private fun handleModelSwitch(command: String) { + private fun handleModelSwitch( + command: String, + confirmExpensive: Boolean = false, + ) { val sessionId = runtimeSessionId if (sessionId == null) { addAssistantMessage("No active session. Use `/new` to create one.") @@ -1907,11 +2033,25 @@ class ChatViewModel( } else { command.trim() } + val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel + optimisticPreviousModel = null + val params = + mutableMapOf( + "key" to "model", + "value" to spec, + "session_id" to sessionId, + ) + if (confirmExpensive) { + params["confirm_expensive_model"] = true + } viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.CONFIG_SET, - mapOf("key" to "model", "value" to spec, "session_id" to sessionId), - onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) }, + params, + onSent = { id -> + trackRequest(id, WsMethods.CONFIG_SET) + pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel) + }, ) } } @@ -1951,6 +2091,67 @@ class ChatViewModel( } } + private fun handleUndoCommand(count: String) { + val sessionId = runtimeSessionId + if (sessionId == null) { + addAssistantMessage("No active session to undo.") + return + } + viewModelScope.launch { + slashUsageStore.recordUse("/undo") + } + viewModelScope.launch(ioDispatcher) { + try { + val result = + wsClient + .request( + WsMethods.COMMAND_DISPATCH, + mapOf("name" to "undo", "arg" to count, "session_id" to sessionId), + ).await() + handleDispatchResult(result) + } catch (e: HermesWsClient.HermesRpcException) { + addAssistantMessage(e.message ?: "Failed to undo.") + } catch (e: Exception) { + addAssistantMessage(e.message ?: "Failed to undo.") + } + } + } + + private fun handlePrefillResult( + message: String, + notice: String, + ) { + val storageSessionId = _uiState.value.currentSessionId + if (message.isNotBlank()) { + _uiState.update { it.copy(pendingPrefillText = message) } + } + val feedback = + when { + notice.isNotBlank() && message.isNotBlank() -> "$notice (Rewound to: \"$message\")" + notice.isNotBlank() -> notice + message.isNotBlank() -> "↶ Rewound to: \"$message\"" + else -> "↶ Rewound" + } + if (storageSessionId != null) { + val generation = sessionGeneration + viewModelScope.launch { + withContext(ioDispatcher) { + repo.clearMessagesForSession(storageSessionId) + } + _uiState.update { it.copy(messages = emptyList()) } + loadSessionMessages(storageSessionId, generation) + addSystemMessage(feedback, persist = true) + fetchContextUsage() + } + } else { + addSystemMessage(feedback, persist = false) + } + } + + fun consumePendingPrefill() { + _uiState.update { it.copy(pendingPrefillText = null) } + } + // ── Session management ─────────────────────────────────────────────── fun interruptSession() { @@ -2082,15 +2283,6 @@ class ChatViewModel( WsMethods.COMMANDS_CATALOG, onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) }, ) - // Fetch available bot profiles for @ autocomplete - val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() } - if (profilesResult is NetworkResult.Success) { - val profiles = - profilesResult.data.profiles.orEmpty().filter { - !AuthManager.isProfileHidden(it.name) && it.botMeta()?.hidden != true - } - _uiState.update { it.copy(availableBots = profiles) } - } } } @@ -2266,6 +2458,7 @@ class ChatViewModel( provider: String, model: String, ) { + optimisticPreviousModel = _uiState.value.currentSessionModel _uiState.update { it.copy( showModelPicker = false, @@ -2281,6 +2474,28 @@ class ChatViewModel( handleSlashCommand("/model $model --provider $provider --session") } + fun dismissModelSwitchConfirm() { + val pending = activeModelSwitchConfirmation + activeModelSwitchConfirmation = null + _uiState.update { + it.copy( + modelSwitchConfirmMessage = null, + currentSessionModel = pending?.previousModel ?: it.currentSessionModel, + ) + } + syncCurrentModelCapabilities() + fetchContextUsage() + } + + fun confirmModelSwitchExpensive() { + val pending = activeModelSwitchConfirmation + activeModelSwitchConfirmation = null + _uiState.update { it.copy(modelSwitchConfirmMessage = null) } + if (pending != null) { + handleModelSwitch(pending.spec, confirmExpensive = true) + } + } + /** * Set the reasoning effort level for the current session. * @@ -2529,7 +2744,11 @@ class ChatViewModel( private fun sealStreamingMessageIfAny() { val streaming = _streamingState.value.streamingMessage ?: return if (streaming.content.isBlank() && streaming.reasoningText.isBlank()) return - val finalized = streaming.copy(isStreaming = false) + val finalized = + streaming.copy( + isStreaming = false, + finishTimestamp = System.currentTimeMillis(), + ) _uiState.update { it.copy(messages = (it.messages + finalized).dedupeById()) } val sid = _uiState.value.currentSessionId if (sid != null) { @@ -2576,6 +2795,7 @@ class ChatViewModel( showSessionPicker = false, showModelPicker = false, modelPickerLoading = false, + modelSwitchConfirmMessage = null, currentSessionModel = null, currentModelCapabilities = null, reasoningLevel = null, @@ -2590,6 +2810,7 @@ class ChatViewModel( todos = emptyList(), resumeError = null, isResumeRetrying = false, + pendingPrefillText = null, ) } return generation @@ -3235,39 +3456,107 @@ class ChatViewModel( */ fun dismissClarify() { val sessionId = _uiState.value.currentSessionId ?: return - val clarifyId = _uiState.value.clarifyRequest?.clarifyId + val clarify = _uiState.value.clarifyRequest + val clarifyId = clarify?.clarifyId + // Raw batch questions (non-empty only for true batch payloads). + // Legacy singles keep questions empty and rely on questionId (nullable). + val isBatch = !clarify?.questions.isNullOrEmpty() + val displayQuestions = clarify?.resolvedQuestions.orEmpty() _uiState.update { it.copy(clarifyRequest = null) } addSystemMessage("Clarify dismissed — no answer sent", persist = true) viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "response" to CLARIFY_DISMISS_RESPONSE, - "answer" to CLARIFY_DISMISS_RESPONSE, + if (isBatch) { + // Send dismissal for every question in the batch + for (q in displayQuestions) { + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to CLARIFY_DISMISS_RESPONSE, + "answer" to CLARIFY_DISMISS_RESPONSE, + "question_id" to q.qid, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, + ) + } + } else { + // Legacy: only send question_id when the original payload had one. + val questionId = clarify?.questionId + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to CLARIFY_DISMISS_RESPONSE, + "answer" to CLARIFY_DISMISS_RESPONSE, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + if (questionId != null) { + params["question_id"] = questionId + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, ) - if (clarifyId != null) { - params["clarify_id"] = clarifyId - params["request_id"] = clarifyId } - wsClient.send( - method = WsMethods.CLARIFY_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, - ) } } fun respondToClarify(option: String) { + val clarify = _uiState.value.clarifyRequest + // Only use synthesized qid when this is a true batch or legacy with explicit qid. + val qid = + if (clarify != null && + (clarify.questionId != null || clarify.questions.isNotEmpty()) + ) { + clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId + } else { + null + } + if (qid != null && clarify?.resolvedQuestions?.size == 1) { + respondToClarifyBatch(mapOf(qid to option)) + } else { + respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option) + } + } + + fun respondToClarifyBatch( + answers: Map, + singleFallbackAnswer: String? = null, + ) { val sessionId = _uiState.value.currentSessionId ?: return - val clarifyId = _uiState.value.clarifyRequest?.clarifyId + val clarify = _uiState.value.clarifyRequest + val clarifyId = clarify?.clarifyId + val isBatch = !clarify?.questions.isNullOrEmpty() + val questions = clarify?.resolvedQuestions.orEmpty() _uiState.update { it.copy(clarifyRequest = null) } + val displayContent = + if (questions.size > 1) { + questions + .mapIndexed { index, q -> + val ans = answers[q.qid]?.trim().orEmpty() + "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" + }.joinToString("\n") + } else { + val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty() + loneAns + } + val userMessage = ChatMessage( role = MessageRole.USER, - content = option, + content = displayContent, ) _uiState.update { state -> @@ -3282,21 +3571,49 @@ class ChatViewModel( } viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "response" to option, - "answer" to option, + if (isBatch) { + for (q in questions) { + val ans = answers[q.qid]?.trim().orEmpty() + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to ans, + "answer" to ans, + "question_id" to q.qid, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, + ) + } + } else { + // Legacy single: only include question_id when explicitly present. + val qid = clarify?.questionId + val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty() + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to ans, + "answer" to ans, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + if (qid != null) { + params["question_id"] = qid + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, ) - if (clarifyId != null) { - params["clarify_id"] = clarifyId - params["request_id"] = clarifyId } - wsClient.send( - method = WsMethods.CLARIFY_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, - ) } } @@ -3327,6 +3644,10 @@ class ChatViewModel( command = event.command, description = event.description, patternKeys = event.patternKeys, + requestId = event.requestId, + choices = event.choices, + allowPermanent = event.allowPermanent, + smartDenied = event.smartDenied, ), ) _uiState.update { state -> @@ -3335,12 +3656,107 @@ class ChatViewModel( isAgentTyping = false, ) } + // Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render + // so the backend knows this client holds the prompt. Fire-and-forget. + val requestId = event.requestId + val sessionId = runtimeSessionId ?: event.sessionId ?: _uiState.value.currentSessionId + if (requestId != null && sessionId != null) { + viewModelScope.launch(ioDispatcher) { + runCatching { + wsClient.send( + method = WsMethods.APPROVAL_RECEIVED, + params = + mapOf( + "session_id" to sessionId, + "request_id" to requestId, + ), + ) + } + } + } + } + + /** + * Convert a backend approval map (snake_case, from `approval.pending` or + * `session.info` `pending_approval`) into a typed event. Mirrors + * [EventParser] defaults so replay and live paths agree. + */ + private fun parseApprovalMap( + map: Map<*, *>, + sessionId: String?, + ): WsEvent.ApprovalRequest { + @Suppress("UNCHECKED_CAST") + val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance() + + @Suppress("UNCHECKED_CAST") + val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance() + val allowPermanent = map["allow_permanent"] as? Boolean + val allowSession = map["allow_session"] as? Boolean + val smartDenied = map["smart_denied"] as? Boolean + val choices = + rawChoices ?: run { + if (smartDenied == true) { + listOf("once", "deny") + } else { + buildList { + add("once") + if (allowSession != false) { + add("session") + if (allowPermanent != false) add("always") + } + add("deny") + } + } + } + return WsEvent.ApprovalRequest( + command = map["command"] as? String, + description = map["description"] as? String, + patternKeys = patternKeys, + sessionId = sessionId, + requestId = map["request_id"] as? String, + choices = choices, + allowPermanent = allowPermanent, + smartDenied = smartDenied, + ) + } + + /** + * Reconnect replay (desktop `replayPendingApproval` parity): ask the + * gateway for unresolved approvals and surface the oldest one. Called + * after resume and after each respond (the queue can hold more). + */ + private fun replayPendingApproval(sessionId: String) { + val targetSessionId = runtimeSessionId ?: sessionId + viewModelScope.launch(ioDispatcher) { + wsClient.send( + method = WsMethods.APPROVAL_PENDING, + params = mapOf("session_id" to targetSessionId), + onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }, + ) + } + } + + private fun handleApprovalPendingResult(result: Any?) { + @Suppress("UNCHECKED_CAST") + val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> + val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return + val requestId = first["request_id"] as? String ?: return + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + // Don't duplicate a prompt already on screen. + val alreadyShown = + _uiState.value.messages.any { it.approvalInfo?.requestId == requestId } + if (alreadyShown) return + handleApprovalRequest(parseApprovalMap(first, sessionId)) } fun respondToApproval(action: String) { val state = _uiState.value val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return - val sessionId = state.currentSessionId ?: return + val sessionId = runtimeSessionId ?: state.currentSessionId ?: return + // Desktop sends `once` for a single run; legacy mobile sent `approve` + // (any non-deny still unblocks, but stay on-spec going forward). + val choice = if (action == "approve") "once" else action + val requestId = approvalMsg.approvalInfo?.requestId // Clear buttons immediately _uiState.update { s -> @@ -3357,16 +3773,20 @@ class ChatViewModel( } viewModelScope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "choice" to choice, + "all" to false, + ) + if (requestId != null) params["request_id"] = requestId wsClient.send( method = WsMethods.APPROVAL_RESPOND, - params = - mapOf( - "session_id" to sessionId, - "choice" to action, - "all" to false, - ), + params = params, onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }, ) + // The queue can hold more pendings — surface the next one. + replayPendingApproval(sessionId) } } @@ -3385,6 +3805,23 @@ class ChatViewModel( } } + /** + * Backend sudo timeout (120s) — clear only the matching dialog so a + * late expire for an old prompt never kills the current one. + * Desktop parity: `patchOverlayState(prev => prev.sudo?.requestId === id ? null : prev)`. + */ + private fun handleSudoExpire(event: WsEvent.SudoExpire) { + _uiState.update { state -> + val current = state.sudoPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(sudoPrompt = null) + } + } + /** * The agent needs a secret value (token/password). Previously dropped → * agent hung forever. Now we surface a secure dialog and reply via @@ -3393,18 +3830,82 @@ class ChatViewModel( private fun handleSecretRequest(event: WsEvent.SecretRequest) { _uiState.update { it.copy( - secretPrompt = SecretPromptUi(event.requestId, event.sessionId), + secretPrompt = + SecretPromptUi( + event.requestId, + event.sessionId, + event.envVar, + event.prompt, + ), isAgentTyping = false, ) } } - fun dismissSudo() { - _uiState.update { it.copy(sudoPrompt = null) } + /** + * Backend secret timeout — match-only clear like [handleSudoExpire]. + */ + private fun handleSecretExpire(event: WsEvent.SecretExpire) { + _uiState.update { state -> + val current = state.secretPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(secretPrompt = null) + } } + /** + * Cancel → send empty password (desktop parity: + * `prompt-overlays.tsx` `send('')`). Backend treats empty sudo as + * failed sudo (no command runs), so closing the dialog is a safe + * refusal that unblocks the turn instantly instead of hanging 120s + * until `sudo.expire`. + */ + fun dismissSudo() { + val prompt = _uiState.value.sudoPrompt ?: return + val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId + _uiState.update { it.copy(sudoPrompt = null) } + if (sessionId == null) return + viewModelScope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsClient.send( + method = WsMethods.SUDO_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }, + ) + } + } + + /** + * Cancel → send empty value (desktop parity). Backend `secret_cb` + * returns `skipped=True` on empty, unblocking the turn instantly. + */ fun dismissSecret() { + val prompt = _uiState.value.secretPrompt ?: return + val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId _uiState.update { it.copy(secretPrompt = null) } + if (sessionId == null) return + viewModelScope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsClient.send( + method = WsMethods.SECRET_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }, + ) + } } /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 1ce569d0..d4f84abe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -67,6 +67,7 @@ object ChatWsEventReducer { is WsEvent.ToolComplete -> event.sessionId is WsEvent.ToolOutputRisk -> event.sessionId is WsEvent.ClarifyRequest -> event.sessionId + is WsEvent.ClarifyExpire -> event.sessionId is WsEvent.ToolProgress -> event.sessionId is WsEvent.ToolGenerating -> event.sessionId is WsEvent.SubagentEvent -> event.sessionId @@ -128,6 +129,8 @@ object ChatWsEventReducer { is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event) + is WsEvent.ClarifyExpire -> onClarifyExpire(state, streamingState, event) + is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event) is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event) @@ -160,8 +163,12 @@ object ChatWsEventReducer { // SudoRequest / SecretRequest are handled by the ViewModel (issue #524) is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SudoExpire -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState) + // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) @@ -206,7 +213,11 @@ object ChatWsEventReducer { var orphan: ChatMessage? = null val preState = if (streamingState.streamingMessage?.content?.isNotEmpty() == true) { - val finalized = streamingState.streamingMessage.copy(isStreaming = false) + val finalized = + streamingState.streamingMessage.copy( + isStreaming = false, + finishTimestamp = System.currentTimeMillis(), + ) orphan = finalized state.copy( messages = state.messages.upsertById(finalized), @@ -369,10 +380,12 @@ object ChatWsEventReducer { content = text, isStreaming = false, reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = text, reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), ) val effects = mutableListOf() val sid = state.currentSessionId @@ -418,7 +431,12 @@ object ChatWsEventReducer { } else { streaming.reasoningText } - val msg = streaming.copy(isStreaming = false, reasoningText = reasoning) + val msg = + streaming.copy( + isStreaming = false, + reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), + ) val effects = mutableListOf() val sid = state.currentSessionId if (sid != null) { @@ -481,6 +499,7 @@ object ChatWsEventReducer { streamingState.streamingMessage.copy( isStreaming = false, reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), ) orphanToPersist = finalized state.copy( @@ -624,8 +643,25 @@ object ChatWsEventReducer { state: ChatUiState, streamingState: StreamingState, event: WsEvent.ClarifyRequest, - ): ReducerResult = - ReducerResult( + ): ReducerResult { + val questions = + if (event.questions.isNotEmpty()) { + event.questions.map { + ClarifyQuestionUi( + qid = it.qid, + question = it.question, + choices = it.choices, + multiSelect = it.multiSelect, + ) + } + } else { + // Legacy single-question payload: keep questions empty so the wire + // layer can tell legacy apart from a true batch. UI synthesizes + // via ClarifyUi.resolvedQuestions; wire uses text/options/questionId. + emptyList() + } + + return ReducerResult( state = state.copy( clarifyRequest = @@ -633,10 +669,28 @@ object ChatWsEventReducer { text = event.text.orEmpty(), options = event.options.orEmpty(), clarifyId = event.clarifyId, + questionId = event.questionId, + multiSelect = event.multiSelect, + questions = questions, ), isAgentTyping = false, ), ) + } + + private fun onClarifyExpire( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.ClarifyExpire, + ): ReducerResult = + if (event.clarifyId == null || state.clarifyRequest?.clarifyId == event.clarifyId) { + ReducerResult( + state = state.copy(clarifyRequest = null), + streamingState = streamingState, + ) + } else { + ReducerResult(state = state, streamingState = streamingState) + } // ── ReviewSummary (Self-improvement background review) ────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index d11f5c2c..71d8c628 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -44,7 +46,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.hrm.latex.renderer.LatexAutoWrap @@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.util.BidiUtils private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val TABLE_COL_WIDTH = 160.dp @@ -66,6 +71,8 @@ private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""") private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""") private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""") +private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr + /** * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming. * While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap @@ -89,12 +96,19 @@ fun MarkdownText( val statusColors = LocalHermesStatusColors.current val highlights = searchHighlightColors(statusColors) if (isStreaming) { - Text( - text = text, - color = textColor, - style = MaterialTheme.typography.bodyMedium, - modifier = modifier, - ) + val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) { + Text( + text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text, + color = textColor, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + modifier = modifier, + ) + } return } @@ -143,154 +157,191 @@ fun MarkdownText( 5 -> 15.sp else -> 14.sp } - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium - .copy(fontSize = fontSize, fontWeight = FontWeight.Bold), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.padding(vertical = 2.dp), - ) - } - - is MdBlock.Bullet -> { - val indent = (block.level * 16).dp - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = indent) - .padding(vertical = 1.dp), - verticalAlignment = Alignment.Top, - ) { - val bulletChar = - when (block.level % 3) { - 0 -> "•" - 1 -> "◦" - else -> "▪" - } - Text( - text = bulletChar, - color = textColor, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 6.dp), - ) + val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { MarkdownInlineText( text = block.text, textColor = textColor, latexMeasurer = latexMeasurer, - style = MaterialTheme.typography.bodyMedium, + style = + MaterialTheme.typography.bodyMedium + .copy( + fontSize = fontSize, + fontWeight = FontWeight.Bold, + textDirection = bidiTextDirection(isRtl), + ), searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, linkColor = linkColor, highlights = highlights, - modifier = Modifier.weight(1f), + modifier = Modifier.padding(vertical = 2.dp), ) } } + is MdBlock.Bullet -> { + val indent = (block.level * 16).dp + val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = indent) + .padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + val bulletChar = + when (block.level % 3) { + 0 -> "•" + 1 -> "◦" + else -> "▪" + } + Text( + text = bulletChar, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 6.dp), + ) + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } + } + } + is MdBlock.Task -> { val indent = (block.level * 16).dp - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = indent) - .padding(vertical = 1.dp), - verticalAlignment = Alignment.Top, - ) { - Icon( - imageVector = - if (block.checked) { - Icons.Outlined.CheckBox - } else { - Icons.Outlined.CheckBoxOutlineBlank - }, - contentDescription = null, - tint = - if (block.checked) { - MaterialTheme.colorScheme.primary - } else { - textColor.copy( - alpha = 0.6f, - ) - }, - modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp), - ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = MaterialTheme.typography.bodyMedium, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = indent) + .padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = + if (block.checked) { + Icons.Outlined.CheckBox + } else { + Icons.Outlined.CheckBoxOutlineBlank + }, + contentDescription = null, + tint = + if (block.checked) { + MaterialTheme.colorScheme.primary + } else { + textColor.copy( + alpha = 0.6f, + ) + }, + modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp), + ) + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } } } is MdBlock.Ordered -> { val indent = (block.level * 16).dp - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = indent) - .padding(vertical = 1.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - text = "${block.index}.", - color = textColor, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 6.dp), - ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = MaterialTheme.typography.bodyMedium, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = indent) + .padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "${block.index}.", + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 6.dp), + ) + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } } } is MdBlock.Quote -> { - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .width(3.dp) - .fillMaxHeight() - .clip(RoundedCornerShape(2.dp)) - .background(textColor.copy(alpha = 0.35f)), - ) - Spacer(modifier = Modifier.width(8.dp)) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(3.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(2.dp)) + .background(textColor.copy(alpha = 0.35f)), + ) + Spacer(modifier = Modifier.width(8.dp)) + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic, + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } } } @@ -347,22 +398,41 @@ fun MarkdownText( } is MdBlock.DefList -> { - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - block.items.forEach { item -> - Text( - text = item.term, - color = textColor, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), - ) - item.definitions.forEach { def -> + val isRtl = + remember(block.items) { + block.items.any { item -> + BidiUtils.isRtlText(item.term) || + item.definitions.any { BidiUtils.isRtlText(it) } + } + } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + block.items.forEach { item -> + val itemRtl = BidiUtils.isRtlText(item.term) Text( - text = def, + text = if (itemRtl) BidiUtils.anchorTrailingRtl(item.term) else item.term, color = textColor, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 16.dp, bottom = 2.dp), + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + textDirection = bidiTextDirection(itemRtl), + ), ) + item.definitions.forEach { def -> + val defRtl = BidiUtils.isRtlText(def) + Text( + text = if (defRtl) BidiUtils.anchorTrailingRtl(def) else def, + color = textColor, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(defRtl), + ), + modifier = Modifier.padding(start = 16.dp, bottom = 2.dp), + ) + } + Spacer(modifier = Modifier.height(2.dp)) } - Spacer(modifier = Modifier.height(2.dp)) } } } @@ -384,40 +454,54 @@ fun MarkdownText( modifier = Modifier.padding(bottom = 2.dp), ) block.notes.forEach { note -> - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { - Text( - text = "[${note.id}] ", - color = textColor, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold), - ) - MarkdownInlineText( - text = note.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = MaterialTheme.typography.bodySmall, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + val isRtl = remember(note.text) { BidiUtils.isRtlText(note.text) } + val noteDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides noteDirection) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { + Text( + text = "[${note.id}] ", + color = textColor, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold), + ) + MarkdownInlineText( + text = note.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodySmall.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } } } } } is MdBlock.Paragraph -> { - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = MaterialTheme.typography.bodyMedium, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - ) + val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } + val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + ) + } } } } @@ -462,63 +546,85 @@ private fun MarkdownInlineText( highlights: SearchHighlightColors, modifier: Modifier = Modifier, ) { - val segments = remember(text) { splitInlineMath(text) } - if (segments.none { it is InlineMathSegment.Math }) { - Text( - text = - remember(text, searchQuery, isCurrentMatch, textColor, linkColor) { - parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights) - }, - color = textColor, - style = style, - modifier = modifier, + val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val resolvedStyle = + style.copy( + textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, ) - return - } - - val config = - LatexConfig( - fontSize = style.fontSize, - theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), - accessibilityEnabled = true, - ) - val inlineContent = mutableMapOf() - segments.forEachIndexed { index, segment -> - if (segment is InlineMathSegment.Math) { - latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + val processedText = + remember(text, isRtl) { + if (isRtl) BidiUtils.anchorTrailingRtl(text) else text } - } - val annotated = - buildAnnotatedString { - segments.forEachIndexed { index, segment -> - when (segment) { - is InlineMathSegment.Math -> { - val id = "latex-$index" - if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) - } - is InlineMathSegment.Text -> { - append( - parseInlineSource( - segment.value, - textColor, - searchQuery, - isCurrentMatch, - linkColor, - highlights, - ), + CompositionLocalProvider(LocalLayoutDirection provides direction) { + val segments = remember(processedText) { splitInlineMath(processedText) } + if (segments.none { it is InlineMathSegment.Math }) { + Text( + text = + remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { + parseInlineSource( + text = processedText, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, ) + }, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + return@CompositionLocalProvider + } + + val config = + LatexConfig( + fontSize = style.fontSize, + theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), + accessibilityEnabled = true, + ) + val inlineContent = mutableMapOf() + segments.forEachIndexed { index, segment -> + if (segment is InlineMathSegment.Math) { + latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + } + } + val annotated = + buildAnnotatedString { + segments.forEachIndexed { index, segment -> + when (segment) { + is InlineMathSegment.Math -> { + val id = "latex-$index" + if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) + } + + is InlineMathSegment.Text -> { + append( + parseInlineSource( + text = segment.value, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ), + ) + } } } } - } - Text( - text = annotated, - inlineContent = inlineContent, - color = textColor, - style = style, - modifier = modifier, - ) + Text( + text = annotated, + inlineContent = inlineContent, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + } } @Composable @@ -526,40 +632,35 @@ private fun MarkdownTable( block: MdBlock.Table, textColor: Color, ) { + val isRtl = + remember(block) { + block.header.any { BidiUtils.isRtlText(it) } || + block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } + } + val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val headerBg = textColor.copy(alpha = 0.08f) val alignments = block.alignments - Column( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(vertical = 4.dp), - ) { - // Header row - Row(modifier = Modifier.background(headerBg)) { - block.header.forEachIndexed { idx, cell -> - Text( - text = cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.25f)) - // Body rows - block.rows.forEach { row -> - Row { - row.forEachIndexed { idx, cell -> + CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { + Column( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + ) { + // Header row + Row(modifier = Modifier.background(headerBg)) { + block.header.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) Text( - text = cell, + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, textAlign = tableTextAlign(alignments.getOrNull(idx)), color = textColor, - style = MaterialTheme.typography.bodySmall, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + textDirection = bidiTextDirection(cellRtl), + ), modifier = Modifier .width(TABLE_COL_WIDTH) @@ -567,7 +668,29 @@ private fun MarkdownTable( ) } } - HorizontalDivider(color = textColor.copy(alpha = 0.1f)) + HorizontalDivider(color = textColor.copy(alpha = 0.25f)) + // Body rows + block.rows.forEach { row -> + Row { + row.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + textDirection = bidiTextDirection(cellRtl), + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.1f)) + } } } } @@ -1116,6 +1239,7 @@ internal fun parseInline( isCurrentMatch: Boolean, linkColor: Color, highlights: SearchHighlightColors, + isRtl: Boolean = BidiUtils.isRtlText(text), ): AnnotatedString = parseInlineSource( text = @@ -1130,6 +1254,7 @@ internal fun parseInline( isCurrentMatch = isCurrentMatch, linkColor = linkColor, highlights = highlights, + isRtl = isRtl, ) private fun parseInlineSource( @@ -1139,6 +1264,7 @@ private fun parseInlineSource( isCurrentMatch: Boolean, linkColor: Color, highlights: SearchHighlightColors, + isRtl: Boolean = BidiUtils.isRtlText(text), ): AnnotatedString { val searchHighlightColor = if (isCurrentMatch) { @@ -1156,8 +1282,10 @@ private fun parseInlineSource( src.startsWith("***", i) -> { val end = src.indexOf("***", i + 3) if (end != -1) { + val raw = src.substring(i + 3, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { - append(src.substring(i + 3, end)) + append(toAppend) } i = end + 3 } else { @@ -1170,8 +1298,10 @@ private fun parseInlineSource( src.startsWith("**", i) -> { val end = src.indexOf("**", i + 2) if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(src.substring(i + 2, end)) + append(toAppend) } i = end + 2 } else { @@ -1184,8 +1314,10 @@ private fun parseInlineSource( src.startsWith("~~", i) -> { val end = src.indexOf("~~", i + 2) if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(src.substring(i + 2, end)) + append(toAppend) } i = end + 2 } else { @@ -1198,8 +1330,10 @@ private fun parseInlineSource( src.startsWith("*", i) -> { val end = src.indexOf('*', i + 1) if (end != -1 && end > i + 1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(src.substring(i + 1, end)) + append(toAppend) } i = end + 1 } else { @@ -1212,8 +1346,10 @@ private fun parseInlineSource( src.startsWith("==", i) -> { val end = src.indexOf("==", i + 2) if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(background = highlights.markupBackground)) { - append(src.substring(i + 2, end)) + append(toAppend) } i = end + 2 } else { @@ -1254,13 +1390,15 @@ private fun parseInlineSource( src.startsWith("", i) -> { val end = src.indexOf("", i) if (end != -1) { + val raw = src.substring(i + 5, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle( SpanStyle( fontFamily = FontFamily.Monospace, background = textColor.copy(alpha = 0.12f), ), ) { - append(src.substring(i + 5, end)) + append(toAppend) } i = end + 6 } else { @@ -1297,6 +1435,7 @@ private fun parseInlineSource( val urlEnd = src.indexOf(')', close + 2) if (urlEnd != -1) { val label = src.substring(i + 1, close) + val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label val url = src.substring(close + 2, urlEnd) pushLink(LinkAnnotation.Url(url)) withStyle( @@ -1305,7 +1444,7 @@ private fun parseInlineSource( textDecoration = TextDecoration.Underline, ), ) { - append(label) + append(labelToAppend) } pop() i = urlEnd + 1 @@ -1323,6 +1462,8 @@ private fun parseInlineSource( src.startsWith("`", i) -> { val end = src.indexOf('`', i + 1) if (end != -1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle( SpanStyle( fontFamily = FontFamily.Monospace, @@ -1330,7 +1471,7 @@ private fun parseInlineSource( background = textColor.copy(alpha = 0.08f), ), ) { - append(src.substring(i + 1, end)) + append(toAppend) } i = end + 1 } else { @@ -1343,6 +1484,7 @@ private fun parseInlineSource( URL_PATTERN.matchAt(src, i) != null -> { val match = URL_PATTERN.matchAt(src, i)!! val url = match.value + val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url pushLink(LinkAnnotation.Url(url)) withStyle( SpanStyle( @@ -1350,12 +1492,40 @@ private fun parseInlineSource( textDecoration = TextDecoration.Underline, ), ) { - append(url) + append(urlToAppend) } pop() i = match.range.last + 1 } + // Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate + // so an English word at the start/middle of an Arabic line doesn't flip the layout direction. + isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && + Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + var end = i + Character.charCount(src.codePointAt(i)) + while (end < src.length) { + val cp = src.codePointAt(end) + val dir = Character.getDirectionality(cp) + if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || + ( + dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && + Character.getDirectionality(src.codePointAt(end + 1)) == + Character.DIRECTIONALITY_LEFT_TO_RIGHT + ) + ) { + end += Character.charCount(cp) + } else { + break + } + } + val ltrSnippet = src.substring(i, end) + append(BidiUtils.wrapLtrIsolate(ltrSnippet)) + i = end + } + // search highlight searchQuery.isNotEmpty() && src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 8cef91ab..f96d78a7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -66,6 +66,11 @@ class SlashCommandDispatcher { SlashResult.SideQuestion(question = arg) } + "/undo" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.Undo(count = arg) + } + else -> { SlashResult.RpcDispatch } @@ -143,4 +148,12 @@ sealed class SlashResult { data class SideQuestion( val question: String, ) : SlashResult() + + /** + * Rewind conversation turns via the `undo` command.dispatch RPC. + * Rewinds server transcript, reloads active messages, and prefills the composer. + */ + data class Undo( + val count: String, + ) : SlashResult() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index e018f609..7e02eb86 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,11 +54,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource 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 +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage @@ -68,6 +73,7 @@ import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.common.BotAvatar +import com.m57.hermescontrol.util.BidiUtils /** * The chat input bar with a two-row layout: input+send on top, @@ -85,7 +91,6 @@ fun ChatInputBar( commandCatalog: CommandCatalog, slashUsageCounts: Map = emptyMap(), pendingAttachments: List = emptyList(), - availableBots: List = emptyList(), onCameraTap: () -> Unit = {}, onImageTap: () -> Unit = {}, onFileTap: () -> Unit = {}, @@ -184,90 +189,6 @@ fun ChatInputBar( } } - // Bot mention @ suggestions - val mentionQuery = - remember(inputFieldValue.text, inputFieldValue.selection.end) { - ChatInputPolicy.extractMentionQuery( - inputFieldValue.text, - inputFieldValue.selection.end, - ) - } - - androidx.compose.animation.AnimatedVisibility( - visible = mentionQuery != null && availableBots.isNotEmpty(), - enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), - exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), - ) { - val filteredBots = - remember(mentionQuery, availableBots) { - if (mentionQuery == null) { - emptyList() - } else { - availableBots.filter { bot -> - bot.name.startsWith(mentionQuery, ignoreCase = true) || - bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) - } - } - } - - if (filteredBots.isNotEmpty()) { - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ), - ) { - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), - ) { - items(filteredBots, key = { it.name }) { bot -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { - onInputChange( - ChatInputPolicy.applyMention(inputFieldValue, bot.name), - ) - }.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - BotAvatar( - name = bot.name, - avatar = bot.botMeta()?.avatar, - size = 28.dp, - showPresence = false, - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = "@${bot.name}", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - if (bot.effectiveTitle != bot.name) { - Text( - text = bot.effectiveTitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } - } - } - // Attachment preview chips AnimatedVisibility( visible = pendingAttachments.isNotEmpty(), @@ -318,91 +239,129 @@ fun ChatInputBar( } } - BasicTextField( - value = inputFieldValue, - onValueChange = onInputChange, - modifier = - Modifier - .weight(1f) - .heightIn(min = 42.dp, max = 120.dp) - .padding(vertical = 4.dp) - .onFocusChanged { isFocused = it.isFocused } - .testTag("chat_input"), - enabled = isConnected, - textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = false, - maxLines = 4, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - decorationBox = { innerTextField -> - Surface( - shape = RoundedCornerShape(18.dp), - border = - BorderStroke( - width = if (isFocused) 2.dp else 1.dp, - color = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - }, - ), - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(modifier = Modifier.weight(1f)) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - innerTextField() - } + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - // Send button INSIDE the field. Shown whenever a send is - // possible — text typed OR an attachment pending (issue - // #956): the old text-only gate hid the button entirely - // for attachment-only sends. - AnimatedContent( - targetState = canSend, - transitionSpec = { - (scaleIn(initialScale = 0.8f) + fadeIn()) - .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) - }, - label = "send_toggle", - ) { showSend -> - if (showSend) { - IconButton( - onClick = onSend, - enabled = canSend, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("send_button"), + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = onInputChange, + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp, max = 120.dp) + .padding(vertical = 4.dp) + .onFocusChanged { isFocused = it.isFocused } + .testTag("chat_input"), + enabled = isConnected, + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 4, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Surface( + shape = RoundedCornerShape(18.dp), + border = + BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + }, + ), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), - ) + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyMedium, + textAlign = + if (isInputRtl) { + TextAlign.Right + } else { + TextAlign.Left + }, + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() + } + } + + // Send button INSIDE the field. Shown whenever a send is + // possible — text typed OR an attachment pending (issue + // #956): the old text-only gate hid the button entirely + // for attachment-only sends. + AnimatedContent( + targetState = canSend, + transitionSpec = { + (scaleIn(initialScale = 0.8f) + fadeIn()) + .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) + }, + label = "send_toggle", + ) { showSend -> + if (showSend) { + IconButton( + onClick = onSend, + enabled = canSend, + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = + stringResource( + R.string.chat_send_desc, + ), + ) + } + } } } } } - } - }, - ) + }, + ) + } } // ── BOTTOM ROW: Toolbar ── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt index 0d3dad90..e7fdd9fb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt @@ -87,20 +87,25 @@ fun SudoPromptDialog( fun SecretPromptDialog( onConfirm: (String) -> Unit, onDismiss: () -> Unit, + envVar: String? = null, + prompt: String? = null, ) { var secret by remember { mutableStateOf("") } + val titleText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_title) + val bodyText = prompt?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_body) + val labelText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_value) AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.chat_secret_title)) }, + title = { Text(titleText) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = stringResource(R.string.chat_secret_body)) + Text(text = bodyText) Spacer(modifier = Modifier.height(4.dp)) OutlinedTextField( value = secret, onValueChange = { secret = it }, - label = { Text(stringResource(R.string.chat_secret_value)) }, + label = { Text(labelText) }, modifier = Modifier.fillMaxWidth().testTag("secret_value_input"), singleLine = true, visualTransformation = PasswordVisualTransformation(), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index c869afb4..7af3e313 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -133,6 +133,8 @@ fun ChatLifecycleEffects( SecretPromptDialog( onConfirm = viewModel::respondToSecret, onDismiss = viewModel::dismissSecret, + envVar = prompt.envVar, + prompt = prompt.prompt, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 473338b0..8ec0a493 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -42,6 +42,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -85,6 +87,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBg import com.m57.hermescontrol.theme.CodeTerminalBorder import com.m57.hermescontrol.theme.CodeTerminalMuted import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.SubagentIndicator import kotlinx.coroutines.delay @@ -429,19 +432,27 @@ private fun copyToClipboard( // ── ClarifyBubble ───────────────────────────────────────────────────────── /** - * Centered dashed-border bubble showing a clarify question with - * selectable option chips and a dismiss button. + * Centered dashed-border bubble showing a clarify question (or batch of questions) with + * selectable option chips, multi-select toggles, open-ended input, and a dismiss button. */ @OptIn(ExperimentalLayoutApi::class) @Composable fun ClarifyBubble( - text: String, - options: List, - onOptionSelected: (String) -> Unit, + clarifyRequest: ClarifyUi, + onRespondSingle: (String) -> Unit, + onRespondBatch: (Map) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - var typedText by remember { mutableStateOf("") } + val questions = clarifyRequest.resolvedQuestions + val isBatch = questions.size > 1 + + var selectedChoicesByQid by remember(clarifyRequest) { + mutableStateOf>>(emptyMap()) + } + var customTextByQid by remember(clarifyRequest) { + mutableStateOf>(emptyMap()) + } Surface( modifier = @@ -458,57 +469,170 @@ fun ClarifyBubble( ), ) { Column( - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (options.isNotEmpty()) { - Spacer(Modifier.height(10.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - options.forEach { option -> - SuggestionChip( - onClick = { onOptionSelected(option) }, - label = { Text(option) }, - colors = - SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - labelColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) + questions.forEachIndexed { index, q -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + + if (isBatch) { + Text( + text = "${index + 1}. ${q.question}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = q.question, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (q.multiSelect) { + Spacer(Modifier.height(2.dp)) + Text( + text = "Select all that apply", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (q.choices.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.choices.forEach { choice -> + val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() + val isSelected = selectedSet.contains(choice) + FilterChip( + selected = isSelected, + onClick = { + if (q.multiSelect) { + val updated = if (isSelected) selectedSet - choice else selectedSet + choice + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } else { + if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { + // Fast 1-tap respond for lone single-select question when no custom text is entered + onRespondSingle(choice) + } else { + val updated = if (isSelected) emptySet() else setOf(choice) + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } + } + }, + label = { Text(choice) }, + leadingIcon = + if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + ) + } } } + + Spacer(Modifier.height(8.dp)) + val typed = customTextByQid[q.qid].orEmpty() + OutlinedTextField( + value = typed, + onValueChange = { newText -> + customTextByQid = customTextByQid + (q.qid to newText) + }, + label = { + Text( + if (q.choices.isEmpty()) { + stringResource(R.string.message_your_response) + } else { + "Other (optional)" + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) } - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = typedText, - onValueChange = { typedText = it }, - label = { Text(stringResource(R.string.message_your_response)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(Modifier.height(8.dp)) + + Spacer(Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), ) { + val hasAnyInput = + questions.any { q -> + (selectedChoicesByQid[q.qid]?.isNotEmpty() == true) || + (!customTextByQid[q.qid].isNullOrBlank()) + } + FilledTonalButton( onClick = { - if (typedText.isNotBlank()) { - onOptionSelected(typedText) - typedText = "" + if (isBatch) { + val answers = + questions.associate { q -> + val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() + val custom = customTextByQid[q.qid]?.trim().orEmpty() + val finalAns = + when { + selectedList.isNotEmpty() && custom.isNotEmpty() -> { + (selectedList + custom).joinToString(", ") + } + + selectedList.isNotEmpty() -> { + selectedList.joinToString(", ") + } + + else -> { + custom + } + } + q.qid to finalAns + } + onRespondBatch(answers) + } else { + val q = questions.first() + val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() + val custom = customTextByQid[q.qid]?.trim().orEmpty() + val finalAns = + when { + selectedList.isNotEmpty() && custom.isNotEmpty() -> { + (selectedList + custom).joinToString(", ") + } + + selectedList.isNotEmpty() -> { + selectedList.joinToString(", ") + } + + else -> { + custom + } + } + if (finalAns.isNotBlank()) { + onRespondSingle(finalAns) + } } }, - enabled = typedText.isNotBlank(), + enabled = hasAnyInput, ) { - Text("Send") + Text(if (isBatch) "Submit All" else "Send") } TextButton(onClick = onDismiss) { Text("Dismiss") @@ -518,6 +642,28 @@ fun ClarifyBubble( } } +/** + * Backward-compatible overload for legacy callers and unit tests. + */ +@Composable +fun ClarifyBubble( + text: String, + options: List, + onOptionSelected: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ClarifyBubble( + clarifyRequest = ClarifyUi(text = text, options = options), + onRespondSingle = onOptionSelected, + onRespondBatch = { answers -> + answers.values.firstOrNull()?.let(onOptionSelected) + }, + onDismiss = onDismiss, + modifier = modifier, + ) +} + // ── SubagentCard ────────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 4ded4389..8acdba59 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -140,20 +140,36 @@ internal fun FullBleedAgentMessage( } if (!message.isStreaming) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content))) - } - copied = true - }, - modifier = Modifier.size(28.dp).testTag("fullbleed_copy"), + Row( + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content))) + } + copied = true + }, + modifier = Modifier.size(28.dp).testTag("fullbleed_copy"), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + val finishTime = message.finishTimestamp ?: message.timestamp + Text( + text = + com.m57.hermescontrol.ui.chat.formatTimestamp( + finishTime, + DateFormat.is24HourFormat(LocalContext.current), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.testTag("fullbleed_finish_time"), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 5f3eccdf..5de6167d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -67,6 +67,7 @@ fun FullBleedChatList( viewModel: ChatViewModel, clarifyRequest: ClarifyUi? = null, onRespondClarify: ((String) -> Unit)? = null, + onRespondClarifyBatch: ((Map) -> Unit)? = null, onDismissClarify: (() -> Unit)? = null, onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, savingAttachmentPath: String? = null, @@ -299,9 +300,9 @@ fun FullBleedChatList( if (clarifyRequest != null) { item(key = "clarify_bubble") { ClarifyBubble( - text = clarifyRequest.text, - options = clarifyRequest.options, - onOptionSelected = { option -> onRespondClarify?.invoke(option) }, + clarifyRequest = clarifyRequest, + onRespondSingle = { option -> onRespondClarify?.invoke(option) }, + onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) }, onDismiss = { onDismissClarify?.invoke() }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 7d542fd0..20dd7f6b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -29,7 +29,7 @@ data class SettingsUiState( val token: String = "", val autoReconnect: Boolean = true, val themePreference: ThemePreference = ThemePreference.SYSTEM, - val useDynamicColors: Boolean = true, + val useDynamicColors: Boolean = false, val themePreset: ThemePreset = ThemePreset.DEFAULT, val isTesting: Boolean = false, val testResult: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/util/BidiUtils.kt b/app/src/main/java/com/m57/hermescontrol/util/BidiUtils.kt new file mode 100644 index 00000000..879902cf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/BidiUtils.kt @@ -0,0 +1,153 @@ +package com.m57.hermescontrol.util + +import androidx.compose.ui.unit.LayoutDirection + +/** + * Bidirectional (BiDi) text utilities for mixed Arabic/English content, + * dynamic layout direction resolution, and Unicode directional isolates. + */ +object BidiUtils { + const val LRI = "\u2066" // Left-to-Right Isolate + const val RLI = "\u2067" // Right-to-Left Isolate + const val FSI = "\u2068" // First-Strong Isolate + const val PDI = "\u2069" // Pop Directional Isolate + const val LRM = "\u200E" // Left-to-Right Mark + const val RLM = "\u200F" // Right-to-Left Mark + + enum class BidiDirection { + LTR, + RTL, + NEUTRAL, + } + + /** + * Determines whether the given text should be rendered as RTL. + * + * 1. If the first strong directional character is RTL (Arabic/Hebrew), returns true. + * 2. If the first strong directional character is LTR (e.g. an Arabic sentence starting with + * an English term like "Android هو نظام..."), returns true if RTL characters outnumber LTR characters. + * 3. If there are no strong characters (empty, numbers, emojis, symbols only), returns false. + */ + fun isRtlText(text: CharSequence): Boolean { + var firstStrongRtl: Boolean? = null + var rtlCount = 0 + var ltrCount = 0 + + var i = 0 + val len = text.length + while (i < len) { + val cp = Character.codePointAt(text, i) + when (Character.getDirectionality(cp)) { + Character.DIRECTIONALITY_RIGHT_TO_LEFT, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC, + -> { + if (firstStrongRtl == null) firstStrongRtl = true + rtlCount++ + } + + Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + if (firstStrongRtl == null) firstStrongRtl = false + ltrCount++ + } + } + i += Character.charCount(cp) + } + + return when { + firstStrongRtl == true -> true + firstStrongRtl == false -> rtlCount > ltrCount + else -> false + } + } + + /** + * Detects the dominant [BidiDirection] of [text]. + * Returns [BidiDirection.NEUTRAL] when no strong directional characters exist. + */ + fun detectTextDirection(text: CharSequence): BidiDirection { + var hasStrong = false + var firstStrongRtl: Boolean? = null + var rtlCount = 0 + var ltrCount = 0 + + var i = 0 + val len = text.length + while (i < len) { + val cp = Character.codePointAt(text, i) + when (Character.getDirectionality(cp)) { + Character.DIRECTIONALITY_RIGHT_TO_LEFT, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC, + -> { + hasStrong = true + if (firstStrongRtl == null) firstStrongRtl = true + rtlCount++ + } + + Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + hasStrong = true + if (firstStrongRtl == null) firstStrongRtl = false + ltrCount++ + } + } + i += Character.charCount(cp) + } + + if (!hasStrong) return BidiDirection.NEUTRAL + + val isRtl = + when { + firstStrongRtl == true -> true + firstStrongRtl == false -> rtlCount > ltrCount + else -> false + } + return if (isRtl) BidiDirection.RTL else BidiDirection.LTR + } + + /** + * Resolves the Compose [LayoutDirection] for [text]. + * If [text] is neutral/empty, falls back to [fallback]. + */ + fun resolveLayoutDirection( + text: CharSequence, + fallback: LayoutDirection = LayoutDirection.Ltr, + ): LayoutDirection = + when (detectTextDirection(text)) { + BidiDirection.RTL -> LayoutDirection.Rtl + BidiDirection.LTR -> LayoutDirection.Ltr + BidiDirection.NEUTRAL -> fallback + } + + /** + * Returns true if [text] contains strong LTR characters (Latin letters, digits) + * and no strong RTL characters (Arabic, Hebrew). + */ + fun isLtrSnippet(text: CharSequence): Boolean { + var hasLtr = false + var i = 0 + val len = text.length + while (i < len) { + val cp = Character.codePointAt(text, i) + when (Character.getDirectionality(cp)) { + Character.DIRECTIONALITY_RIGHT_TO_LEFT, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC, + -> return false + + Character.DIRECTIONALITY_LEFT_TO_RIGHT -> hasLtr = true + } + i += Character.charCount(cp) + } + return hasLtr + } + + /** + * Wraps [text] in a Left-to-Right Isolate (LRI ... PDI) if [isLtrSnippet] is true. + * Used to prevent inline English terms and code from bleeding into surrounding RTL runs. + */ + fun wrapLtrIsolate(text: String): String = if (isLtrSnippet(text)) "$LRI$text$PDI" else text + + /** + * Appends a Right-to-Left Mark (RLM) to [text] if it is RTL, ensuring trailing + * punctuation and neutral emojis anchor to the visual end of the RTL sentence. + */ + fun anchorTrailingRtl(text: String): String = if (isRtlText(text) && !text.endsWith(RLM)) "$text$RLM" else text +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 03f09da1..2dce4f60 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1078,7 +1078,8 @@ مزيج الوكلاء (Mixture of Agents) المهام المساعدة المهام المساعدة -نموذج عالي التكلفة +نموذج مرتفع التكلفة +متابعة النموذج الرئيسي مزيج الوكلاء (Mixture of Agents) ينطبق على الجلسات الجديدة @@ -1167,7 +1168,11 @@ لا توجد رسائل بعد أشر إلى بوت أو استخدم @all لبدء التعاون. %1$s يفكّر الآن… +تخطى %1$s دوره جارٍ الكتابة… +جارٍ التشغيل… +لا يوجد مخرجات +رمز الخروج 0 (لا يوجد مخرجات) %1$d أعضاء @all المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة @@ -1181,6 +1186,10 @@ تعليمات / موجه الغرفة قواعد مخصصة، أو أسلوب، أو سياق لهذه المجموعة (اختياري)… إرشادات مخصصة لجميع البوتات في هذه الغرفة +تحذير من الحدود المرتفعة +قد يؤدي تعيين حدود مرتفعة للرسائل (%1$d) أو التمريرات (%2$d) إلى استهلاك كبير للرموز (Tokens) وحدوث حلقات نقاش طويلة ومستمرة بين البوتات. +حفظ على أي حال +تعديل الحدود الإعدادات تم إيقاف النقاش إيقاف التوليد diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a1e47513..ba813670 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -387,6 +387,7 @@ 보고된 모델이 없거나 인증이 필요합니다. 활성 모델 고정된 모델 + 계속 모델 고정 해제 고정 섹션 표시 전환 @@ -863,6 +864,10 @@ 방 지침 / 프롬프트 이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)… 이 방의 모든 봇에 적용되는 사용자 지정 가이드라인 + 높은 제한 경고 + 메시지 수(%1$d) 또는 전달 횟수(%2$d)를 높게 설정하면 API 토큰이 대량 소비되고 봇 간의 긴 토론 루프가 발생할 수 있습니다. + 그래도 저장 + 제한 조정 설정 에이전트 계획 실시간 기록 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0912dbd7..ebd07c23 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1069,6 +1069,7 @@ 多 Agent 混合 辅助任务 辅助任务 + 继续 高成本模型 主模型 多 Agent 混合 @@ -1164,5 +1165,9 @@ 房间指令 / 提示词 此群聊的自定义规则、语气或上下文(可选)… 适用于此房间内所有 Bot 的自定义准则 + 高上限警告 + 设置较高的消息数 (%1$d) 或交接次数 (%2$d) 可能会消耗大量 API 令牌,并导致机器人之间产生长时间的连续讨论循环。 + 仍要保存 + 调整限制 设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ab998eb..51a64751 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,6 +145,7 @@ Waiting for Hermes replies New message Hermes needs a clarification + Hermes needs your input Type your reply\u2026 Hermes Background Service Keeps connection alive in the background @@ -1198,6 +1199,7 @@ Mixture of Agents Auxiliary tasks Auxiliary Tasks + Continue Expensive Model Main model Mixture of Agents @@ -1281,7 +1283,11 @@ No Messages Yet Mention a bot or use @all to kick off collaboration. %1$s is thinking… + %1$s passed Typing… + Running… + No output + Exit code 0 (no output) %1$d members @all Discussion paused (turn limit reached) — reply to continue @@ -1295,6 +1301,10 @@ Room Instructions / Prompt Custom rules, tone, or context for this group (optional)… Custom guidelines for all bots in this room + High Limits Warning + Setting high message (%1$d) or handoff (%2$d) limits can consume significant API tokens and lead to long continuous bot discussion loops. + Save Anyway + Adjust Limits Settings Discussion stopped Stop generation diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index dc922da5..ebf22b3a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -291,6 +291,223 @@ class EventParserTest { assertEquals(listOf("staging", "production"), clarifyEvent.options) } + @Test + fun testParseClarifyRequest_withBatchQuestions_parsesSuccessfully() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "clarify.request", + "payload" to + mapOf( + "request_id" to "req-batch-1", + "questions" to + listOf( + mapOf( + "qid" to "q0", + "question" to "Which database?", + "choices" to listOf("Postgres", "SQLite"), + "multi_select" to false, + ), + ), + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ClarifyRequest) + val clarifyEvent = event as WsEvent.ClarifyRequest + assertEquals("Which database?", clarifyEvent.text) + assertEquals(listOf("Postgres", "SQLite"), clarifyEvent.options) + assertEquals("req-batch-1", clarifyEvent.clarifyId) + assertEquals("q0", clarifyEvent.questionId) + } + + @Test + fun testParseClarifyRequest_withMultiSelectAndMultipleQuestions() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "clarify.request", + "payload" to + mapOf( + "request_id" to "req-batch-2", + "questions" to + listOf( + mapOf( + "qid" to "q0", + "question" to "Pick languages:", + "choices" to listOf("Python", "Go", "Rust"), + "multi_select" to true, + ), + mapOf( + "qid" to "q1", + "question" to "Any comments?", + "choices" to emptyList(), + "multi_select" to false, + ), + ), + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ClarifyRequest) + val clarifyEvent = event as WsEvent.ClarifyRequest + assertEquals("req-batch-2", clarifyEvent.clarifyId) + assertEquals(2, clarifyEvent.questions.size) + + val q0 = clarifyEvent.questions[0] + assertEquals("q0", q0.qid) + assertEquals("Pick languages:", q0.question) + assertEquals(listOf("Python", "Go", "Rust"), q0.choices) + assertTrue(q0.multiSelect) + + val q1 = clarifyEvent.questions[1] + assertEquals("q1", q1.qid) + assertEquals("Any comments?", q1.question) + assertTrue(q1.choices.isEmpty()) + assertFalse(q1.multiSelect) + } + + @Test + fun testParseClarifyExpire_parsesSuccessfully() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "clarify.expire", + "payload" to mapOf("request_id" to "req-expire-1"), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ClarifyExpire) + val expireEvent = event as WsEvent.ClarifyExpire + assertEquals("req-expire-1", expireEvent.clarifyId) + } + + // ── Approval requests (full support) ─────────────────────────────── + + @Test + fun testParseApprovalRequest_withChoicesAndRequestId() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to + mapOf( + "command" to "rm -rf /tmp/x", + "description" to "dangerous command", + "request_id" to "req-1", + "choices" to listOf("once", "session", "always", "deny"), + "allow_permanent" to true, + "pattern_keys" to listOf("shell:rm"), + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals("rm -rf /tmp/x", approval.command) + assertEquals("req-1", approval.requestId) + assertEquals(listOf("once", "session", "always", "deny"), approval.choices) + assertEquals(true, approval.allowPermanent) + assertEquals(listOf("shell:rm"), approval.patternKeys) + } + + @Test + fun testParseApprovalRequest_smartDeniedDefaultsToOnceDeny() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to + mapOf( + "command" to "rm -rf /", + "smart_denied" to true, + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals(listOf("once", "deny"), approval.choices) + assertEquals(true, approval.smartDenied) + } + + @Test + fun testParseApprovalRequest_legacyDefaultsToFullChoices() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to mapOf("command" to "ls"), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals(listOf("once", "session", "always", "deny"), approval.choices) + } + + @Test + fun testParseApprovalRequest_allowPermanentFalseStillParses() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to + mapOf( + "command" to "rm", + "choices" to listOf("once", "session", "always", "deny"), + "allow_permanent" to false, + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals(false, approval.allowPermanent) + } + // ── TEST-07: Untested subtypes ───────────────────────────────────── @Test @@ -746,4 +963,71 @@ class EventParserTest { assertEquals("1", todoUpdated.todos[1].parent) assertTrue(todoUpdated.todos[1].isSubtask) } + + @Test + fun testParseSudoRequest_returnsSudoRequest() { + val params = + mapOf( + "type" to "sudo.request", + "session_id" to "sess-sudo-1", + "payload" to mapOf("request_id" to "sudo-1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SudoRequest) + val sudo = event as WsEvent.SudoRequest + assertEquals("sudo-1", sudo.requestId) + assertEquals("sess-sudo-1", sudo.sessionId) + } + + @Test + fun testParseSudoExpire_returnsSudoExpire() { + val params = + mapOf( + "type" to "sudo.expire", + "session_id" to "sess-sudo-1", + "payload" to mapOf("request_id" to "sudo-1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SudoExpire) + val expire = event as WsEvent.SudoExpire + assertEquals("sudo-1", expire.requestId) + assertEquals("sess-sudo-1", expire.sessionId) + } + + @Test + fun testParseSecretRequest_withEnvVarAndPrompt() { + val params = + mapOf( + "type" to "secret.request", + "session_id" to "sess-secret-1", + "payload" to + mapOf( + "request_id" to "secret-1", + "env_var" to "GITHUB_TOKEN", + "prompt" to "Enter your GitHub token to continue", + ), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SecretRequest) + val secret = event as WsEvent.SecretRequest + assertEquals("secret-1", secret.requestId) + assertEquals("sess-secret-1", secret.sessionId) + assertEquals("GITHUB_TOKEN", secret.envVar) + assertEquals("Enter your GitHub token to continue", secret.prompt) + } + + @Test + fun testParseSecretExpire_returnsSecretExpire() { + val params = + mapOf( + "type" to "secret.expire", + "session_id" to "sess-secret-1", + "payload" to mapOf("request_id" to "secret-1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SecretExpire) + val expire = event as WsEvent.SecretExpire + assertEquals("secret-1", expire.requestId) + assertEquals("sess-secret-1", expire.sessionId) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt index 4171f758..127ca05a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt @@ -68,4 +68,56 @@ class GroupChatMentionsTest { assertEquals(1, responders.size) assertEquals("coder", responders.first().name) } + + @Test + fun groupChatMentionAutocomplete_endToEndIntegration() { + // 1. User types "@cod" + val input = + androidx.compose.ui.text.input + .TextFieldValue( + "hey @cod", + androidx.compose.ui.text + .TextRange(8), + ) + val query = + com.m57.hermescontrol.ui.chat.ChatInputPolicy + .extractMentionQuery(input.text, input.selection.end) + assertEquals("cod", query) + + // 2. Candidate filtering: + // Query "@c" matches both "coder" (starts with c) and "Scout Bot" (contains c) + val broadMatches = + members.filter { + it.name.startsWith("c", ignoreCase = true) || it.effectiveTitle.contains("c", ignoreCase = true) + } + assertEquals(2, broadMatches.size) + + // Narrower query "@cod" matches only "coder" + val matchingBots = + members.filter { + it.name.startsWith(query!!, ignoreCase = true) || it.effectiveTitle.contains(query, ignoreCase = true) + } + assertEquals(1, matchingBots.size) + assertEquals("coder", matchingBots.first().name) + + // 3. User taps chip for coder + val completed = + com.m57.hermescontrol.ui.chat.ChatInputPolicy + .applyMention(input, matchingBots.first().name) + assertEquals("hey @coder ", completed.text) + assertEquals(11, completed.selection.end) + + // 4. Mention query is now null after space + val nextQuery = + com.m57.hermescontrol.ui.chat.ChatInputPolicy.extractMentionQuery( + completed.text, + completed.selection.end, + ) + org.junit.Assert.assertNull(nextQuery) + + // 5. Group chat mentions resolver picks up the mentioned bot + val responders = GroupChatMentions.resolveResponders(completed.text, members) + assertEquals(1, responders.size) + assertEquals("coder", responders.first().name) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt index a8bc0116..8bad923e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -266,9 +266,17 @@ class GroupChatViewModelTest { testScheduler.runCurrent() val finalState = viewModel.uiState.value - // Only the user message should remain; (pass) is filtered out - assertEquals(1, finalState.messages.size) + // User message plus the visual pass indicator + assertEquals(2, finalState.messages.size) assertTrue(finalState.messages.first().isUser) + val passMsg = finalState.messages.last() + assertTrue(passMsg.isSystem) + assertTrue(passMsg.isPass) + assertEquals("Scout Bot", passMsg.senderDisplayName) + assertEquals("Scout Bot passed", passMsg.text) + + // LLM prompt history only considers non-system messages + assertEquals(1, finalState.messages.filter { !it.isSystem }.size) } @Test @@ -420,6 +428,33 @@ class GroupChatViewModelTest { assertEquals(15, viewModel.uiState.value.maxBotMessages) assertEquals(4, viewModel.uiState.value.maxContinuationPasses) assertEquals("Custom instructions for this group", viewModel.uiState.value.systemPrompt) + + // Extended limits within new range + viewModel.updateGroupLimits( + maxMessages = 45, + maxPasses = 18, + ) + testScheduler.runCurrent() + assertEquals(45, viewModel.uiState.value.maxBotMessages) + assertEquals(18, viewModel.uiState.value.maxContinuationPasses) + + // Exceeding ceiling clamps to MAX_ALLOWED_BOT_MESSAGES (50) and MAX_ALLOWED_CONTINUATION_PASSES (20) + viewModel.updateGroupLimits( + maxMessages = 999, + maxPasses = 999, + ) + testScheduler.runCurrent() + assertEquals(MAX_ALLOWED_BOT_MESSAGES, viewModel.uiState.value.maxBotMessages) + assertEquals(MAX_ALLOWED_CONTINUATION_PASSES, viewModel.uiState.value.maxContinuationPasses) + + // Under floor clamps to 1 and 0 + viewModel.updateGroupLimits( + maxMessages = -10, + maxPasses = -5, + ) + testScheduler.runCurrent() + assertEquals(1, viewModel.uiState.value.maxBotMessages) + assertEquals(0, viewModel.uiState.value.maxContinuationPasses) } @Test @@ -473,13 +508,19 @@ class GroupChatViewModelTest { val toolCall = msgWithTool.toolCalls.first() assertEquals("terminal", toolCall.name) assertEquals("date", toolCall.summary) + assertEquals("date", toolCall.command) assertTrue(toolCall.isRunning) // Emit ToolComplete eventsFlow.emit( WsEvent.ToolComplete( name = "terminal", - data = mapOf("tool_id" to "tool-123"), + data = + mapOf( + "tool_id" to "tool-123", + "output" to "Sat Aug 29 03:30:00 UTC 2026", + "exit_code" to 0, + ), sessionId = "session-scout-1", ), ) @@ -488,7 +529,11 @@ class GroupChatViewModelTest { val msgAfterToolDone = viewModel.uiState.value.messages .last() - assertFalse(msgAfterToolDone.toolCalls.first().isRunning) + val completedTool = msgAfterToolDone.toolCalls.first() + assertFalse(completedTool.isRunning) + assertEquals("Sat Aug 29 03:30:00 UTC 2026", completedTool.output) + assertEquals(0, completedTool.exitCode) + assertFalse(completedTool.isError) // Complete message eventsFlow.emit( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 0382f8d4..eb8234d5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -793,6 +793,148 @@ class ChatViewModelTest { assertEquals(sessionId, call.third) } + @Test + fun testModelSwitch_confirmRequired_triggersConfirmationDialogAndReSends() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + var lastReqId = "" + val capturedParams = mutableListOf>() + every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + val params = arg>(1) + capturedParams.add(params) + val id = "req-confirm-${capturedParams.size}" + lastReqId = id + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // User sends model switch command that triggers privacy/expensive guard + viewModel.sendMessage("/model muse-spark-1.3-contributor-free --provider opencode-free --session") + advanceUntilIdle() + + assertEquals(1, capturedParams.size) + assertNull(capturedParams[0]["confirm_expensive_model"]) + + // Backend returns confirm_required = true + mockEventsFlow.emit( + WsEvent.RpcResult( + id = lastReqId, + result = + mapOf( + "key" to "model", + "value" to "muse-spark-1.3-contributor-free", + "confirm_required" to true, + "confirm_message" to "Meta training warning", + ), + ), + ) + advanceUntilIdle() + + // UI state now shows confirmation dialog message + assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage) + + // User confirms + viewModel.confirmModelSwitchExpensive() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) + assertEquals(2, capturedParams.size) + assertEquals(true, capturedParams[1]["confirm_expensive_model"]) + assertEquals( + "muse-spark-1.3-contributor-free --provider opencode-free --session", + capturedParams[1]["value"], + ) + assertEquals(sessionId, capturedParams[1]["session_id"]) + } + + @Test + fun testModelSwitch_dismissConfirmation_revertsModel() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + var lastReqId = "" + every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + val id = "req-cfg-dismiss" + lastReqId = id + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // Set an initial model + viewModel.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel) + + // Switch to expensive model + viewModel.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free") + advanceUntilIdle() + assertEquals("opencode-free/muse-spark-1.3-contributor-free", viewModel.uiState.value.currentSessionModel) + + // Backend requires confirmation + mockEventsFlow.emit( + WsEvent.RpcResult( + id = lastReqId, + result = + mapOf( + "key" to "model", + "value" to "muse-spark-1.3-contributor-free", + "confirm_required" to true, + "confirm_message" to "Meta training warning", + ), + ), + ) + advanceUntilIdle() + assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage) + + // User dismisses/cancels + viewModel.dismissModelSwitchConfirm() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) + // Reverted back to previous model + assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel) + } + + @Test + fun testUndoCommand_dispatchesAndPrefillsComposer() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + every { + HermesWsClient.request( + WsMethods.COMMAND_DISPATCH, + mapOf("name" to "undo", "arg" to "2", "session_id" to sessionId), + any(), + ) + } returns + CompletableDeferred( + mapOf( + "type" to "prefill", + "message" to "undone user message", + "notice" to "↶ Undid 2 turns", + ), + ) + + viewModel.sendMessage("/undo 2") + advanceUntilIdle() + + // Prefill text is staged in UI state + assertEquals("undone user message", viewModel.uiState.value.pendingPrefillText) + // Notice is displayed as system message with rewind target feedback + assertTrue( + viewModel.uiState.value.messages + .any { it.content.contains("↶ Undid 2 turns") && it.content.contains("undone user message") }, + ) + // Verify slash usage was recorded + assertEquals(1, viewModel.uiState.value.slashUsageCounts["/undo"]) + + // Consume prefill text + viewModel.consumePendingPrefill() + advanceUntilIdle() + assertNull(viewModel.uiState.value.pendingPrefillText) + } + // ── Connection / init tests ────────────────────────────────────────────── @Test @@ -1217,6 +1359,130 @@ class ChatViewModelTest { } } + @Test + fun testClarifyRequestWithQuestionIdAndRespond() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + mockEventsFlow.emit( + WsEvent.ClarifyRequest( + text = "Pick one:", + options = listOf("Option 1"), + clarifyId = "clarify-batch-1", + sessionId = sessionId, + questionId = "q0", + ), + ) + advanceUntilIdle() + + assertEquals( + "Pick one:", + viewModel.uiState.value.clarifyRequest + ?.text, + ) + assertEquals( + "q0", + viewModel.uiState.value.clarifyRequest + ?.questionId, + ) + + viewModel.respondToClarify("Option 1") + advanceUntilIdle() + + assertNull(viewModel.uiState.value.clarifyRequest) + verify { + HermesWsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = + mapOf( + "session_id" to sessionId, + "response" to "Option 1", + "answer" to "Option 1", + "clarify_id" to "clarify-batch-1", + "request_id" to "clarify-batch-1", + "question_id" to "q0", + ), + onSent = any(), + ) + } + } + + @Test + fun testClarifyBatch_respondsToAllQuestions() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + mockEventsFlow.emit( + WsEvent.ClarifyRequest( + text = "1. Pick A\n\n2. Pick B", + options = listOf("A1", "A2"), + clarifyId = "clarify-batch-multi", + sessionId = sessionId, + questionId = "q0", + questions = + listOf( + WsEvent.ClarifyQuestion( + qid = "q0", + question = "Pick A", + choices = listOf("A1", "A2"), + multiSelect = false, + ), + WsEvent.ClarifyQuestion( + qid = "q1", + question = "Pick B", + choices = listOf("B1", "B2"), + multiSelect = true, + ), + ), + ), + ) + advanceUntilIdle() + + val clarify = viewModel.uiState.value.clarifyRequest + assertNotNull(clarify) + assertEquals(2, clarify?.resolvedQuestions?.size) + + viewModel.respondToClarifyBatch( + mapOf( + "q0" to "A1", + "q1" to "B1, B2", + ), + ) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.clarifyRequest) + verify { + HermesWsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = + mapOf( + "session_id" to sessionId, + "response" to "A1", + "answer" to "A1", + "question_id" to "q0", + "clarify_id" to "clarify-batch-multi", + "request_id" to "clarify-batch-multi", + ), + onSent = any(), + ) + } + verify { + HermesWsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = + mapOf( + "session_id" to sessionId, + "response" to "B1, B2", + "answer" to "B1, B2", + "question_id" to "q1", + "clarify_id" to "clarify-batch-multi", + "request_id" to "clarify-batch-multi", + ), + onSent = any(), + ) + } + } + @Test fun testClarifyRequestCustomResponse() = runTest { @@ -1316,6 +1582,53 @@ class ChatViewModelTest { } } + @Test + fun testClarifyDismissWithQuestionIdInformsAgent() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + mockEventsFlow.emit( + WsEvent.ClarifyRequest( + "Please choose:", + listOf("Yes", "No"), + "clarify-789", + sessionId, + questionId = "q0", + ), + ) + advanceUntilIdle() + assertEquals( + "clarify-789", + viewModel.uiState.value.clarifyRequest + ?.clarifyId, + ) + assertEquals( + "q0", + viewModel.uiState.value.clarifyRequest + ?.questionId, + ) + + viewModel.dismissClarify() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.clarifyRequest) + verify { + HermesWsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = + mapOf( + "session_id" to sessionId, + "response" to "The user cancelled — no answer provided.", + "answer" to "The user cancelled — no answer provided.", + "clarify_id" to "clarify-789", + "request_id" to "clarify-789", + "question_id" to "q0", + ), + onSent = any(), + ) + } + } + // ── Attachments ────────────────────────────────────────────────────────── /** Add [count] dummy attachments so a test starts with a populated list. */ @@ -2722,6 +3035,236 @@ class ChatViewModelTest { assertNull(msgAfter!!.approvalInfo) } + @Test + fun testApprovalRequest_storesRequestIdAndChoices() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm -rf /tmp/x", + description = "dangerous command", + patternKeys = listOf("shell:rm"), + sessionId = null, + requestId = "req-1", + choices = listOf("once", "session", "always", "deny"), + allowPermanent = true, + smartDenied = false, + ), + ) + advanceUntilIdle() + + val msg = + viewModel.uiState.value.messages + .first { it.content.contains("Approval Required") } + assertEquals("req-1", msg.approvalInfo?.requestId) + assertEquals( + listOf("once", "session", "always", "deny"), + msg.approvalInfo?.choices, + ) + assertEquals(true, msg.approvalInfo?.allowPermanent) + } + + @Test + fun testRespondToApproval_mapsApproveToOnceAndSendsRequestId() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + requestId = "req-42", + choices = listOf("once", "deny"), + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("approve") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("once", params["choice"]) + assertEquals("req-42", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testRespondToApproval_sessionChoice() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + requestId = "req-7", + choices = listOf("once", "session", "always", "deny"), + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("session") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals("session", params["choice"]) + assertEquals("req-7", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testRespondToApproval_omitsRequestIdWhenAbsent() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "ls", + description = "Safe", + patternKeys = null, + sessionId = null, + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("deny") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals("deny", params["choice"]) + assertFalse(params.containsKey("request_id")) + }, + any(), + ) + } + } + + @Test + fun testRespondToApproval_triggersPendingReplay() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + requestId = "req-9", + choices = listOf("once", "deny"), + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("once") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_PENDING, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + }, + any(), + ) + } + } + + @Test + fun testApprovalFlow_prefersRuntimeSessionIdOverStorageId() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.RpcResult( + "req-id-3", + mapOf( + "session_id" to "runtime-sid-999", + "stored_session_id" to "storage-uuid-111", + ), + ), + ) + advanceUntilIdle() + + assertEquals("storage-uuid-111", viewModel.uiState.value.currentSessionId) + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm -rf /data", + description = "Dangerous operation", + patternKeys = null, + sessionId = null, + requestId = "req-runtime-test", + choices = listOf("once", "deny"), + ), + ) + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RECEIVED, + withArg { params -> + assertEquals("runtime-sid-999", params["session_id"]) + assertEquals("req-runtime-test", params["request_id"]) + }, + any(), + ) + } + + viewModel.respondToApproval("once") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals("runtime-sid-999", params["session_id"]) + assertEquals("once", params["choice"]) + assertEquals("req-runtime-test", params["request_id"]) + }, + any(), + ) + HermesWsClient.send( + WsMethods.APPROVAL_PENDING, + withArg { params -> + assertEquals("runtime-sid-999", params["session_id"]) + }, + any(), + ) + } + } + // ── Sudo / secret prompt flow (issue #524) ─────────────────────────── @Test @@ -2846,6 +3389,130 @@ class ChatViewModelTest { assertNull(viewModel.uiState.value.secretPrompt) } + @Test + fun testSudoExpire_clearsMatchingPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-1", sessionId = null)) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.sudoPrompt) + } + + @Test + fun testSudoExpire_ignoresNonMatchingRequestId() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-2", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-stale", sessionId = null)) + advanceUntilIdle() + + assertNotNull(viewModel.uiState.value.sudoPrompt) + } + + @Test + fun testSecretExpire_clearsMatchingPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.secretPrompt) + + mockEventsFlow.emit(WsEvent.SecretExpire(requestId = "secret-1", sessionId = null)) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.secretPrompt) + } + + @Test + fun testSecretRequest_setsEnvVarAndPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SecretRequest( + requestId = "secret-1", + sessionId = null, + envVar = "GITHUB_TOKEN", + prompt = "Enter your GitHub token", + ), + ) + advanceUntilIdle() + + val prompt = viewModel.uiState.value.secretPrompt + assertNotNull(prompt) + assertEquals("GITHUB_TOKEN", prompt?.envVar) + assertEquals("Enter your GitHub token", prompt?.prompt) + } + + @Test + fun testDismissSudo_sendsEmptyPassword() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + viewModel.dismissSudo() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.sudoPrompt) + verify { + HermesWsClient.send( + WsMethods.SUDO_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("", params["password"]) + assertEquals("sudo-1", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testDismissSecret_sendsEmptyValue() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.secretPrompt) + + viewModel.dismissSecret() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.secretPrompt) + verify { + HermesWsClient.send( + WsMethods.SECRET_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("", params["value"]) + assertEquals("secret-1", params["request_id"]) + }, + any(), + ) + } + } + // ── Settings ───────────────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index eec260cd..27baf301 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -26,6 +26,25 @@ class ChatWsEventReducerTest { assertEquals(null, result.state.clarifyRequest) } + @Test + fun testClarifyExpire_clearsMatchingClarifyRequest() { + val state = + ChatUiState( + currentSessionId = "session-1", + clarifyRequest = ClarifyUi("Expiring question", emptyList(), "clarify-expire-1"), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = WsEvent.ClarifyExpire(clarifyId = "clarify-expire-1", sessionId = "session-1"), + currentSessionId = "session-1", + ) + + assertEquals(null, result.state.clarifyRequest) + } + @Test fun testToolProgress_updatesProgressPreviewForMatchingRunningTool() { val initialMessage = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 032c9db4..c531107b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -11,6 +11,7 @@ import com.m57.hermescontrol.theme.StatusRedContainer import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.util.BidiUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -538,4 +539,36 @@ class MarkdownTextFeatureTest { val codeBlock = blocks.single() as MdBlock.Code assertTrue(codeBlock.code.contains("```python")) } + + // 25. ARABIC BIDI MIXED TEXT (issue #1044) + @Test + fun testArabicMixedWithInlineCode_wrapsWithLtrIsolate() { + val input = "تم اختبار كود `val x = 1` بنجاح" + val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + val expected = "تم اختبار كود ${BidiUtils.LRI}val x = 1${BidiUtils.PDI} بنجاح" + assertEquals(expected, parsed.toString()) + } + + @Test + fun testArabicMixedWithBoldEnglish_wrapsWithLtrIsolate() { + val input = "هذا النص يحتوي على **Android** داخل فقرة" + val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + val expected = "هذا النص يحتوي على ${BidiUtils.LRI}Android${BidiUtils.PDI} داخل فقرة" + assertEquals(expected, parsed.toString()) + } + + @Test + fun testEnglishWithInlineCode_doesNotWrapWithIsolate() { + val input = "Run `val x = 1` here" + val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + assertEquals("Run val x = 1 here", parsed.toString()) + } + + @Test + fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() { + val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" + val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + val expected = "${BidiUtils.LRI}Okay${BidiUtils.PDI} سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" + assertEquals(expected, parsed.toString()) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 6f5db563..a196f878 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -82,6 +82,14 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw ")) } + @Test + fun `undo routes to Undo`() { + assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/undo")) + assertEquals(SlashResult.Undo("2"), dispatcher.dispatch("/undo 2")) + assertEquals(SlashResult.Undo("3"), dispatcher.dispatch("/UNDO 3")) + assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt index 7c85693f..36be9b7f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt @@ -27,6 +27,10 @@ class FakeChatMessageDao : ChatMessageDao { messageList.forEach { messages[it.id] = it } } + override suspend fun deleteMessagesForSession(sessionId: String) { + messages.values.removeAll { it.sessionId == sessionId } + } + /** Direct access for test setup — bypasses the suspend modifier. */ fun addMessageDirect(message: ChatMessageEntity) { messages[message.id] = message diff --git a/app/src/test/java/com/m57/hermescontrol/util/BidiUtilsTest.kt b/app/src/test/java/com/m57/hermescontrol/util/BidiUtilsTest.kt new file mode 100644 index 00000000..05880459 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/BidiUtilsTest.kt @@ -0,0 +1,89 @@ +package com.m57.hermescontrol.util + +import androidx.compose.ui.unit.LayoutDirection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BidiUtilsTest { + @Test + fun testIsRtlText_pureArabic() { + assertTrue(BidiUtils.isRtlText("مرحبا بك في تطبيق هيرمس")) + } + + @Test + fun testIsRtlText_pureEnglish() { + assertFalse(BidiUtils.isRtlText("Hello from Hermes Control")) + } + + @Test + fun testIsRtlText_mixedArabicWithEnglishWords() { + val text = "هذا النص يحتوي على Android و Compose داخل فقرة عربية" + assertTrue(BidiUtils.isRtlText(text)) + } + + @Test + fun testIsRtlText_arabicStartingWithEnglishTerm() { + val text = "Android هو نظام تشغيل رائع ومميز جدا" + assertTrue(BidiUtils.isRtlText(text)) + } + + @Test + fun testIsRtlText_englishSentenceWithArabicWord() { + val text = "The Arabic word for welcome is مرحبا in most dialects" + assertFalse(BidiUtils.isRtlText(text)) + } + + @Test + fun testIsRtlText_neutralOnly() { + assertFalse(BidiUtils.isRtlText("")) + assertFalse(BidiUtils.isRtlText(" ")) + assertFalse(BidiUtils.isRtlText("12345")) + assertFalse(BidiUtils.isRtlText("🚀✨🔥")) + assertFalse(BidiUtils.isRtlText("/status")) + } + + @Test + fun testIsRtlText_neutralPrefixWithArabic() { + assertTrue(BidiUtils.isRtlText("🚀 مرحبا يا صديقي")) + assertTrue(BidiUtils.isRtlText("- [x] مهمة جديدة مع Compose")) + } + + @Test + fun testDetectTextDirection() { + assertEquals(BidiUtils.BidiDirection.RTL, BidiUtils.detectTextDirection("أهلا")) + assertEquals(BidiUtils.BidiDirection.LTR, BidiUtils.detectTextDirection("Hello")) + assertEquals(BidiUtils.BidiDirection.NEUTRAL, BidiUtils.detectTextDirection("123 🚀")) + } + + @Test + fun testResolveLayoutDirection() { + assertEquals(LayoutDirection.Rtl, BidiUtils.resolveLayoutDirection("صباح الخير")) + assertEquals(LayoutDirection.Ltr, BidiUtils.resolveLayoutDirection("Good morning")) + assertEquals(LayoutDirection.Ltr, BidiUtils.resolveLayoutDirection("123", fallback = LayoutDirection.Ltr)) + assertEquals(LayoutDirection.Rtl, BidiUtils.resolveLayoutDirection("123", fallback = LayoutDirection.Rtl)) + } + + @Test + fun testWrapLtrIsolate() { + assertEquals("${BidiUtils.LRI}Android${BidiUtils.PDI}", BidiUtils.wrapLtrIsolate("Android")) + assertEquals("${BidiUtils.LRI}val x = 1${BidiUtils.PDI}", BidiUtils.wrapLtrIsolate("val x = 1")) + // RTL snippet should not be wrapped + assertEquals("مرحبا", BidiUtils.wrapLtrIsolate("مرحبا")) + } + + @Test + fun testAnchorTrailingRtl() { + val rtlWithEmoji = "مرحبا بكم 🚀" + assertEquals("مرحبا بكم 🚀${BidiUtils.RLM}", BidiUtils.anchorTrailingRtl(rtlWithEmoji)) + + // Does not append duplicate RLM + val alreadyAnchored = "مرحبا بكم 🚀${BidiUtils.RLM}" + assertEquals(alreadyAnchored, BidiUtils.anchorTrailingRtl(alreadyAnchored)) + + // LTR text remains unmodified + val ltrWithEmoji = "Welcome to Hermes 🚀" + assertEquals(ltrWithEmoji, BidiUtils.anchorTrailingRtl(ltrWithEmoji)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 255f6ef9..c5d2f252 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ retrofit = "3.0.0" securityCrypto = "1.0.0" mockk = "1.14.11" turbine = "1.2.1" -coil = "3.6.0" +coil = "3.6.1" room = "3.0.2" sqlcipher = "4.18.0" datastore = "1.1.2"