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:
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,
|
||||
|
||||
Reference in new issue
Block a user