45 files changed
+4070
-873
No files matched your search
@@ -55,7 +55,7 @@ class ServerStoreMigration(
|
|||||||
runCatching { ThemePreference.valueOf(themePrefString!!) }
|
runCatching { ThemePreference.valueOf(themePrefString!!) }
|
||||||
.getOrDefault(ThemePreference.SYSTEM)
|
.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 themePresetString = prefs.getString("theme_preset", ThemePreset.DEFAULT.name)
|
||||||
val themePreset =
|
val themePreset =
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ data class ServerStoreState(
|
|||||||
val baseUrl: String? = null,
|
val baseUrl: String? = null,
|
||||||
val autoReconnect: Boolean = true,
|
val autoReconnect: Boolean = true,
|
||||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||||
val useDynamicColors: Boolean = true,
|
val useDynamicColors: Boolean = false,
|
||||||
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||||
val connectionProfiles: List<ConnectionProfile> = emptyList(),
|
val connectionProfiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ object AuthManager {
|
|||||||
private val _themePreferenceFlow = MutableStateFlow<ThemePreference>(ThemePreference.SYSTEM)
|
private val _themePreferenceFlow = MutableStateFlow<ThemePreference>(ThemePreference.SYSTEM)
|
||||||
val themePreferenceFlow: StateFlow<ThemePreference> = _themePreferenceFlow.asStateFlow()
|
val themePreferenceFlow: StateFlow<ThemePreference> = _themePreferenceFlow.asStateFlow()
|
||||||
|
|
||||||
private val _useDynamicColorsFlow = MutableStateFlow<Boolean>(true)
|
private val _useDynamicColorsFlow = MutableStateFlow<Boolean>(false)
|
||||||
val useDynamicColorsFlow: StateFlow<Boolean> = _useDynamicColorsFlow.asStateFlow()
|
val useDynamicColorsFlow: StateFlow<Boolean> = _useDynamicColorsFlow.asStateFlow()
|
||||||
|
|
||||||
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ interface ChatMessageDao {
|
|||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(messages: List<ChatMessageEntity>)
|
suspend fun upsertAll(messages: List<ChatMessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
|
||||||
|
suspend fun deleteMessagesForSession(sessionId: String)
|
||||||
}
|
}
|
||||||
@@ -194,6 +194,7 @@ interface HermesApiService {
|
|||||||
// legacy backends that don't know the param — they ignore it.
|
// legacy backends that don't know the param — they ignore it.
|
||||||
@Query("order") order: String? = null,
|
@Query("order") order: String? = null,
|
||||||
@Query("include_compacted") includeCompacted: Boolean? = true,
|
@Query("include_compacted") includeCompacted: Boolean? = true,
|
||||||
|
@Query("profile") profile: String? = null,
|
||||||
): Response<SessionMessagesResponse>
|
): Response<SessionMessagesResponse>
|
||||||
|
|
||||||
@GET("api/sessions/stats")
|
@GET("api/sessions/stats")
|
||||||
|
|||||||
@@ -134,17 +134,90 @@ object EventParser {
|
|||||||
"clarify.request" -> {
|
"clarify.request" -> {
|
||||||
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
|
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
|
||||||
// older client or test that still uses the legacy field names. (Issue #206)
|
// older client or test that still uses the legacy field names. (Issue #206)
|
||||||
val text =
|
// Batch clarify (issue #18450): gateway sends "questions" array of {qid, question, choices, multi_select}.
|
||||||
payload?.get("question") as? String
|
val rawQuestions = payload?.get("questions") as? List<*>
|
||||||
?: payload?.get("text") as? String
|
|
||||||
val rawOptions =
|
|
||||||
payload?.get("choices")
|
|
||||||
?: payload?.get("options")
|
|
||||||
val clarifyId = payload?.get("clarify_id") as? String ?: payload?.get("request_id") as? String
|
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<String>() ?: 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<String>() ?: 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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
|
val legacyOptions =
|
||||||
WsEvent.ClarifyRequest(text, options, clarifyId, sessionId)
|
primary?.choices
|
||||||
|
?: (payload?.get("choices") ?: payload?.get("options"))
|
||||||
|
.let { (it as? List<*>)?.filterIsInstance<String>() }
|
||||||
|
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" -> {
|
"status.update" -> {
|
||||||
@@ -210,7 +283,40 @@ object EventParser {
|
|||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance<String>()
|
val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance<String>()
|
||||||
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<String>()
|
||||||
|
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" -> {
|
"sudo.request" -> {
|
||||||
@@ -218,9 +324,21 @@ object EventParser {
|
|||||||
WsEvent.SudoRequest(requestId, sessionId)
|
WsEvent.SudoRequest(requestId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"sudo.expire" -> {
|
||||||
|
val requestId = payload?.get("request_id") as? String
|
||||||
|
WsEvent.SudoExpire(requestId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
"secret.request" -> {
|
"secret.request" -> {
|
||||||
val requestId = payload?.get("request_id") as? String
|
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 -> {
|
else -> {
|
||||||
|
|||||||
@@ -1226,9 +1226,7 @@ object HermesWsClient {
|
|||||||
disconnectIfIdleInBackground()
|
disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {}
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// tryEmit on a DROP_OLDEST flow only returns false when the
|
// tryEmit on a DROP_OLDEST flow only returns false when the
|
||||||
// buffer is full AND no subscriber is draining; with extraBuffer=512
|
// buffer is full AND no subscriber is draining; with extraBuffer=512
|
||||||
|
|||||||
@@ -135,11 +135,26 @@ sealed class WsEvent {
|
|||||||
|
|
||||||
// ── Interactive ──────────────────────────────────────────────────────
|
// ── Interactive ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class ClarifyQuestion(
|
||||||
|
val qid: String = "q0",
|
||||||
|
val question: String = "",
|
||||||
|
val choices: List<String> = emptyList(),
|
||||||
|
val multiSelect: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
data class ClarifyRequest(
|
data class ClarifyRequest(
|
||||||
val text: String?,
|
val text: String?,
|
||||||
val options: List<String>?,
|
val options: List<String>?,
|
||||||
val clarifyId: String? = null,
|
val clarifyId: String? = null,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val questionId: String? = null,
|
||||||
|
val multiSelect: Boolean = false,
|
||||||
|
val questions: List<ClarifyQuestion> = emptyList(),
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ClarifyExpire(
|
||||||
|
val clarifyId: String? = null,
|
||||||
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,6 +230,30 @@ sealed class WsEvent {
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val patternKeys: List<String>?,
|
val patternKeys: List<String>?,
|
||||||
val sessionId: String?,
|
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<String>? = 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()
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Sudo / secret requests ─────────────────────────────────────────
|
// ── Sudo / secret requests ─────────────────────────────────────────
|
||||||
@@ -229,14 +268,37 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
) : WsEvent()
|
) : 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
|
* Backend needs a secret value (password / token) to continue a turn
|
||||||
* (desktop: `secret.request` → `secret.respond {request_id, value}`).
|
* (desktop: `secret.request` → `secret.respond {request_id, value}`).
|
||||||
* Mobile previously dropped this and the agent hung forever.
|
* Mobile previously dropped this and the agent hung forever.
|
||||||
|
* `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle,
|
||||||
|
* body = prompt ?: secretDesc.
|
||||||
*/
|
*/
|
||||||
data class SecretRequest(
|
data class SecretRequest(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: 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()
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Gateway-level errors ───────────────────────────────────────────
|
// ── Gateway-level errors ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ object WsMethods {
|
|||||||
const val PROMPT_BTW = "prompt.btw"
|
const val PROMPT_BTW = "prompt.btw"
|
||||||
const val CLARIFY_RESPOND = "clarify.respond"
|
const val CLARIFY_RESPOND = "clarify.respond"
|
||||||
const val APPROVAL_RESPOND = "approval.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 SUDO_RESPOND = "sudo.respond"
|
||||||
const val SECRET_RESPOND = "secret.respond"
|
const val SECRET_RESPOND = "secret.respond"
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,26 @@ class ChatNotificationService : Service() {
|
|||||||
showReplyNotification(getString(R.string.notif_clarification_needed), null)
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Dynamic (Material You) color on API 31+ overrides the preset scheme when
|
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
|
||||||
`useDynamicColors = true`. Semantic status colors are always resolved from the
|
`useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the
|
||||||
active preset via `LocalHermesStatusColors`.
|
active preset via `LocalHermesStatusColors`.
|
||||||
|
|
||||||
## Adding a new theme
|
## Adding a new theme
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ internal fun resolveStatusColors(
|
|||||||
@Composable
|
@Composable
|
||||||
fun HermesControlTheme(
|
fun HermesControlTheme(
|
||||||
themePreference: ThemePreference = LocalThemePreference.current,
|
themePreference: ThemePreference = LocalThemePreference.current,
|
||||||
useDynamicColors: Boolean = true,
|
useDynamicColors: Boolean = false,
|
||||||
themePreset: ThemePreset = ThemePreset.DEFAULT,
|
themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||||
chatFontScale: Float = LocalChatFontScale.current,
|
chatFontScale: Float = LocalChatFontScale.current,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ data class GroupChatToolCall(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val summary: String? = null,
|
val summary: String? = null,
|
||||||
val isRunning: Boolean = false,
|
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 isStreaming: Boolean = false,
|
||||||
val thread: String? = null,
|
val thread: String? = null,
|
||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
|
val isPass: Boolean = false,
|
||||||
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
val toolCalls: List<GroupChatToolCall> = 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.
|
* Pure helper for mention extraction and responder resolution matching Desktop parity.
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
|||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -52,11 +53,14 @@ data class MemberSession(
|
|||||||
|
|
||||||
const val DEFAULT_MAX_BOT_MESSAGES = 6
|
const val DEFAULT_MAX_BOT_MESSAGES = 6
|
||||||
const val DEFAULT_MAX_CONTINUATION_PASSES = 2
|
const val DEFAULT_MAX_CONTINUATION_PASSES = 2
|
||||||
private const val LEASE_STEP_TTL_MS = 45_000L
|
const val MAX_ALLOWED_BOT_MESSAGES = 50
|
||||||
private const val TURN_TIMEOUT_MS = 45_000L
|
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 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(
|
class GroupChatViewModel(
|
||||||
private var groupName: String = "",
|
private var groupName: String = "",
|
||||||
@@ -84,6 +88,9 @@ class GroupChatViewModel(
|
|||||||
// Map of session_id -> bot profile
|
// Map of session_id -> bot profile
|
||||||
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
|
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
|
||||||
|
|
||||||
|
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
|
||||||
|
private val sessionLastActivity = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (groupName.isNotBlank()) {
|
if (groupName.isNotBlank()) {
|
||||||
loadGroup()
|
loadGroup()
|
||||||
@@ -104,10 +111,26 @@ class GroupChatViewModel(
|
|||||||
inFlightTurns.clear()
|
inFlightTurns.clear()
|
||||||
activeStreamMsgId.clear()
|
activeStreamMsgId.clear()
|
||||||
sessionToBot.clear()
|
sessionToBot.clear()
|
||||||
|
sessionLastActivity.clear()
|
||||||
_uiState.value = GroupChatUiState(groupName = name)
|
_uiState.value = GroupChatUiState(groupName = name)
|
||||||
loadGroup()
|
loadGroup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractToolCommand(data: Map<String, Any?>?): 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, Any?>?): String? {
|
private fun extractToolSummary(data: Map<String, Any?>?): String? {
|
||||||
if (data == null) return null
|
if (data == null) return null
|
||||||
val cmd = (data["command"] as? String)?.trim()
|
val cmd = (data["command"] as? String)?.trim()
|
||||||
@@ -121,6 +144,21 @@ class GroupChatViewModel(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractToolOutput(data: Map<String, Any?>?): 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() {
|
private fun observeWsEvents() {
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
HermesWsClient.events.collect { event ->
|
HermesWsClient.events.collect { event ->
|
||||||
@@ -128,6 +166,7 @@ class GroupChatViewModel(
|
|||||||
is WsEvent.MessageToken -> {
|
is WsEvent.MessageToken -> {
|
||||||
val sid = event.sessionId?.trim('\"', ' ')
|
val sid = event.sessionId?.trim('\"', ' ')
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
|
sessionLastActivity[sid] = System.currentTimeMillis()
|
||||||
val streamId = activeStreamMsgId[sid]
|
val streamId = activeStreamMsgId[sid]
|
||||||
val bot = sessionToBot[sid]
|
val bot = sessionToBot[sid]
|
||||||
if (streamId != null && bot != null && event.token.isNotEmpty()) {
|
if (streamId != null && bot != null && event.token.isNotEmpty()) {
|
||||||
@@ -163,6 +202,7 @@ class GroupChatViewModel(
|
|||||||
is WsEvent.ToolStart -> {
|
is WsEvent.ToolStart -> {
|
||||||
val sid = event.sessionId?.trim('\"', ' ')
|
val sid = event.sessionId?.trim('\"', ' ')
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
|
sessionLastActivity[sid] = System.currentTimeMillis()
|
||||||
val streamId = activeStreamMsgId[sid]
|
val streamId = activeStreamMsgId[sid]
|
||||||
val bot = sessionToBot[sid]
|
val bot = sessionToBot[sid]
|
||||||
val toolName = event.name ?: "tool"
|
val toolName = event.name ?: "tool"
|
||||||
@@ -170,11 +210,13 @@ class GroupChatViewModel(
|
|||||||
(event.data?.get("tool_id") as? String)?.ifBlank { null }
|
(event.data?.get("tool_id") as? String)?.ifBlank { null }
|
||||||
?: UUID.randomUUID().toString()
|
?: UUID.randomUUID().toString()
|
||||||
val summary = extractToolSummary(event.data)
|
val summary = extractToolSummary(event.data)
|
||||||
|
val command = extractToolCommand(event.data)
|
||||||
val toolCall =
|
val toolCall =
|
||||||
GroupChatToolCall(
|
GroupChatToolCall(
|
||||||
id = toolId,
|
id = toolId,
|
||||||
name = toolName,
|
name = toolName,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
|
command = command,
|
||||||
isRunning = true,
|
isRunning = true,
|
||||||
)
|
)
|
||||||
if (streamId != null && bot != null) {
|
if (streamId != null && bot != null) {
|
||||||
@@ -211,9 +253,21 @@ class GroupChatViewModel(
|
|||||||
is WsEvent.ToolComplete -> {
|
is WsEvent.ToolComplete -> {
|
||||||
val sid = event.sessionId?.trim('\"', ' ')
|
val sid = event.sessionId?.trim('\"', ' ')
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
|
sessionLastActivity[sid] = System.currentTimeMillis()
|
||||||
val streamId = activeStreamMsgId[sid]
|
val streamId = activeStreamMsgId[sid]
|
||||||
val toolId = event.data?.get("tool_id") as? String
|
val toolId = event.data?.get("tool_id") as? String
|
||||||
val toolName = event.name
|
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) {
|
if (streamId != null) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val existing = state.messages.find { it.id == streamId }
|
val existing = state.messages.find { it.id == streamId }
|
||||||
@@ -224,7 +278,12 @@ class GroupChatViewModel(
|
|||||||
(toolId == null && tool.name == toolName && tool.isRunning) ||
|
(toolId == null && tool.name == toolName && tool.isRunning) ||
|
||||||
(toolId == null && toolName == null && 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 {
|
} else {
|
||||||
tool
|
tool
|
||||||
}
|
}
|
||||||
@@ -424,8 +483,8 @@ class GroupChatViewModel(
|
|||||||
maxPasses: Int,
|
maxPasses: Int,
|
||||||
systemPrompt: String? = null,
|
systemPrompt: String? = null,
|
||||||
) {
|
) {
|
||||||
val clampedMessages = maxMessages.coerceIn(1, 20)
|
val clampedMessages = maxMessages.coerceIn(1, MAX_ALLOWED_BOT_MESSAGES)
|
||||||
val clampedPasses = maxPasses.coerceIn(0, 10)
|
val clampedPasses = maxPasses.coerceIn(0, MAX_ALLOWED_CONTINUATION_PASSES)
|
||||||
val cleanPrompt = systemPrompt?.trim()?.ifBlank { null }
|
val cleanPrompt = systemPrompt?.trim()?.ifBlank { null }
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -681,10 +740,34 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var replyText =
|
var replyText: String? = null
|
||||||
withTimeoutOrNull(TURN_TIMEOUT_MS) {
|
var deadline = turnStartTime + GROUP_TURN_TIMEOUT_MS
|
||||||
turnDeferred.await()
|
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)
|
inFlightTurns.remove(runtimeId)
|
||||||
storedId?.let { inFlightTurns.remove(it) }
|
storedId?.let { inFlightTurns.remove(it) }
|
||||||
|
|
||||||
@@ -698,6 +781,7 @@ class GroupChatViewModel(
|
|||||||
sessionId = tid,
|
sessionId = tid,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
order = "latest",
|
order = "latest",
|
||||||
|
profile = bot.name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (res is NetworkResult.Success) {
|
if (res is NetworkResult.Success) {
|
||||||
@@ -751,6 +835,28 @@ class GroupChatViewModel(
|
|||||||
state.copy(messages = updatedList)
|
state.copy(messages = updatedList)
|
||||||
}
|
}
|
||||||
return replyText.trim()
|
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 {
|
} else {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
||||||
@@ -768,11 +874,13 @@ class GroupChatViewModel(
|
|||||||
activeStreamMsgId.remove(it)
|
activeStreamMsgId.remove(it)
|
||||||
sessionToBot.remove(it)
|
sessionToBot.remove(it)
|
||||||
inFlightTurns.remove(it)
|
inFlightTurns.remove(it)
|
||||||
|
sessionLastActivity.remove(it)
|
||||||
}
|
}
|
||||||
activeStoredId?.let {
|
activeStoredId?.let {
|
||||||
activeStreamMsgId.remove(it)
|
activeStreamMsgId.remove(it)
|
||||||
sessionToBot.remove(it)
|
sessionToBot.remove(it)
|
||||||
inFlightTurns.remove(it)
|
inFlightTurns.remove(it)
|
||||||
|
sessionLastActivity.remove(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.Psychology
|
import androidx.compose.material.icons.filled.Psychology
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -41,7 +44,9 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
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.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
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.LightOnSurface
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.onColorFor
|
import com.m57.hermescontrol.theme.onColorFor
|
||||||
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -176,67 +185,79 @@ fun ChatBubble(
|
|||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
||||||
SelectionContainer {
|
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Text(
|
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||||
text = highlightedText,
|
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||||
color = userBubbleTextColor,
|
SelectionContainer {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
)
|
text = highlightedText,
|
||||||
}
|
color = userBubbleTextColor,
|
||||||
// Render inline attachments
|
style =
|
||||||
if (!message.attachments.isNullOrEmpty()) {
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
textDirection =
|
||||||
message.attachments.forEach { attachment ->
|
if (isRtl) {
|
||||||
InlineAttachment(
|
TextDirection.Rtl
|
||||||
attachment = attachment,
|
} else {
|
||||||
textColor = userBubbleTextColor,
|
TextDirection.Ltr
|
||||||
onOpen = { onOpenAttachment(it) },
|
},
|
||||||
onSave = { onSaveAttachment(it) },
|
),
|
||||||
savingPath = savingAttachmentPath,
|
|
||||||
openingPath = openingAttachmentPath,
|
|
||||||
canSave = canSaveAttachment,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
}
|
||||||
}
|
// Render inline attachments
|
||||||
if (!message.isStreaming) {
|
if (!message.attachments.isNullOrEmpty()) {
|
||||||
Row(
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
modifier =
|
message.attachments.forEach { attachment ->
|
||||||
Modifier
|
InlineAttachment(
|
||||||
.align(Alignment.End)
|
attachment = attachment,
|
||||||
.padding(top = 4.dp),
|
textColor = userBubbleTextColor,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onOpen = { onOpenAttachment(it) },
|
||||||
) {
|
onSave = { onSaveAttachment(it) },
|
||||||
IconButton(
|
savingPath = savingAttachmentPath,
|
||||||
onClick = {
|
openingPath = openingAttachmentPath,
|
||||||
scope.launch {
|
canSave = canSaveAttachment,
|
||||||
clipboard.setClipEntry(
|
onImageClick = onImageClick,
|
||||||
ClipEntry(ClipData.newPlainText(null, message.content)),
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
copied = true
|
}
|
||||||
},
|
if (!message.isStreaming) {
|
||||||
modifier = Modifier.size(20.dp),
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(
|
||||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
onClick = {
|
||||||
contentDescription = stringResource(R.string.content_desc_copy),
|
scope.launch {
|
||||||
modifier = Modifier.size(12.dp),
|
clipboard.setClipEntry(
|
||||||
tint = userBubbleTextColor.copy(alpha = 0.7f),
|
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
|
@Composable
|
||||||
internal fun SystemBubble(
|
internal fun SystemBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
@@ -315,6 +337,9 @@ internal fun SystemBubble(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val approvalInfo = message.approvalInfo
|
||||||
|
var confirmAlways by remember(message.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -331,52 +356,145 @@ internal fun SystemBubble(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Approval action buttons
|
// Approval action buttons — dynamic from backend `choices`
|
||||||
if (message.approvalInfo != null) {
|
// (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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
val rawChoices = approvalInfo.choices ?: listOf("once", "deny")
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
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(
|
for (choice in visibleChoices) {
|
||||||
onClick = { onRespondApproval("approve") },
|
when (choice) {
|
||||||
modifier =
|
"deny" -> {
|
||||||
Modifier
|
FilledTonalButton(
|
||||||
.height(36.dp)
|
onClick = { onRespondApproval("deny") },
|
||||||
.testTag("approve_button"),
|
modifier =
|
||||||
colors =
|
Modifier
|
||||||
ButtonDefaults.filledTonalButtonColors(
|
.height(36.dp)
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
.testTag("deny_button"),
|
||||||
),
|
colors =
|
||||||
) {
|
ButtonDefaults.filledTonalButtonColors(
|
||||||
Icon(
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
imageVector = Icons.Filled.Check,
|
),
|
||||||
contentDescription = null,
|
) {
|
||||||
modifier = Modifier.size(16.dp),
|
Icon(
|
||||||
)
|
imageVector = Icons.Filled.Close,
|
||||||
Spacer(Modifier.width(4.dp))
|
contentDescription = null,
|
||||||
Text("Approve")
|
modifier = Modifier.size(16.dp),
|
||||||
}
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FilledTonalButton(
|
"session" -> {
|
||||||
onClick = { onRespondApproval("deny") },
|
FilledTonalButton(
|
||||||
modifier =
|
onClick = { onRespondApproval("session") },
|
||||||
Modifier
|
modifier =
|
||||||
.height(36.dp)
|
Modifier
|
||||||
.testTag("deny_button"),
|
.height(36.dp)
|
||||||
colors =
|
.testTag("session_button"),
|
||||||
ButtonDefaults.filledTonalButtonColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
ButtonDefaults.filledTonalButtonColors(
|
||||||
),
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
) {
|
),
|
||||||
Icon(
|
) {
|
||||||
imageVector = Icons.Filled.Close,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = Icons.Filled.Check,
|
||||||
modifier = Modifier.size(16.dp),
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(16.dp),
|
||||||
Spacer(Modifier.width(4.dp))
|
)
|
||||||
Text("Deny")
|
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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import java.util.UUID
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata for a [ChatMessage] that represents an approval request.
|
* 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.
|
* Transient — not persisted to SQLite.
|
||||||
*/
|
*/
|
||||||
data class ApprovalInfo(
|
data class ApprovalInfo(
|
||||||
val command: String?,
|
val command: String?,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val patternKeys: List<String>?,
|
val patternKeys: List<String>?,
|
||||||
|
/** 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<String>? = 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.
|
* them as centered timeline chips instead of user bubbles.
|
||||||
*/
|
*/
|
||||||
val displayKind: String? = null,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,4 +33,9 @@ open class ChatPersistenceRepository(
|
|||||||
/** Load cached messages for a session from Room. */
|
/** Load cached messages for a session from Room. */
|
||||||
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
||||||
dao.getMessagesForSession(sessionId).map { it.toUiModel() }
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -289,6 +290,14 @@ fun ChatScreen(
|
|||||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
mutableStateOf(TextFieldValue(""))
|
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 isListening by rememberSaveable { mutableStateOf(false) }
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -711,6 +720,7 @@ fun ChatScreen(
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest,
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
|
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
@@ -806,7 +816,6 @@ fun ChatScreen(
|
|||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
availableBots = state.availableBots,
|
|
||||||
slashUsageCounts = state.slashUsageCounts,
|
slashUsageCounts = state.slashUsageCounts,
|
||||||
pendingAttachments = state.pendingAttachments,
|
pendingAttachments = state.pendingAttachments,
|
||||||
onCameraTap = {
|
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) {
|
if (showContextSheet && state.contextBreakdown != null) {
|
||||||
ContextDetailSheet(
|
ContextDetailSheet(
|
||||||
breakdown = state.contextBreakdown!!,
|
breakdown = state.contextBreakdown!!,
|
||||||
|
|||||||
@@ -312,7 +312,6 @@ data class ChatUiState(
|
|||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
// Commands catalog
|
// Commands catalog
|
||||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
val availableBots: List<ProfileInfo> = emptyList(),
|
|
||||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||||
// #865). Empty until the local store loads; commands without recorded
|
// #865). Empty until the local store loads; commands without recorded
|
||||||
// usage keep their catalog order.
|
// usage keep their catalog order.
|
||||||
@@ -327,6 +326,7 @@ data class ChatUiState(
|
|||||||
val modelPickerProviders: List<ModelProvider> = emptyList(),
|
val modelPickerProviders: List<ModelProvider> = emptyList(),
|
||||||
val modelPickerPinned: List<PinnedModel> = emptyList(),
|
val modelPickerPinned: List<PinnedModel> = emptyList(),
|
||||||
val modelPickerLoading: Boolean = false,
|
val modelPickerLoading: Boolean = false,
|
||||||
|
val modelSwitchConfirmMessage: String? = null,
|
||||||
// Current session's active model label (provider/model), shown in the chip
|
// Current session's active model label (provider/model), shown in the chip
|
||||||
val currentSessionModel: String? = null,
|
val currentSessionModel: String? = null,
|
||||||
// Per-model reasoning capabilities for the current session's model
|
// 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)
|
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
|
||||||
val resumeError: String? = null,
|
val resumeError: String? = null,
|
||||||
val isResumeRetrying: Boolean = false,
|
val isResumeRetrying: Boolean = false,
|
||||||
|
/** Text staged to prefill the composer (e.g. from /undo). */
|
||||||
|
val pendingPrefillText: String? = null,
|
||||||
) {
|
) {
|
||||||
/** Convenience — derived from [connectionStatus]. */
|
/** Convenience — derived from [connectionStatus]. */
|
||||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||||
@@ -375,11 +377,40 @@ data class SessionUi(
|
|||||||
val depth: Int = 0,
|
val depth: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ClarifyQuestionUi(
|
||||||
|
val qid: String = "q0",
|
||||||
|
val question: String = "",
|
||||||
|
val choices: List<String> = emptyList(),
|
||||||
|
val multiSelect: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
data class ClarifyUi(
|
data class ClarifyUi(
|
||||||
val text: String,
|
val text: String,
|
||||||
val options: List<String>,
|
val options: List<String> = emptyList(),
|
||||||
val clarifyId: String? = null,
|
val clarifyId: String? = null,
|
||||||
)
|
val questionId: String? = null,
|
||||||
|
val multiSelect: Boolean = false,
|
||||||
|
val questions: List<ClarifyQuestionUi> = emptyList(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Normalized list of questions to display. Guarantees at least one question
|
||||||
|
* entry even for legacy single-question clarify events.
|
||||||
|
*/
|
||||||
|
val resolvedQuestions: List<ClarifyQuestionUi>
|
||||||
|
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`).
|
* State for the context-aware side-question bottom sheet (issue #1015, `/btw`).
|
||||||
@@ -413,6 +444,8 @@ data class SudoPromptUi(
|
|||||||
data class SecretPromptUi(
|
data class SecretPromptUi(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
|
val envVar: String? = null,
|
||||||
|
val prompt: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -462,6 +495,15 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||||
private var sessionGeneration = 0L
|
private var sessionGeneration = 0L
|
||||||
|
|
||||||
|
private data class ActiveModelSwitch(
|
||||||
|
val spec: String,
|
||||||
|
val previousModel: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
|
||||||
|
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
|
||||||
|
private var optimisticPreviousModel: String? = null
|
||||||
private var resumeRequestSequence = 0L
|
private var resumeRequestSequence = 0L
|
||||||
private var activeResumeRequestSequence = 0L
|
private var activeResumeRequestSequence = 0L
|
||||||
private var hydrationRequestSequence = 0L
|
private var hydrationRequestSequence = 0L
|
||||||
@@ -788,9 +830,7 @@ class ChatViewModel(
|
|||||||
streamingController.flushPendingTokens()
|
streamingController.flushPendingTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {}
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, let the reducer compute the new state and any effects
|
// 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.
|
// chip stays hidden until the real window lands.
|
||||||
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
|
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)
|
handleSudoRequest(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.SudoExpire -> {
|
||||||
|
handleSudoExpire(event)
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.SecretRequest -> {
|
is WsEvent.SecretRequest -> {
|
||||||
handleSecretRequest(event)
|
handleSecretRequest(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.SecretExpire -> {
|
||||||
|
handleSecretExpire(event)
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.GatewayError -> {
|
is WsEvent.GatewayError -> {
|
||||||
// Reducer already set errorMessage; no extra VM work needed.
|
// Reducer already set errorMessage; no extra VM work needed.
|
||||||
}
|
}
|
||||||
@@ -1171,6 +1236,26 @@ class ChatViewModel(
|
|||||||
val generation = request?.generation ?: sessionGeneration
|
val generation = request?.generation ?: sessionGeneration
|
||||||
resumedGeneration = generation
|
resumedGeneration = generation
|
||||||
finishResumeWhenHydrated(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 -> {
|
WsMethods.SESSION_INTERRUPT -> {
|
||||||
@@ -1209,6 +1294,29 @@ class ChatViewModel(
|
|||||||
addSystemMessage("Approval submitted")
|
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)
|
handleSlashCommand(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"prefill" -> {
|
||||||
|
val message = map["message"] as? String ?: ""
|
||||||
|
val notice = map["notice"] as? String ?: ""
|
||||||
|
handlePrefillResult(message, notice)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val output = map["output"] as? String ?: map.toString()
|
val output = map["output"] as? String ?: map.toString()
|
||||||
addAssistantMessage(output)
|
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
|
// Block desktop/CLI-only + TUI-only commands that don't function on
|
||||||
// mobile (issue #576, deliverable #3). These are also hidden from the
|
// mobile (issue #576, deliverable #3). These are also hidden from the
|
||||||
// suggestion menu, but a user can still type one — intercept it here
|
// suggestion menu, but a user can still type one — intercept it here
|
||||||
@@ -1654,6 +1773,10 @@ class ChatViewModel(
|
|||||||
handleSideQuestionCommand(result.question)
|
handleSideQuestionCommand(result.question)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SlashResult.Undo -> {
|
||||||
|
handleUndoCommand(result.count)
|
||||||
|
}
|
||||||
|
|
||||||
is SlashResult.RpcDispatch -> {
|
is SlashResult.RpcDispatch -> {
|
||||||
dispatchViaRpc(command)
|
dispatchViaRpc(command)
|
||||||
}
|
}
|
||||||
@@ -1890,7 +2013,10 @@ class ChatViewModel(
|
|||||||
* `<model> --provider <slug> --session`
|
* `<model> --provider <slug> --session`
|
||||||
* (matching the TUI client's `modelValueForConfigSet`).
|
* (matching the TUI client's `modelValueForConfigSet`).
|
||||||
*/
|
*/
|
||||||
private fun handleModelSwitch(command: String) {
|
private fun handleModelSwitch(
|
||||||
|
command: String,
|
||||||
|
confirmExpensive: Boolean = false,
|
||||||
|
) {
|
||||||
val sessionId = runtimeSessionId
|
val sessionId = runtimeSessionId
|
||||||
if (sessionId == null) {
|
if (sessionId == null) {
|
||||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||||
@@ -1907,11 +2033,25 @@ class ChatViewModel(
|
|||||||
} else {
|
} else {
|
||||||
command.trim()
|
command.trim()
|
||||||
}
|
}
|
||||||
|
val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel
|
||||||
|
optimisticPreviousModel = null
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"key" to "model",
|
||||||
|
"value" to spec,
|
||||||
|
"session_id" to sessionId,
|
||||||
|
)
|
||||||
|
if (confirmExpensive) {
|
||||||
|
params["confirm_expensive_model"] = true
|
||||||
|
}
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
WsMethods.CONFIG_SET,
|
WsMethods.CONFIG_SET,
|
||||||
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
|
params,
|
||||||
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
|
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 ───────────────────────────────────────────────
|
// ── Session management ───────────────────────────────────────────────
|
||||||
|
|
||||||
fun interruptSession() {
|
fun interruptSession() {
|
||||||
@@ -2082,15 +2283,6 @@ class ChatViewModel(
|
|||||||
WsMethods.COMMANDS_CATALOG,
|
WsMethods.COMMANDS_CATALOG,
|
||||||
onSent = { id -> trackRequest(id, 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,
|
provider: String,
|
||||||
model: String,
|
model: String,
|
||||||
) {
|
) {
|
||||||
|
optimisticPreviousModel = _uiState.value.currentSessionModel
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
showModelPicker = false,
|
showModelPicker = false,
|
||||||
@@ -2281,6 +2474,28 @@ class ChatViewModel(
|
|||||||
handleSlashCommand("/model $model --provider $provider --session")
|
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.
|
* Set the reasoning effort level for the current session.
|
||||||
*
|
*
|
||||||
@@ -2529,7 +2744,11 @@ class ChatViewModel(
|
|||||||
private fun sealStreamingMessageIfAny() {
|
private fun sealStreamingMessageIfAny() {
|
||||||
val streaming = _streamingState.value.streamingMessage ?: return
|
val streaming = _streamingState.value.streamingMessage ?: return
|
||||||
if (streaming.content.isBlank() && streaming.reasoningText.isBlank()) 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()) }
|
_uiState.update { it.copy(messages = (it.messages + finalized).dedupeById()) }
|
||||||
val sid = _uiState.value.currentSessionId
|
val sid = _uiState.value.currentSessionId
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
@@ -2576,6 +2795,7 @@ class ChatViewModel(
|
|||||||
showSessionPicker = false,
|
showSessionPicker = false,
|
||||||
showModelPicker = false,
|
showModelPicker = false,
|
||||||
modelPickerLoading = false,
|
modelPickerLoading = false,
|
||||||
|
modelSwitchConfirmMessage = null,
|
||||||
currentSessionModel = null,
|
currentSessionModel = null,
|
||||||
currentModelCapabilities = null,
|
currentModelCapabilities = null,
|
||||||
reasoningLevel = null,
|
reasoningLevel = null,
|
||||||
@@ -2590,6 +2810,7 @@ class ChatViewModel(
|
|||||||
todos = emptyList(),
|
todos = emptyList(),
|
||||||
resumeError = null,
|
resumeError = null,
|
||||||
isResumeRetrying = false,
|
isResumeRetrying = false,
|
||||||
|
pendingPrefillText = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return generation
|
return generation
|
||||||
@@ -3235,39 +3456,107 @@ class ChatViewModel(
|
|||||||
*/
|
*/
|
||||||
fun dismissClarify() {
|
fun dismissClarify() {
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
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) }
|
_uiState.update { it.copy(clarifyRequest = null) }
|
||||||
|
|
||||||
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
|
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
val params =
|
if (isBatch) {
|
||||||
mutableMapOf<String, Any>(
|
// Send dismissal for every question in the batch
|
||||||
"session_id" to sessionId,
|
for (q in displayQuestions) {
|
||||||
"response" to CLARIFY_DISMISS_RESPONSE,
|
val params =
|
||||||
"answer" to CLARIFY_DISMISS_RESPONSE,
|
mutableMapOf<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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) {
|
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<String, String>,
|
||||||
|
singleFallbackAnswer: String? = null,
|
||||||
|
) {
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
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) }
|
_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 =
|
val userMessage =
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role = MessageRole.USER,
|
role = MessageRole.USER,
|
||||||
content = option,
|
content = displayContent,
|
||||||
)
|
)
|
||||||
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
@@ -3282,21 +3571,49 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
val params =
|
if (isBatch) {
|
||||||
mutableMapOf<String, Any>(
|
for (q in questions) {
|
||||||
"session_id" to sessionId,
|
val ans = answers[q.qid]?.trim().orEmpty()
|
||||||
"response" to option,
|
val params =
|
||||||
"answer" to option,
|
mutableMapOf<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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,
|
command = event.command,
|
||||||
description = event.description,
|
description = event.description,
|
||||||
patternKeys = event.patternKeys,
|
patternKeys = event.patternKeys,
|
||||||
|
requestId = event.requestId,
|
||||||
|
choices = event.choices,
|
||||||
|
allowPermanent = event.allowPermanent,
|
||||||
|
smartDenied = event.smartDenied,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
@@ -3335,12 +3656,107 @@ class ChatViewModel(
|
|||||||
isAgentTyping = false,
|
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<String>()
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>()
|
||||||
|
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<Map<*, *>>()?.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) {
|
fun respondToApproval(action: String) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
|
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
|
// Clear buttons immediately
|
||||||
_uiState.update { s ->
|
_uiState.update { s ->
|
||||||
@@ -3357,16 +3773,20 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"session_id" to sessionId,
|
||||||
|
"choice" to choice,
|
||||||
|
"all" to false,
|
||||||
|
)
|
||||||
|
if (requestId != null) params["request_id"] = requestId
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
method = WsMethods.APPROVAL_RESPOND,
|
method = WsMethods.APPROVAL_RESPOND,
|
||||||
params =
|
params = params,
|
||||||
mapOf(
|
|
||||||
"session_id" to sessionId,
|
|
||||||
"choice" to action,
|
|
||||||
"all" to false,
|
|
||||||
),
|
|
||||||
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
|
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 →
|
* The agent needs a secret value (token/password). Previously dropped →
|
||||||
* agent hung forever. Now we surface a secure dialog and reply via
|
* agent hung forever. Now we surface a secure dialog and reply via
|
||||||
@@ -3393,18 +3830,82 @@ class ChatViewModel(
|
|||||||
private fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
private fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
secretPrompt = SecretPromptUi(event.requestId, event.sessionId),
|
secretPrompt =
|
||||||
|
SecretPromptUi(
|
||||||
|
event.requestId,
|
||||||
|
event.sessionId,
|
||||||
|
event.envVar,
|
||||||
|
event.prompt,
|
||||||
|
),
|
||||||
isAgentTyping = false,
|
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<String, Any>(
|
||||||
|
"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() {
|
fun dismissSecret() {
|
||||||
|
val prompt = _uiState.value.secretPrompt ?: return
|
||||||
|
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
|
||||||
_uiState.update { it.copy(secretPrompt = null) }
|
_uiState.update { it.copy(secretPrompt = null) }
|
||||||
|
if (sessionId == null) return
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.ToolComplete -> event.sessionId
|
is WsEvent.ToolComplete -> event.sessionId
|
||||||
is WsEvent.ToolOutputRisk -> event.sessionId
|
is WsEvent.ToolOutputRisk -> event.sessionId
|
||||||
is WsEvent.ClarifyRequest -> event.sessionId
|
is WsEvent.ClarifyRequest -> event.sessionId
|
||||||
|
is WsEvent.ClarifyExpire -> event.sessionId
|
||||||
is WsEvent.ToolProgress -> event.sessionId
|
is WsEvent.ToolProgress -> event.sessionId
|
||||||
is WsEvent.ToolGenerating -> event.sessionId
|
is WsEvent.ToolGenerating -> event.sessionId
|
||||||
is WsEvent.SubagentEvent -> event.sessionId
|
is WsEvent.SubagentEvent -> event.sessionId
|
||||||
@@ -128,6 +129,8 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.ClarifyExpire -> onClarifyExpire(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.BtwComplete -> onBtwComplete(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)
|
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
|
||||||
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
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.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
|
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
@@ -206,7 +213,11 @@ object ChatWsEventReducer {
|
|||||||
var orphan: ChatMessage? = null
|
var orphan: ChatMessage? = null
|
||||||
val preState =
|
val preState =
|
||||||
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
|
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
|
orphan = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
messages = state.messages.upsertById(finalized),
|
messages = state.messages.upsertById(finalized),
|
||||||
@@ -369,10 +380,12 @@ object ChatWsEventReducer {
|
|||||||
content = text,
|
content = text,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
) ?: ChatMessage(
|
) ?: ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = text,
|
content = text,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -418,7 +431,12 @@ object ChatWsEventReducer {
|
|||||||
} else {
|
} else {
|
||||||
streaming.reasoningText
|
streaming.reasoningText
|
||||||
}
|
}
|
||||||
val msg = streaming.copy(isStreaming = false, reasoningText = reasoning)
|
val msg =
|
||||||
|
streaming.copy(
|
||||||
|
isStreaming = false,
|
||||||
|
reasoningText = reasoning,
|
||||||
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
@@ -481,6 +499,7 @@ object ChatWsEventReducer {
|
|||||||
streamingState.streamingMessage.copy(
|
streamingState.streamingMessage.copy(
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
orphanToPersist = finalized
|
orphanToPersist = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -624,8 +643,25 @@ object ChatWsEventReducer {
|
|||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
event: WsEvent.ClarifyRequest,
|
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 =
|
||||||
state.copy(
|
state.copy(
|
||||||
clarifyRequest =
|
clarifyRequest =
|
||||||
@@ -633,10 +669,28 @@ object ChatWsEventReducer {
|
|||||||
text = event.text.orEmpty(),
|
text = event.text.orEmpty(),
|
||||||
options = event.options.orEmpty(),
|
options = event.options.orEmpty(),
|
||||||
clarifyId = event.clarifyId,
|
clarifyId = event.clarifyId,
|
||||||
|
questionId = event.questionId,
|
||||||
|
multiSelect = event.multiSelect,
|
||||||
|
questions = questions,
|
||||||
),
|
),
|
||||||
isAgentTyping = false,
|
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) ──────────────
|
// ── ReviewSummary (Self-improvement background review) ──────────────
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
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.BaselineShift
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.hrm.latex.renderer.LatexAutoWrap
|
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.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||||
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 URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
|
||||||
private val TABLE_COL_WIDTH = 160.dp
|
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 ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
|
||||||
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\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.
|
* 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
|
* 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 statusColors = LocalHermesStatusColors.current
|
||||||
val highlights = searchHighlightColors(statusColors)
|
val highlights = searchHighlightColors(statusColors)
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
Text(
|
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||||
text = text,
|
val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
color = textColor,
|
CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
modifier = modifier,
|
text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text,
|
||||||
)
|
color = textColor,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
textDirection = bidiTextDirection(isRtl),
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,154 +157,191 @@ fun MarkdownText(
|
|||||||
5 -> 15.sp
|
5 -> 15.sp
|
||||||
else -> 14.sp
|
else -> 14.sp
|
||||||
}
|
}
|
||||||
MarkdownInlineText(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
text = block.text,
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
textColor = textColor,
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
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),
|
|
||||||
)
|
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium
|
||||||
|
.copy(
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textDirection = bidiTextDirection(isRtl),
|
||||||
|
),
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
linkColor = linkColor,
|
linkColor = linkColor,
|
||||||
highlights = highlights,
|
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 -> {
|
is MdBlock.Task -> {
|
||||||
val indent = (block.level * 16).dp
|
val indent = (block.level * 16).dp
|
||||||
Row(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
modifier =
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Modifier
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(start = indent)
|
modifier =
|
||||||
.padding(vertical = 1.dp),
|
Modifier
|
||||||
verticalAlignment = Alignment.Top,
|
.fillMaxWidth()
|
||||||
) {
|
.padding(start = indent)
|
||||||
Icon(
|
.padding(vertical = 1.dp),
|
||||||
imageVector =
|
verticalAlignment = Alignment.Top,
|
||||||
if (block.checked) {
|
) {
|
||||||
Icons.Outlined.CheckBox
|
Icon(
|
||||||
} else {
|
imageVector =
|
||||||
Icons.Outlined.CheckBoxOutlineBlank
|
if (block.checked) {
|
||||||
},
|
Icons.Outlined.CheckBox
|
||||||
contentDescription = null,
|
} else {
|
||||||
tint =
|
Icons.Outlined.CheckBoxOutlineBlank
|
||||||
if (block.checked) {
|
},
|
||||||
MaterialTheme.colorScheme.primary
|
contentDescription = null,
|
||||||
} else {
|
tint =
|
||||||
textColor.copy(
|
if (block.checked) {
|
||||||
alpha = 0.6f,
|
MaterialTheme.colorScheme.primary
|
||||||
)
|
} else {
|
||||||
},
|
textColor.copy(
|
||||||
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
|
alpha = 0.6f,
|
||||||
)
|
)
|
||||||
MarkdownInlineText(
|
},
|
||||||
text = block.text,
|
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
|
||||||
textColor = textColor,
|
)
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.weight(1f),
|
textDirection = bidiTextDirection(isRtl),
|
||||||
)
|
),
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Ordered -> {
|
is MdBlock.Ordered -> {
|
||||||
val indent = (block.level * 16).dp
|
val indent = (block.level * 16).dp
|
||||||
Row(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
modifier =
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Modifier
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(start = indent)
|
modifier =
|
||||||
.padding(vertical = 1.dp),
|
Modifier
|
||||||
verticalAlignment = Alignment.Top,
|
.fillMaxWidth()
|
||||||
) {
|
.padding(start = indent)
|
||||||
Text(
|
.padding(vertical = 1.dp),
|
||||||
text = "${block.index}.",
|
verticalAlignment = Alignment.Top,
|
||||||
color = textColor,
|
) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
modifier = Modifier.padding(end = 6.dp),
|
text = "${block.index}.",
|
||||||
)
|
color = textColor,
|
||||||
MarkdownInlineText(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
text = block.text,
|
modifier = Modifier.padding(end = 6.dp),
|
||||||
textColor = textColor,
|
)
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.weight(1f),
|
textDirection = bidiTextDirection(isRtl),
|
||||||
)
|
),
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Quote -> {
|
is MdBlock.Quote -> {
|
||||||
Row(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
) {
|
Row(
|
||||||
Box(
|
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
|
||||||
modifier =
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Modifier
|
) {
|
||||||
.width(3.dp)
|
Box(
|
||||||
.fillMaxHeight()
|
modifier =
|
||||||
.clip(RoundedCornerShape(2.dp))
|
Modifier
|
||||||
.background(textColor.copy(alpha = 0.35f)),
|
.width(3.dp)
|
||||||
)
|
.fillMaxHeight()
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
MarkdownInlineText(
|
.background(textColor.copy(alpha = 0.35f)),
|
||||||
text = block.text,
|
)
|
||||||
textColor = textColor,
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.weight(1f),
|
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 -> {
|
is MdBlock.DefList -> {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
val isRtl =
|
||||||
block.items.forEach { item ->
|
remember(block.items) {
|
||||||
Text(
|
block.items.any { item ->
|
||||||
text = item.term,
|
BidiUtils.isRtlText(item.term) ||
|
||||||
color = textColor,
|
item.definitions.any { BidiUtils.isRtlText(it) }
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
|
}
|
||||||
)
|
}
|
||||||
item.definitions.forEach { def ->
|
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(
|
||||||
text = def,
|
text = if (itemRtl) BidiUtils.anchorTrailingRtl(item.term) else item.term,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style =
|
||||||
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
|
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),
|
modifier = Modifier.padding(bottom = 2.dp),
|
||||||
)
|
)
|
||||||
block.notes.forEach { note ->
|
block.notes.forEach { note ->
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
val isRtl = remember(note.text) { BidiUtils.isRtlText(note.text) }
|
||||||
Text(
|
val noteDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
text = "[${note.id}] ",
|
CompositionLocalProvider(LocalLayoutDirection provides noteDirection) {
|
||||||
color = textColor,
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
Text(
|
||||||
)
|
text = "[${note.id}] ",
|
||||||
MarkdownInlineText(
|
color = textColor,
|
||||||
text = note.text,
|
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
||||||
textColor = textColor,
|
)
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = note.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
modifier = Modifier.weight(1f),
|
textDirection = bidiTextDirection(isRtl),
|
||||||
)
|
),
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Paragraph -> {
|
is MdBlock.Paragraph -> {
|
||||||
MarkdownInlineText(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
text = block.text,
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
textColor = textColor,
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
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,
|
highlights: SearchHighlightColors,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val segments = remember(text) { splitInlineMath(text) }
|
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||||
if (segments.none { it is InlineMathSegment.Math }) {
|
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Text(
|
val resolvedStyle =
|
||||||
text =
|
style.copy(
|
||||||
remember(text, searchQuery, isCurrentMatch, textColor, linkColor) {
|
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
|
|
||||||
},
|
|
||||||
color = textColor,
|
|
||||||
style = style,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
return
|
val processedText =
|
||||||
}
|
remember(text, isRtl) {
|
||||||
|
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
|
||||||
val config =
|
|
||||||
LatexConfig(
|
|
||||||
fontSize = style.fontSize,
|
|
||||||
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
|
||||||
accessibilityEnabled = true,
|
|
||||||
)
|
|
||||||
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
|
||||||
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 -> {
|
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||||
append(
|
val segments = remember(processedText) { splitInlineMath(processedText) }
|
||||||
parseInlineSource(
|
if (segments.none { it is InlineMathSegment.Math }) {
|
||||||
segment.value,
|
Text(
|
||||||
textColor,
|
text =
|
||||||
searchQuery,
|
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
|
||||||
isCurrentMatch,
|
parseInlineSource(
|
||||||
linkColor,
|
text = processedText,
|
||||||
highlights,
|
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<String, InlineTextContent>()
|
||||||
|
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(
|
text = annotated,
|
||||||
text = annotated,
|
inlineContent = inlineContent,
|
||||||
inlineContent = inlineContent,
|
color = textColor,
|
||||||
color = textColor,
|
style = resolvedStyle,
|
||||||
style = style,
|
modifier = modifier,
|
||||||
modifier = modifier,
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -526,40 +632,35 @@ private fun MarkdownTable(
|
|||||||
block: MdBlock.Table,
|
block: MdBlock.Table,
|
||||||
textColor: Color,
|
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 headerBg = textColor.copy(alpha = 0.08f)
|
||||||
val alignments = block.alignments
|
val alignments = block.alignments
|
||||||
Column(
|
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||||
modifier =
|
Column(
|
||||||
Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier
|
||||||
.horizontalScroll(rememberScrollState())
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.horizontalScroll(rememberScrollState())
|
||||||
) {
|
.padding(vertical = 4.dp),
|
||||||
// Header row
|
) {
|
||||||
Row(modifier = Modifier.background(headerBg)) {
|
// Header row
|
||||||
block.header.forEachIndexed { idx, cell ->
|
Row(modifier = Modifier.background(headerBg)) {
|
||||||
Text(
|
block.header.forEachIndexed { idx, cell ->
|
||||||
text = cell,
|
val cellRtl = BidiUtils.isRtlText(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 ->
|
|
||||||
Text(
|
Text(
|
||||||
text = cell,
|
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textDirection = bidiTextDirection(cellRtl),
|
||||||
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.width(TABLE_COL_WIDTH)
|
.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,
|
isCurrentMatch: Boolean,
|
||||||
linkColor: Color,
|
linkColor: Color,
|
||||||
highlights: SearchHighlightColors,
|
highlights: SearchHighlightColors,
|
||||||
|
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||||
): AnnotatedString =
|
): AnnotatedString =
|
||||||
parseInlineSource(
|
parseInlineSource(
|
||||||
text =
|
text =
|
||||||
@@ -1130,6 +1254,7 @@ internal fun parseInline(
|
|||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
linkColor = linkColor,
|
linkColor = linkColor,
|
||||||
highlights = highlights,
|
highlights = highlights,
|
||||||
|
isRtl = isRtl,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseInlineSource(
|
private fun parseInlineSource(
|
||||||
@@ -1139,6 +1264,7 @@ private fun parseInlineSource(
|
|||||||
isCurrentMatch: Boolean,
|
isCurrentMatch: Boolean,
|
||||||
linkColor: Color,
|
linkColor: Color,
|
||||||
highlights: SearchHighlightColors,
|
highlights: SearchHighlightColors,
|
||||||
|
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||||
): AnnotatedString {
|
): AnnotatedString {
|
||||||
val searchHighlightColor =
|
val searchHighlightColor =
|
||||||
if (isCurrentMatch) {
|
if (isCurrentMatch) {
|
||||||
@@ -1156,8 +1282,10 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("***", i) -> {
|
src.startsWith("***", i) -> {
|
||||||
val end = src.indexOf("***", i + 3)
|
val end = src.indexOf("***", i + 3)
|
||||||
if (end != -1) {
|
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)) {
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||||
append(src.substring(i + 3, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 3
|
i = end + 3
|
||||||
} else {
|
} else {
|
||||||
@@ -1170,8 +1298,10 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("**", i) -> {
|
src.startsWith("**", i) -> {
|
||||||
val end = src.indexOf("**", i + 2)
|
val end = src.indexOf("**", i + 2)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 2, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
append(src.substring(i + 2, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 2
|
i = end + 2
|
||||||
} else {
|
} else {
|
||||||
@@ -1184,8 +1314,10 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("~~", i) -> {
|
src.startsWith("~~", i) -> {
|
||||||
val end = src.indexOf("~~", i + 2)
|
val end = src.indexOf("~~", i + 2)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 2, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||||
append(src.substring(i + 2, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 2
|
i = end + 2
|
||||||
} else {
|
} else {
|
||||||
@@ -1198,8 +1330,10 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("*", i) -> {
|
src.startsWith("*", i) -> {
|
||||||
val end = src.indexOf('*', i + 1)
|
val end = src.indexOf('*', i + 1)
|
||||||
if (end != -1 && end > 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)) {
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
append(src.substring(i + 1, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 1
|
i = end + 1
|
||||||
} else {
|
} else {
|
||||||
@@ -1212,8 +1346,10 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("==", i) -> {
|
src.startsWith("==", i) -> {
|
||||||
val end = src.indexOf("==", i + 2)
|
val end = src.indexOf("==", i + 2)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 2, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
withStyle(SpanStyle(background = highlights.markupBackground)) {
|
withStyle(SpanStyle(background = highlights.markupBackground)) {
|
||||||
append(src.substring(i + 2, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 2
|
i = end + 2
|
||||||
} else {
|
} else {
|
||||||
@@ -1254,13 +1390,15 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("<kbd>", i) -> {
|
src.startsWith("<kbd>", i) -> {
|
||||||
val end = src.indexOf("</kbd>", i)
|
val end = src.indexOf("</kbd>", i)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 5, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
withStyle(
|
withStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
background = textColor.copy(alpha = 0.12f),
|
background = textColor.copy(alpha = 0.12f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
append(src.substring(i + 5, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 6
|
i = end + 6
|
||||||
} else {
|
} else {
|
||||||
@@ -1297,6 +1435,7 @@ private fun parseInlineSource(
|
|||||||
val urlEnd = src.indexOf(')', close + 2)
|
val urlEnd = src.indexOf(')', close + 2)
|
||||||
if (urlEnd != -1) {
|
if (urlEnd != -1) {
|
||||||
val label = src.substring(i + 1, close)
|
val label = src.substring(i + 1, close)
|
||||||
|
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
|
||||||
val url = src.substring(close + 2, urlEnd)
|
val url = src.substring(close + 2, urlEnd)
|
||||||
pushLink(LinkAnnotation.Url(url))
|
pushLink(LinkAnnotation.Url(url))
|
||||||
withStyle(
|
withStyle(
|
||||||
@@ -1305,7 +1444,7 @@ private fun parseInlineSource(
|
|||||||
textDecoration = TextDecoration.Underline,
|
textDecoration = TextDecoration.Underline,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
append(label)
|
append(labelToAppend)
|
||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
i = urlEnd + 1
|
i = urlEnd + 1
|
||||||
@@ -1323,6 +1462,8 @@ private fun parseInlineSource(
|
|||||||
src.startsWith("`", i) -> {
|
src.startsWith("`", i) -> {
|
||||||
val end = src.indexOf('`', i + 1)
|
val end = src.indexOf('`', i + 1)
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
|
val raw = src.substring(i + 1, end)
|
||||||
|
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
withStyle(
|
withStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
@@ -1330,7 +1471,7 @@ private fun parseInlineSource(
|
|||||||
background = textColor.copy(alpha = 0.08f),
|
background = textColor.copy(alpha = 0.08f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
append(src.substring(i + 1, end))
|
append(toAppend)
|
||||||
}
|
}
|
||||||
i = end + 1
|
i = end + 1
|
||||||
} else {
|
} else {
|
||||||
@@ -1343,6 +1484,7 @@ private fun parseInlineSource(
|
|||||||
URL_PATTERN.matchAt(src, i) != null -> {
|
URL_PATTERN.matchAt(src, i) != null -> {
|
||||||
val match = URL_PATTERN.matchAt(src, i)!!
|
val match = URL_PATTERN.matchAt(src, i)!!
|
||||||
val url = match.value
|
val url = match.value
|
||||||
|
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
|
||||||
pushLink(LinkAnnotation.Url(url))
|
pushLink(LinkAnnotation.Url(url))
|
||||||
withStyle(
|
withStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
@@ -1350,12 +1492,40 @@ private fun parseInlineSource(
|
|||||||
textDecoration = TextDecoration.Underline,
|
textDecoration = TextDecoration.Underline,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
append(url)
|
append(urlToAppend)
|
||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
i = match.range.last + 1
|
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
|
// search highlight
|
||||||
searchQuery.isNotEmpty() &&
|
searchQuery.isNotEmpty() &&
|
||||||
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ class SlashCommandDispatcher {
|
|||||||
SlashResult.SideQuestion(question = arg)
|
SlashResult.SideQuestion(question = arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"/undo" -> {
|
||||||
|
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||||
|
SlashResult.Undo(count = arg)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
SlashResult.RpcDispatch
|
SlashResult.RpcDispatch
|
||||||
}
|
}
|
||||||
@@ -143,4 +148,12 @@ sealed class SlashResult {
|
|||||||
data class SideQuestion(
|
data class SideQuestion(
|
||||||
val question: String,
|
val question: String,
|
||||||
) : SlashResult()
|
) : 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()
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
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.data.ws.CommandCatalog
|
||||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
import com.m57.hermescontrol.ui.common.BotAvatar
|
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,
|
* The chat input bar with a two-row layout: input+send on top,
|
||||||
@@ -85,7 +91,6 @@ fun ChatInputBar(
|
|||||||
commandCatalog: CommandCatalog,
|
commandCatalog: CommandCatalog,
|
||||||
slashUsageCounts: Map<String, Int> = emptyMap(),
|
slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||||
pendingAttachments: List<Attachment> = emptyList(),
|
pendingAttachments: List<Attachment> = emptyList(),
|
||||||
availableBots: List<ProfileInfo> = emptyList(),
|
|
||||||
onCameraTap: () -> Unit = {},
|
onCameraTap: () -> Unit = {},
|
||||||
onImageTap: () -> Unit = {},
|
onImageTap: () -> Unit = {},
|
||||||
onFileTap: () -> 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
|
// Attachment preview chips
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = pendingAttachments.isNotEmpty(),
|
visible = pendingAttachments.isNotEmpty(),
|
||||||
@@ -318,91 +239,129 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BasicTextField(
|
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||||
value = inputFieldValue,
|
val inputLayoutDirection =
|
||||||
onValueChange = onInputChange,
|
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||||
modifier =
|
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
|
||||||
Modifier
|
}
|
||||||
.weight(1f)
|
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||||
.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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send button INSIDE the field. Shown whenever a send is
|
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||||
// possible — text typed OR an attachment pending (issue
|
BasicTextField(
|
||||||
// #956): the old text-only gate hid the button entirely
|
value = inputFieldValue,
|
||||||
// for attachment-only sends.
|
onValueChange = onInputChange,
|
||||||
AnimatedContent(
|
modifier =
|
||||||
targetState = canSend,
|
Modifier
|
||||||
transitionSpec = {
|
.weight(1f)
|
||||||
(scaleIn(initialScale = 0.8f) + fadeIn())
|
.heightIn(min = 42.dp, max = 120.dp)
|
||||||
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
|
.padding(vertical = 4.dp)
|
||||||
},
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
label = "send_toggle",
|
.testTag("chat_input"),
|
||||||
) { showSend ->
|
enabled = isConnected,
|
||||||
if (showSend) {
|
textStyle =
|
||||||
IconButton(
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
onClick = onSend,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
enabled = canSend,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
modifier =
|
),
|
||||||
Modifier
|
singleLine = false,
|
||||||
.size(36.dp)
|
maxLines = 4,
|
||||||
.testTag("send_button"),
|
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(
|
CompositionLocalProvider(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
LocalLayoutDirection provides inputLayoutDirection,
|
||||||
contentDescription = stringResource(R.string.chat_send_desc),
|
) {
|
||||||
)
|
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 ──
|
// ── BOTTOM ROW: Toolbar ──
|
||||||
|
|||||||
@@ -87,20 +87,25 @@ fun SudoPromptDialog(
|
|||||||
fun SecretPromptDialog(
|
fun SecretPromptDialog(
|
||||||
onConfirm: (String) -> Unit,
|
onConfirm: (String) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
envVar: String? = null,
|
||||||
|
prompt: String? = null,
|
||||||
) {
|
) {
|
||||||
var secret by remember { mutableStateOf("") }
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.chat_secret_title)) },
|
title = { Text(titleText) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(text = stringResource(R.string.chat_secret_body))
|
Text(text = bodyText)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
label = { Text(stringResource(R.string.chat_secret_value)) },
|
label = { Text(labelText) },
|
||||||
modifier = Modifier.fillMaxWidth().testTag("secret_value_input"),
|
modifier = Modifier.fillMaxWidth().testTag("secret_value_input"),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ fun ChatLifecycleEffects(
|
|||||||
SecretPromptDialog(
|
SecretPromptDialog(
|
||||||
onConfirm = viewModel::respondToSecret,
|
onConfirm = viewModel::respondToSecret,
|
||||||
onDismiss = viewModel::dismissSecret,
|
onDismiss = viewModel::dismissSecret,
|
||||||
|
envVar = prompt.envVar,
|
||||||
|
prompt = prompt.prompt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,8 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.CodeTerminalBorder
|
||||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||||
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@@ -429,19 +432,27 @@ private fun copyToClipboard(
|
|||||||
// ── ClarifyBubble ─────────────────────────────────────────────────────────
|
// ── ClarifyBubble ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centered dashed-border bubble showing a clarify question with
|
* Centered dashed-border bubble showing a clarify question (or batch of questions) with
|
||||||
* selectable option chips and a dismiss button.
|
* selectable option chips, multi-select toggles, open-ended input, and a dismiss button.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ClarifyBubble(
|
fun ClarifyBubble(
|
||||||
text: String,
|
clarifyRequest: ClarifyUi,
|
||||||
options: List<String>,
|
onRespondSingle: (String) -> Unit,
|
||||||
onOptionSelected: (String) -> Unit,
|
onRespondBatch: (Map<String, String>) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var typedText by remember { mutableStateOf("") }
|
val questions = clarifyRequest.resolvedQuestions
|
||||||
|
val isBatch = questions.size > 1
|
||||||
|
|
||||||
|
var selectedChoicesByQid by remember(clarifyRequest) {
|
||||||
|
mutableStateOf<Map<String, Set<String>>>(emptyMap())
|
||||||
|
}
|
||||||
|
var customTextByQid by remember(clarifyRequest) {
|
||||||
|
mutableStateOf<Map<String, String>>(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -458,57 +469,170 @@ fun ClarifyBubble(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(14.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
questions.forEachIndexed { index, q ->
|
||||||
text = text,
|
if (index > 0) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
HorizontalDivider(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
)
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
if (options.isNotEmpty()) {
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
}
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
if (isBatch) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Text(
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
text = "${index + 1}. ${q.question}",
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
options.forEach { option ->
|
fontWeight = FontWeight.SemiBold,
|
||||||
SuggestionChip(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = { onOptionSelected(option) },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text(option) },
|
)
|
||||||
colors =
|
} else {
|
||||||
SuggestionChipDefaults.suggestionChipColors(
|
Text(
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
text = q.question,
|
||||||
labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
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(
|
Spacer(Modifier.height(12.dp))
|
||||||
value = typedText,
|
|
||||||
onValueChange = { typedText = it },
|
|
||||||
label = { Text(stringResource(R.string.message_your_response)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
) {
|
) {
|
||||||
|
val hasAnyInput =
|
||||||
|
questions.any { q ->
|
||||||
|
(selectedChoicesByQid[q.qid]?.isNotEmpty() == true) ||
|
||||||
|
(!customTextByQid[q.qid].isNullOrBlank())
|
||||||
|
}
|
||||||
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (typedText.isNotBlank()) {
|
if (isBatch) {
|
||||||
onOptionSelected(typedText)
|
val answers =
|
||||||
typedText = ""
|
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) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Dismiss")
|
Text("Dismiss")
|
||||||
@@ -518,6 +642,28 @@ fun ClarifyBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible overload for legacy callers and unit tests.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ClarifyBubble(
|
||||||
|
text: String,
|
||||||
|
options: List<String>,
|
||||||
|
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 ──────────────────────────────────────────────────────────
|
// ── SubagentCard ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+29
-13
@@ -140,20 +140,36 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
IconButton(
|
Row(
|
||||||
onClick = {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
scope.launch {
|
|
||||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
|
||||||
}
|
|
||||||
copied = true
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(28.dp).testTag("fullbleed_copy"),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(
|
||||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
onClick = {
|
||||||
contentDescription = stringResource(R.string.content_desc_copy),
|
scope.launch {
|
||||||
modifier = Modifier.size(14.dp),
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
}
|
||||||
|
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"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ fun FullBleedChatList(
|
|||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
clarifyRequest: ClarifyUi? = null,
|
clarifyRequest: ClarifyUi? = null,
|
||||||
onRespondClarify: ((String) -> Unit)? = null,
|
onRespondClarify: ((String) -> Unit)? = null,
|
||||||
|
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
||||||
onDismissClarify: (() -> Unit)? = null,
|
onDismissClarify: (() -> Unit)? = null,
|
||||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
@@ -299,9 +300,9 @@ fun FullBleedChatList(
|
|||||||
if (clarifyRequest != null) {
|
if (clarifyRequest != null) {
|
||||||
item(key = "clarify_bubble") {
|
item(key = "clarify_bubble") {
|
||||||
ClarifyBubble(
|
ClarifyBubble(
|
||||||
text = clarifyRequest.text,
|
clarifyRequest = clarifyRequest,
|
||||||
options = clarifyRequest.options,
|
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
|
||||||
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
|
||||||
onDismiss = { onDismissClarify?.invoke() },
|
onDismiss = { onDismissClarify?.invoke() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ data class SettingsUiState(
|
|||||||
val token: String = "",
|
val token: String = "",
|
||||||
val autoReconnect: Boolean = true,
|
val autoReconnect: Boolean = true,
|
||||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||||
val useDynamicColors: Boolean = true,
|
val useDynamicColors: Boolean = false,
|
||||||
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||||
val isTesting: Boolean = false,
|
val isTesting: Boolean = false,
|
||||||
val testResult: String? = null,
|
val testResult: String? = null,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1078,7 +1078,8 @@
|
|||||||
<string name="moa_title">مزيج الوكلاء (Mixture of Agents)</string>
|
<string name="moa_title">مزيج الوكلاء (Mixture of Agents)</string>
|
||||||
<string name="model_aux_tasks_label">المهام المساعدة</string>
|
<string name="model_aux_tasks_label">المهام المساعدة</string>
|
||||||
<string name="model_aux_tasks_title">المهام المساعدة</string>
|
<string name="model_aux_tasks_title">المهام المساعدة</string>
|
||||||
<string name="model_expensive_title">نموذج عالي التكلفة</string>
|
<string name="model_expensive_title">نموذج مرتفع التكلفة</string>
|
||||||
|
<string name="model_confirm_continue">متابعة</string>
|
||||||
<string name="model_main_label">النموذج الرئيسي</string>
|
<string name="model_main_label">النموذج الرئيسي</string>
|
||||||
<string name="model_moa_label">مزيج الوكلاء (Mixture of Agents)</string>
|
<string name="model_moa_label">مزيج الوكلاء (Mixture of Agents)</string>
|
||||||
<string name="model_new_sessions">ينطبق على الجلسات الجديدة</string>
|
<string name="model_new_sessions">ينطبق على الجلسات الجديدة</string>
|
||||||
@@ -1167,7 +1168,11 @@
|
|||||||
<string name="group_chat_empty_title">لا توجد رسائل بعد</string>
|
<string name="group_chat_empty_title">لا توجد رسائل بعد</string>
|
||||||
<string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string>
|
<string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string>
|
||||||
<string name="group_chat_thinking">%1$s يفكّر الآن…</string>
|
<string name="group_chat_thinking">%1$s يفكّر الآن…</string>
|
||||||
|
<string name="group_chat_bot_passed">تخطى %1$s دوره</string>
|
||||||
<string name="group_chat_typing">جارٍ الكتابة…</string>
|
<string name="group_chat_typing">جارٍ الكتابة…</string>
|
||||||
|
<string name="group_chat_tool_running">جارٍ التشغيل…</string>
|
||||||
|
<string name="group_chat_tool_no_output">لا يوجد مخرجات</string>
|
||||||
|
<string name="group_chat_tool_no_output_success">رمز الخروج 0 (لا يوجد مخرجات)</string>
|
||||||
<string name="group_chat_members_count">%1$d أعضاء</string>
|
<string name="group_chat_members_count">%1$d أعضاء</string>
|
||||||
<string name="group_chat_mention_all">@all</string>
|
<string name="group_chat_mention_all">@all</string>
|
||||||
<string name="group_chat_capped_limit">المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة</string>
|
<string name="group_chat_capped_limit">المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة</string>
|
||||||
@@ -1181,6 +1186,10 @@
|
|||||||
<string name="group_chat_settings_system_prompt">تعليمات / موجه الغرفة</string>
|
<string name="group_chat_settings_system_prompt">تعليمات / موجه الغرفة</string>
|
||||||
<string name="group_chat_settings_system_prompt_hint">قواعد مخصصة، أو أسلوب، أو سياق لهذه المجموعة (اختياري)…</string>
|
<string name="group_chat_settings_system_prompt_hint">قواعد مخصصة، أو أسلوب، أو سياق لهذه المجموعة (اختياري)…</string>
|
||||||
<string name="group_chat_settings_system_prompt_desc">إرشادات مخصصة لجميع البوتات في هذه الغرفة</string>
|
<string name="group_chat_settings_system_prompt_desc">إرشادات مخصصة لجميع البوتات في هذه الغرفة</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_title">تحذير من الحدود المرتفعة</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_desc">قد يؤدي تعيين حدود مرتفعة للرسائل (%1$d) أو التمريرات (%2$d) إلى استهلاك كبير للرموز (Tokens) وحدوث حلقات نقاش طويلة ومستمرة بين البوتات.</string>
|
||||||
|
<string name="group_chat_settings_high_limits_confirm">حفظ على أي حال</string>
|
||||||
|
<string name="group_chat_settings_high_limits_adjust">تعديل الحدود</string>
|
||||||
<string name="group_chat_action_settings">الإعدادات</string>
|
<string name="group_chat_action_settings">الإعدادات</string>
|
||||||
<string name="group_chat_stopped_by_user">تم إيقاف النقاش</string>
|
<string name="group_chat_stopped_by_user">تم إيقاف النقاش</string>
|
||||||
<string name="group_chat_action_stop">إيقاف التوليد</string>
|
<string name="group_chat_action_stop">إيقاف التوليد</string>
|
||||||
|
|||||||
@@ -387,6 +387,7 @@
|
|||||||
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
|
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
|
||||||
<string name="content_desc_active_model">활성 모델</string>
|
<string name="content_desc_active_model">활성 모델</string>
|
||||||
<string name="model_section_pinned">고정된 모델</string>
|
<string name="model_section_pinned">고정된 모델</string>
|
||||||
|
<string name="model_confirm_continue">계속</string>
|
||||||
<string name="content_desc_unpin_model">모델 고정 해제</string>
|
<string name="content_desc_unpin_model">모델 고정 해제</string>
|
||||||
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
|
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
|
||||||
|
|
||||||
@@ -863,6 +864,10 @@
|
|||||||
<string name="group_chat_settings_system_prompt">방 지침 / 프롬프트</string>
|
<string name="group_chat_settings_system_prompt">방 지침 / 프롬프트</string>
|
||||||
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
|
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
|
||||||
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
|
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_title">높은 제한 경고</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_desc">메시지 수(%1$d) 또는 전달 횟수(%2$d)를 높게 설정하면 API 토큰이 대량 소비되고 봇 간의 긴 토론 루프가 발생할 수 있습니다.</string>
|
||||||
|
<string name="group_chat_settings_high_limits_confirm">그래도 저장</string>
|
||||||
|
<string name="group_chat_settings_high_limits_adjust">제한 조정</string>
|
||||||
<string name="group_chat_action_settings">설정</string>
|
<string name="group_chat_action_settings">설정</string>
|
||||||
<string name="subagent_agent_plan">에이전트 계획</string>
|
<string name="subagent_agent_plan">에이전트 계획</string>
|
||||||
<string name="subagent_live_transcript">실시간 기록</string>
|
<string name="subagent_live_transcript">실시간 기록</string>
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@
|
|||||||
<string name="moa_title">多 Agent 混合</string>
|
<string name="moa_title">多 Agent 混合</string>
|
||||||
<string name="model_aux_tasks_label">辅助任务</string>
|
<string name="model_aux_tasks_label">辅助任务</string>
|
||||||
<string name="model_aux_tasks_title">辅助任务</string>
|
<string name="model_aux_tasks_title">辅助任务</string>
|
||||||
|
<string name="model_confirm_continue">继续</string>
|
||||||
<string name="model_expensive_title">高成本模型</string>
|
<string name="model_expensive_title">高成本模型</string>
|
||||||
<string name="model_main_label">主模型</string>
|
<string name="model_main_label">主模型</string>
|
||||||
<string name="model_moa_label">多 Agent 混合</string>
|
<string name="model_moa_label">多 Agent 混合</string>
|
||||||
@@ -1164,5 +1165,9 @@
|
|||||||
<string name="group_chat_settings_system_prompt">房间指令 / 提示词</string>
|
<string name="group_chat_settings_system_prompt">房间指令 / 提示词</string>
|
||||||
<string name="group_chat_settings_system_prompt_hint">此群聊的自定义规则、语气或上下文(可选)…</string>
|
<string name="group_chat_settings_system_prompt_hint">此群聊的自定义规则、语气或上下文(可选)…</string>
|
||||||
<string name="group_chat_settings_system_prompt_desc">适用于此房间内所有 Bot 的自定义准则</string>
|
<string name="group_chat_settings_system_prompt_desc">适用于此房间内所有 Bot 的自定义准则</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_title">高上限警告</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_desc">设置较高的消息数 (%1$d) 或交接次数 (%2$d) 可能会消耗大量 API 令牌,并导致机器人之间产生长时间的连续讨论循环。</string>
|
||||||
|
<string name="group_chat_settings_high_limits_confirm">仍要保存</string>
|
||||||
|
<string name="group_chat_settings_high_limits_adjust">调整限制</string>
|
||||||
<string name="group_chat_action_settings">设置</string>
|
<string name="group_chat_action_settings">设置</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
|
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
|
||||||
<string name="notif_new_message">New message</string>
|
<string name="notif_new_message">New message</string>
|
||||||
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
||||||
|
<string name="notif_input_needed">Hermes needs your input</string>
|
||||||
<string name="notif_reply_placeholder">Type your reply\u2026</string>
|
<string name="notif_reply_placeholder">Type your reply\u2026</string>
|
||||||
<string name="notif_channel_service_name">Hermes Background Service</string>
|
<string name="notif_channel_service_name">Hermes Background Service</string>
|
||||||
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
|
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
|
||||||
@@ -1198,6 +1199,7 @@
|
|||||||
<string name="moa_title">Mixture of Agents</string>
|
<string name="moa_title">Mixture of Agents</string>
|
||||||
<string name="model_aux_tasks_label">Auxiliary tasks</string>
|
<string name="model_aux_tasks_label">Auxiliary tasks</string>
|
||||||
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
|
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
|
||||||
|
<string name="model_confirm_continue">Continue</string>
|
||||||
<string name="model_expensive_title">Expensive Model</string>
|
<string name="model_expensive_title">Expensive Model</string>
|
||||||
<string name="model_main_label">Main model</string>
|
<string name="model_main_label">Main model</string>
|
||||||
<string name="model_moa_label">Mixture of Agents</string>
|
<string name="model_moa_label">Mixture of Agents</string>
|
||||||
@@ -1281,7 +1283,11 @@
|
|||||||
<string name="group_chat_empty_title">No Messages Yet</string>
|
<string name="group_chat_empty_title">No Messages Yet</string>
|
||||||
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
|
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
|
||||||
<string name="group_chat_thinking">%1$s is thinking…</string>
|
<string name="group_chat_thinking">%1$s is thinking…</string>
|
||||||
|
<string name="group_chat_bot_passed">%1$s passed</string>
|
||||||
<string name="group_chat_typing">Typing…</string>
|
<string name="group_chat_typing">Typing…</string>
|
||||||
|
<string name="group_chat_tool_running">Running…</string>
|
||||||
|
<string name="group_chat_tool_no_output">No output</string>
|
||||||
|
<string name="group_chat_tool_no_output_success">Exit code 0 (no output)</string>
|
||||||
<string name="group_chat_members_count">%1$d members</string>
|
<string name="group_chat_members_count">%1$d members</string>
|
||||||
<string name="group_chat_mention_all">@all</string>
|
<string name="group_chat_mention_all">@all</string>
|
||||||
<string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string>
|
<string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string>
|
||||||
@@ -1295,6 +1301,10 @@
|
|||||||
<string name="group_chat_settings_system_prompt">Room Instructions / Prompt</string>
|
<string name="group_chat_settings_system_prompt">Room Instructions / Prompt</string>
|
||||||
<string name="group_chat_settings_system_prompt_hint">Custom rules, tone, or context for this group (optional)…</string>
|
<string name="group_chat_settings_system_prompt_hint">Custom rules, tone, or context for this group (optional)…</string>
|
||||||
<string name="group_chat_settings_system_prompt_desc">Custom guidelines for all bots in this room</string>
|
<string name="group_chat_settings_system_prompt_desc">Custom guidelines for all bots in this room</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_title">High Limits Warning</string>
|
||||||
|
<string name="group_chat_settings_high_limits_warning_desc">Setting high message (%1$d) or handoff (%2$d) limits can consume significant API tokens and lead to long continuous bot discussion loops.</string>
|
||||||
|
<string name="group_chat_settings_high_limits_confirm">Save Anyway</string>
|
||||||
|
<string name="group_chat_settings_high_limits_adjust">Adjust Limits</string>
|
||||||
<string name="group_chat_action_settings">Settings</string>
|
<string name="group_chat_action_settings">Settings</string>
|
||||||
<string name="group_chat_stopped_by_user">Discussion stopped</string>
|
<string name="group_chat_stopped_by_user">Discussion stopped</string>
|
||||||
<string name="group_chat_action_stop">Stop generation</string>
|
<string name="group_chat_action_stop">Stop generation</string>
|
||||||
|
|||||||
@@ -291,6 +291,223 @@ class EventParserTest {
|
|||||||
assertEquals(listOf("staging", "production"), clarifyEvent.options)
|
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<String>(),
|
||||||
|
"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-07: Untested subtypes ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -746,4 +963,71 @@ class EventParserTest {
|
|||||||
assertEquals("1", todoUpdated.todos[1].parent)
|
assertEquals("1", todoUpdated.todos[1].parent)
|
||||||
assertTrue(todoUpdated.todos[1].isSubtask)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -68,4 +68,56 @@ class GroupChatMentionsTest {
|
|||||||
assertEquals(1, responders.size)
|
assertEquals(1, responders.size)
|
||||||
assertEquals("coder", responders.first().name)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -266,9 +266,17 @@ class GroupChatViewModelTest {
|
|||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val finalState = viewModel.uiState.value
|
val finalState = viewModel.uiState.value
|
||||||
// Only the user message should remain; (pass) is filtered out
|
// User message plus the visual pass indicator
|
||||||
assertEquals(1, finalState.messages.size)
|
assertEquals(2, finalState.messages.size)
|
||||||
assertTrue(finalState.messages.first().isUser)
|
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
|
@Test
|
||||||
@@ -420,6 +428,33 @@ class GroupChatViewModelTest {
|
|||||||
assertEquals(15, viewModel.uiState.value.maxBotMessages)
|
assertEquals(15, viewModel.uiState.value.maxBotMessages)
|
||||||
assertEquals(4, viewModel.uiState.value.maxContinuationPasses)
|
assertEquals(4, viewModel.uiState.value.maxContinuationPasses)
|
||||||
assertEquals("Custom instructions for this group", viewModel.uiState.value.systemPrompt)
|
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
|
@Test
|
||||||
@@ -473,13 +508,19 @@ class GroupChatViewModelTest {
|
|||||||
val toolCall = msgWithTool.toolCalls.first()
|
val toolCall = msgWithTool.toolCalls.first()
|
||||||
assertEquals("terminal", toolCall.name)
|
assertEquals("terminal", toolCall.name)
|
||||||
assertEquals("date", toolCall.summary)
|
assertEquals("date", toolCall.summary)
|
||||||
|
assertEquals("date", toolCall.command)
|
||||||
assertTrue(toolCall.isRunning)
|
assertTrue(toolCall.isRunning)
|
||||||
|
|
||||||
// Emit ToolComplete
|
// Emit ToolComplete
|
||||||
eventsFlow.emit(
|
eventsFlow.emit(
|
||||||
WsEvent.ToolComplete(
|
WsEvent.ToolComplete(
|
||||||
name = "terminal",
|
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",
|
sessionId = "session-scout-1",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -488,7 +529,11 @@ class GroupChatViewModelTest {
|
|||||||
val msgAfterToolDone =
|
val msgAfterToolDone =
|
||||||
viewModel.uiState.value.messages
|
viewModel.uiState.value.messages
|
||||||
.last()
|
.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
|
// Complete message
|
||||||
eventsFlow.emit(
|
eventsFlow.emit(
|
||||||
|
|||||||
@@ -793,6 +793,148 @@ class ChatViewModelTest {
|
|||||||
assertEquals(sessionId, call.third)
|
assertEquals(sessionId, call.third)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testModelSwitch_confirmRequired_triggersConfirmationDialogAndReSends() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
|
var lastReqId = ""
|
||||||
|
val capturedParams = mutableListOf<Map<String, Any>>()
|
||||||
|
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
|
||||||
|
val params = arg<Map<String, Any>>(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 ──────────────────────────────────────────────
|
// ── Connection / init tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@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
|
@Test
|
||||||
fun testClarifyRequestCustomResponse() =
|
fun testClarifyRequestCustomResponse() =
|
||||||
runTest {
|
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 ──────────────────────────────────────────────────────────
|
// ── Attachments ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Add [count] dummy attachments so a test starts with a populated list. */
|
/** Add [count] dummy attachments so a test starts with a populated list. */
|
||||||
@@ -2722,6 +3035,236 @@ class ChatViewModelTest {
|
|||||||
assertNull(msgAfter!!.approvalInfo)
|
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) ───────────────────────────
|
// ── Sudo / secret prompt flow (issue #524) ───────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -2846,6 +3389,130 @@ class ChatViewModelTest {
|
|||||||
assertNull(viewModel.uiState.value.secretPrompt)
|
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 ─────────────────────────────────────────────────────────────
|
// ── Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -26,6 +26,25 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals(null, result.state.clarifyRequest)
|
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
|
@Test
|
||||||
fun testToolProgress_updatesProgressPreviewForMatchingRunningTool() {
|
fun testToolProgress_updatesProgressPreviewForMatchingRunningTool() {
|
||||||
val initialMessage =
|
val initialMessage =
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.theme.StatusRedContainer
|
|||||||
import com.m57.hermescontrol.theme.StatusYellow
|
import com.m57.hermescontrol.theme.StatusYellow
|
||||||
import com.m57.hermescontrol.theme.StatusYellowContainer
|
import com.m57.hermescontrol.theme.StatusYellowContainer
|
||||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||||
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -538,4 +539,36 @@ class MarkdownTextFeatureTest {
|
|||||||
val codeBlock = blocks.single() as MdBlock.Code
|
val codeBlock = blocks.single() as MdBlock.Code
|
||||||
assertTrue(codeBlock.code.contains("```python"))
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,14 @@ class SlashCommandDispatcherTest {
|
|||||||
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw "))
|
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
|
@Test
|
||||||
fun `NEW uppercase still routes to NewSession`() {
|
fun `NEW uppercase still routes to NewSession`() {
|
||||||
// Dispatcher lower-cases before matching, so case must not matter.
|
// Dispatcher lower-cases before matching, so case must not matter.
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class FakeChatMessageDao : ChatMessageDao {
|
|||||||
messageList.forEach { messages[it.id] = it }
|
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. */
|
/** Direct access for test setup — bypasses the suspend modifier. */
|
||||||
fun addMessageDirect(message: ChatMessageEntity) {
|
fun addMessageDirect(message: ChatMessageEntity) {
|
||||||
messages[message.id] = message
|
messages[message.id] = message
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ retrofit = "3.0.0"
|
|||||||
securityCrypto = "1.0.0"
|
securityCrypto = "1.0.0"
|
||||||
mockk = "1.14.11"
|
mockk = "1.14.11"
|
||||||
turbine = "1.2.1"
|
turbine = "1.2.1"
|
||||||
coil = "3.6.0"
|
coil = "3.6.1"
|
||||||
room = "3.0.2"
|
room = "3.0.2"
|
||||||
sqlcipher = "4.18.0"
|
sqlcipher = "4.18.0"
|
||||||
datastore = "1.1.2"
|
datastore = "1.1.2"
|
||||||
|
|||||||
Reference in new issue
Block a user