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,
+23 -1
View File
@@ -1543,6 +1543,7 @@
<string name="media_player_play_video">تشغيل الفيديو</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
<string name="connection_setup_target_title">إعداد %1$s</string>
<string name="connection_setup_progress">%1$d من %2$d</string>
<string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string>
@@ -1556,4 +1557,25 @@
<string name="connection_setup_required">%1$s مطلوب</string>
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string>
</resources>
<string name="chat_busy_mode_title">أثناء عمل الوكيل</string>
<string name="chat_busy_mode_desc">اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.</string>
<string name="chat_busy_correct">تصحيح الدور الحالي</string>
<string name="chat_busy_queue">الإرسال في الدور التالي</string>
<string name="chat_busy_guide">توجيه عند النقطة الآمنة التالية</string>
<string name="chat_busy_interrupt">مقاطعة قسرية ثم إرسال</string>
<string name="chat_busy_interrupt_warning">قد توقف المقاطعة القسرية الوكلاء الفرعيين. يسمح الانتظار والتوجيه باستمرار العمل الحالي.</string>
<string name="chat_busy_override">وضع إرسال هذه الرسالة</string>
<string name="chat_busy_override_once">يسري هذا الاختيار مرة واحدة فقط ولا يغير الإعداد الافتراضي المحفوظ.</string>
<string name="chat_pending_sends">رسائل معلّقة</string>
<string name="chat_pending_queued">في قائمة الانتظار على هذا الجهاز</string>
<string name="chat_pending_parked">موقوفة بعد الإيقاف</string>
<string name="chat_pending_sending">جارٍ الإرسال…</string>
<string name="chat_pending_accepted">قبلها الخادم؛ بانتظار ظهورها في السجل. قد تؤدي إعادة الإرسال إلى تكرارها.</string>
<string name="chat_pending_unknown">حالة التسليم غير معروفة؛ قد يؤدي الإرسال مجددًا إلى تكرارها</string>
<string name="chat_pending_rejected">لم تُسلّم؛ اختر الإرسال الآن لإعادة المحاولة</string>
<string name="chat_pending_send_now">إرسال الآن</string>
<string name="chat_pending_send_now_warning">قد يقطع إرسال رسالة معلّقة الآن العمل الجاري ويوقف الوكلاء الفرعيين.</string>
<string name="chat_pending_send_again">إرسال مجددًا</string>
<string name="chat_pending_attachments">ستُرسل المرفقات مع هذه الرسالة.</string>
</resources>
+23 -1
View File
@@ -1199,6 +1199,7 @@
<string name="media_player_play_video">비디오 재생</string>
<string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
<string name="connection_setup_target_title">%1$s 설정</string>
<string name="connection_setup_progress">%2$d개 중 %1$d개</string>
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
@@ -1239,4 +1240,25 @@
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
<string name="kanban_active_workers">활성 작업자</string>
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
</resources>
<string name="chat_busy_mode_title">에이전트가 작업 중일 때</string>
<string name="chat_busy_mode_desc">새 메시지를 현재 채팅에 전달할 방식을 선택합니다. 유휴 상태에서는 일반 전송됩니다.</string>
<string name="chat_busy_correct">현재 턴 수정</string>
<string name="chat_busy_queue">다음 턴 대기열에 추가</string>
<string name="chat_busy_guide">다음 안전 지점에서 안내</string>
<string name="chat_busy_interrupt">강제 중단 후 전송</string>
<string name="chat_busy_interrupt_warning">강제 중단은 하위 에이전트를 중지할 수 있습니다. 대기열과 안내는 현재 작업을 계속합니다.</string>
<string name="chat_busy_override">이 메시지의 전송 방식</string>
<string name="chat_busy_override_once">이 선택은 한 번만 적용되며 저장된 기본값은 바뀌지 않습니다.</string>
<string name="chat_pending_sends">대기 중인 메시지</string>
<string name="chat_pending_queued">이 기기에서 대기 중</string>
<string name="chat_pending_parked">중지 후 보류됨</string>
<string name="chat_pending_sending">전송 중…</string>
<string name="chat_pending_accepted">서버에서 수락됨; 대화 기록 대기 중. 다시 보내면 중복될 수 있습니다.</string>
<string name="chat_pending_unknown">전달 여부를 알 수 없습니다. 다시 보내면 중복될 수 있습니다.</string>
<string name="chat_pending_rejected">전달되지 않았습니다. 지금 보내기를 눌러 재시도하세요.</string>
<string name="chat_pending_send_now">지금 보내기</string>
<string name="chat_pending_send_now_warning">대기 중인 메시지를 지금 보내면 진행 중인 작업이 중단되고 하위 에이전트가 멈출 수 있습니다.</string>
<string name="chat_pending_send_again">다시 보내기</string>
<string name="chat_pending_attachments">첨부 파일도 함께 전송됩니다.</string>
</resources>
+20
View File
@@ -1509,6 +1509,26 @@
<string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
<string name="connection_setup_pending">正在等待 Hermes…</string>
<string name="chat_busy_mode_title">智能体工作时</string>
<string name="chat_busy_mode_desc">选择新消息如何送达当前聊天。空闲时正常发送。</string>
<string name="chat_busy_correct">修正当前轮次</string>
<string name="chat_busy_queue">排队等待下一轮</string>
<string name="chat_busy_guide">在下一个安全点提供指导</string>
<string name="chat_busy_interrupt">强制中断后发送</string>
<string name="chat_busy_interrupt_warning">强制中断可能停止子智能体。排队和指导会让当前工作继续。</string>
<string name="chat_busy_override">本条消息的发送方式</string>
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
<string name="chat_pending_sends">待发送消息</string>
<string name="chat_pending_queued">已在本机排队</string>
<string name="chat_pending_parked">停止后已暂停</string>
<string name="chat_pending_sending">正在发送…</string>
<string name="chat_pending_accepted">服务器已接收,等待写入聊天记录。再次发送可能造成重复。</string>
<string name="chat_pending_unknown">送达状态未知;再次发送可能重复</string>
<string name="chat_pending_rejected">未送达;点按“立即发送”重试</string>
<string name="chat_pending_send_now">立即发送</string>
<string name="chat_pending_send_now_warning">现在发送待处理消息可能会中断当前任务并停止子代理。</string>
<string name="chat_pending_send_again">再次发送</string>
<string name="chat_pending_attachments">附件将随这条消息发送。</string>
<string name="chat_action_timeline">时间线</string>
<string name="chat_history_window_latest">回到最新</string>
<string name="chat_history_window_more_both">此窗口之前和之后还有更多消息。</string>
+23
View File
@@ -194,6 +194,29 @@
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
<string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string>
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
<string name="chat_busy_mode_title">While the agent is working</string>
<string name="chat_busy_mode_desc">Choose how a new message reaches the current chat. Idle messages send normally.</string>
<string name="chat_busy_correct">Correct current turn</string>
<string name="chat_busy_queue">Queue next turn</string>
<string name="chat_busy_guide">Guide at next safe point</string>
<string name="chat_busy_interrupt">Hard interrupt, then send</string>
<string name="chat_busy_interrupt_warning">Hard interrupt can stop child agents. Queue and guide let current work continue.</string>
<string name="chat_busy_queue_action">Queue</string>
<string name="chat_busy_stop_and_send">Stop &amp; send</string>
<string name="chat_busy_more">More</string>
<string name="chat_busy_override">Send mode for this message</string>
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
<string name="chat_pending_sends">Pending messages</string>
<string name="chat_pending_queued">Queued on this device</string>
<string name="chat_pending_parked">Parked after Stop</string>
<string name="chat_pending_sending">Sending\u2026</string>
<string name="chat_pending_accepted">Accepted by server; awaiting transcript. Sending again may duplicate it.</string>
<string name="chat_pending_unknown">Delivery unknown; sending again may duplicate it</string>
<string name="chat_pending_rejected">Not delivered; choose Send now to retry</string>
<string name="chat_pending_send_now">Send now</string>
<string name="chat_pending_send_now_warning">Sending a pending message now may interrupt active work and stop child agents.</string>
<string name="chat_pending_send_again">Send again</string>
<string name="chat_pending_attachments">Attachments will be sent with this message.</string>
<string name="chat_tool_execution_error">Execution Error: %1$s</string>
<string name="chat_tool_exit_code">Exit Code: %1$d</string>
<string name="chat_tool_output_omitted">%1$d characters omitted from process output</string>