feat(chat): add explicit busy-send modes and recoverable queue (#1254)

Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andm57 authored and GitHub committed 2026-09-25 13:26:25 +03:00
1 parent f3921957b5
commit 35e6ebc8d5
33 files changed
+4482 -229

No files matched your search

@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -22,6 +23,7 @@ data class ServerStoreState(
val pinnedModels: List<PinnedModel> = emptyList(),
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
@@ -805,6 +806,12 @@ object AuthManager {
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode
fun setBusySendMode(mode: BusySendMode) {
serverStore.update { it.copy(busySendMode = mode) }
}
fun setTypingEffectEnabled(enabled: Boolean) {
serverStore.update { it.copy(typingEffectEnabled = enabled) }
}
@@ -86,4 +86,7 @@ interface ChatMessageDao {
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL")
suspend fun deleteUnconfirmedMessage(id: String)
}
@@ -0,0 +1,11 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class BusySendMode {
CORRECT,
QUEUE,
GUIDE,
INTERRUPT,
}
@@ -11,6 +11,7 @@ object WsMethods {
const val SESSION_CREATE = "session.create"
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_REDIRECT = "session.redirect"
const val SESSION_STEER = "session.steer"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
@@ -0,0 +1,15 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
@Composable
fun BusySendMode.label(): String =
when (this) {
BusySendMode.CORRECT -> stringResource(R.string.chat_busy_correct)
BusySendMode.QUEUE -> stringResource(R.string.chat_busy_queue)
BusySendMode.GUIDE -> stringResource(R.string.chat_busy_guide)
BusySendMode.INTERRUPT -> stringResource(R.string.chat_busy_interrupt)
}
@@ -84,4 +84,8 @@ open class ChatPersistenceRepository(
suspend fun clearMessagesForSession(sessionId: String) {
daoProvider().deleteMessagesForSession(sessionId)
}
suspend fun deleteMessage(id: String) {
daoProvider().deleteUnconfirmedMessage(id)
}
}
@@ -0,0 +1,17 @@
package com.m57.hermescontrol.ui.chat
/** Presentation only: retain queued messages internally for persistence and receipt matching. */
internal fun messagesWithoutUnsentQueue(
messages: List<ChatMessage>,
pendingSends: List<PendingSend>,
): List<ChatMessage> {
val waitingIds =
pendingSends
.filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
.map { it.id }
.toSet()
if (waitingIds.isEmpty()) return messages
return messages.filterNot {
it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
}
}
@@ -86,6 +86,7 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -172,7 +173,11 @@ fun ChatScreen(
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val displayedMessages = timelineState.historyMessages ?: state.messages
val sourceMessages = timelineState.historyMessages ?: state.messages
val displayedMessages =
remember(sourceMessages, state.pendingSends) {
messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
}
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
@@ -195,6 +200,7 @@ fun ChatScreen(
}
Lifecycle.Event.ON_RESUME -> {
viewModel.refreshSettings()
connectorsViewModel.onResume()
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
@@ -314,7 +320,7 @@ fun ChatScreen(
// streaming tail.
LaunchedEffect(
timelineState.isHistorical,
state.messages,
displayedMessages,
streamingState.streamingMessage,
streamingState.isThinking,
state.subagentIndicators,
@@ -328,13 +334,13 @@ fun ChatScreen(
scrollController.onTailChanged(
tailKey =
tailContentKey(
messages = state.messages,
messages = displayedMessages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
),
messageCount = state.messages.size,
messageCount = displayedMessages.size,
)
}
LaunchedEffect(timelineState.historyAnchorRowId) {
@@ -872,6 +878,12 @@ fun ChatScreen(
},
)
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
sends = state.pendingSends,
mainTurnBusy = state.isMainTurnBusy,
onSendNow = viewModel::sendQueuedNow,
)
ChatInputBar(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
@@ -882,9 +894,16 @@ fun ChatScreen(
scrollController.jumpToBottom(animated = true)
}
},
onBusySend = { mode ->
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
inputFieldValue = TextFieldValue("")
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isMainTurnBusy = state.isMainTurnBusy,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null,
@@ -0,0 +1,149 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** The receipt stays on device until REST confirms a user row; a gateway queue is only volatile. */
@Serializable
data class PendingSend(
val id: String,
val scope: String,
val sessionId: String,
val text: String,
val attachments: List<Attachment> = emptyList(),
val mode: BusySendMode,
val state: PendingSendState = PendingSendState.QUEUED,
val createdAt: Long = System.currentTimeMillis(),
val attempts: Int = 0,
/** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */
val requiresAttachmentRecovery: Boolean = false,
)
@Serializable
enum class PendingSendState {
QUEUED,
PARKED,
SENDING,
ACCEPTED,
UNKNOWN,
REJECTED,
}
/**
* Reconcile only identities that the transcript merge already mapped to a durable REST row.
* Content matching belongs to [matchTranscriptMessages], which normalizes gateway wrappers and
* consumes duplicate occurrences once; repeating that matching against receipts can acknowledge
* more sends than the history page actually contains.
*/
internal fun pendingSendIdsConfirmedByDurableAliases(
confirmedAliases: List<ChatMessage>,
pending: List<PendingSend>,
): Set<String> {
val durableUserAliasIds =
confirmedAliases
.asSequence()
.filter { it.role == MessageRole.USER && it.canonicalRestId != null }
.map { it.id }
.toSet()
return pending
.asSequence()
.filter {
it.state in
setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
}.map { it.id }
.filter { it in durableUserAliasIds }
.toSet()
}
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
class ChatSendStore(
private val prefs: SharedPreferences? = null,
) {
constructor(context: Context) : this(
context.applicationContext.getSharedPreferences("chat_send_outbox", Context.MODE_PRIVATE),
)
private val json = Json { ignoreUnknownKeys = true }
private var rows: List<PendingSend> =
prefs?.getString("rows", null)?.let { raw ->
runCatching { json.decodeFromString<List<PendingSend>>(raw) }.getOrDefault(emptyList())
} ?: emptyList()
init {
// A request may have reached the gateway before process death. Never replay it automatically.
replace(
rows.map {
it.quarantineLegacyAttachments().let { quarantined ->
if (quarantined.state == PendingSendState.SENDING ||
quarantined.state == PendingSendState.ACCEPTED
) {
quarantined.copy(state = PendingSendState.UNKNOWN)
} else {
quarantined
}
}
},
)
}
private fun PendingSend.quarantineLegacyAttachments(): PendingSend =
if (attachments.any { !it.uri.startsWith("file:", ignoreCase = true) }) {
copy(state = PendingSendState.REJECTED, requiresAttachmentRecovery = true)
} else {
this
}
@Synchronized
fun all(): List<PendingSend> = rows
@Synchronized
fun put(row: PendingSend) {
replace(rows.filterNot { it.id == row.id } + row.quarantineLegacyAttachments())
}
@Synchronized
fun remove(id: String) {
replace(rows.filterNot { it.id == id })
}
@Synchronized
fun update(
id: String,
transform: (PendingSend) -> PendingSend,
) {
replace(rows.map { if (it.id == id) transform(it) else it })
}
@Synchronized
fun promote(id: String) {
val row = rows.firstOrNull { it.id == id } ?: return
replace(listOf(row.copy(state = PendingSendState.QUEUED)) + rows.filterNot { it.id == id })
}
@Synchronized
fun park(
scope: String,
sessionId: String,
) {
replace(
rows.map {
if (it.scope == scope && it.sessionId == sessionId && it.state == PendingSendState.QUEUED) {
it.copy(state = PendingSendState.PARKED)
} else {
it
}
},
)
}
private fun replace(next: List<PendingSend>) {
if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) {
error("Could not save chat send queue")
}
rows = next
}
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -37,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +61,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -75,9 +78,11 @@ fun ChatInputBar(
inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onBusySend: (BusySendMode) -> Unit = {},
onMicTap: () -> Unit,
isListening: Boolean,
isAgentTyping: Boolean,
isMainTurnBusy: Boolean = false,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
@@ -111,10 +116,14 @@ fun ChatInputBar(
val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette()
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
AnimatedVisibility(
visible = true,
@@ -319,8 +328,11 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
showSend = hasDraft,
showQueue = showBusyActions,
onSend = onSend,
onQueue = { onBusySend(BusySendMode.QUEUE) },
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
@@ -6,8 +6,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -27,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddToQueue
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Stop
@@ -56,7 +59,9 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -83,6 +88,7 @@ import com.m57.hermescontrol.R
* When [supportsReasoning] is false the model takes no reasoning parameter
* and the level menu is disabled.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ComposerToolbar(
isConnected: Boolean,
@@ -96,7 +102,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
showQueue: Boolean = false,
onSend: () -> Unit = {},
onQueue: () -> Unit = {},
onStopAndSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
@@ -430,29 +439,62 @@ fun ComposerToolbar(
}
}
AnimatedVisibility(
visible = showQueue,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
FilledIconButton(
onClick = onQueue,
enabled = canSend,
colors = flatIconButtonColors(palette),
modifier = Modifier.size(48.dp).testTag("queue_button"),
) {
Icon(
imageVector = Icons.Default.AddToQueue,
contentDescription = stringResource(R.string.chat_busy_queue_action),
modifier = Modifier.size(20.dp),
)
}
}
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (showSend) onSend else onMicTap,
enabled = if (showSend) canSend else isConnected,
colors =
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.action,
contentColor = palette.onAction,
)
},
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
val actionEnabled = if (showSend) canSend else isConnected
Box(
modifier =
Modifier
.size(ControlSize)
.testTag(
.size(if (showSend) 48.dp else ControlSize)
.clip(CircleShape)
.background(
if (!showSend && isListening) {
MaterialTheme.colorScheme.errorContainer
} else {
palette.action
},
).semantics {
if (showSend && showQueue && canSend) {
customActions =
listOf(
CustomAccessibilityAction(stopAndSendLabel) {
onStopAndSend()
true
},
)
}
}.combinedClickable(
enabled = actionEnabled,
onClick = if (showSend) onSend else onMicTap,
onLongClick = if (showSend && showQueue) onStopAndSend else null,
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
).testTag(
when {
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState =
@@ -468,15 +510,24 @@ fun ComposerToolbar(
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
tint = palette.onAction,
)
}
ActionGlyph.STOP -> {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening")
Icon(
imageVector = Icons.Default.Stop,
contentDescription = "Stop listening",
tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
)
}
ActionGlyph.VOICE -> {
Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic")
Icon(
imageVector = Icons.Outlined.Mic,
contentDescription = "Mic",
tint = palette.onAction,
)
}
}
}
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
@Composable
fun PendingSendPanel(
sends: List<PendingSend>,
mainTurnBusy: Boolean,
onSendNow: (String) -> Unit,
) {
// Delivery receipts stay internal; only queued work and recovery need UI.
val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
if (visibleSends.isEmpty()) return
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
Text(
stringResource(R.string.chat_pending_send_now_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
items(visibleSends, key = { it.id }) { send ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Text(
send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
when (send.state) {
PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (send.state in
setOf(
PendingSendState.QUEUED,
PendingSendState.PARKED,
PendingSendState.UNKNOWN,
PendingSendState.REJECTED,
PendingSendState.ACCEPTED,
)
) {
TextButton(onClick = { onSendNow(send.id) }) {
Text(
stringResource(
if (mainTurnBusy) {
R.string.chat_busy_stop_and_send
} else if (send.state in
setOf(
PendingSendState.UNKNOWN,
PendingSendState.ACCEPTED,
)
) {
R.string.chat_pending_send_again
} else {
R.string.chat_pending_send_now
},
),
)
}
}
}
}
}
}
}
}
@@ -193,6 +193,8 @@ internal fun SettingsChatPage(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ChatSection(
busySendMode = state.busySendMode,
onBusySendModeChange = viewModel::onBusySendModeChange,
typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -37,6 +38,7 @@ data class SettingsUiState(
val testResult: String? = null,
val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -85,6 +87,7 @@ class SettingsViewModel(
val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val busySendMode = AuthManager.getBusySendMode()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
@@ -112,6 +115,7 @@ class SettingsViewModel(
useDynamicColors = useDynamicColors,
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
busySendMode = busySendMode,
typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
messageStatsEnabled = messageStatsEnabled,
@@ -336,6 +340,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectEnabled(enabled)
}
fun onBusySendModeChange(mode: BusySendMode) {
_uiState.update { it.copy(busySendMode = mode, isSaved = false) }
AuthManager.setBusySendMode(mode)
}
fun onTypingEffectDelayMsChange(delayMs: Int) {
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
AuthManager.setTypingEffectDelayMs(delayMs)
@@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.chat.label
import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -215,6 +217,8 @@ internal fun ChatSection(
onTokensPerSecondChange: (Boolean) -> Unit = {},
showModelProvider: Boolean = false,
onShowModelProviderChange: (Boolean) -> Unit = {},
busySendMode: BusySendMode = BusySendMode.CORRECT,
onBusySendModeChange: (BusySendMode) -> Unit = {},
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
@@ -223,6 +227,38 @@ internal fun ChatSection(
.takeIf { it >= 0 } ?: 1
SectionCard {
var busyModeExpanded by remember { mutableStateOf(false) }
Text(
text = stringResource(R.string.chat_busy_mode_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.chat_busy_mode_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) {
Text(busySendMode.label())
}
DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) {
BusySendMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.label()) },
onClick = {
onBusySendModeChange(mode)
busyModeExpanded = false
},
)
}
}
}
Text(
text = stringResource(R.string.chat_busy_interrupt_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,