feat(chat): show session-scoped reply failure details (#1255)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-09-24 21:37:04 +03:00
1 parent 94ec95a33c
commit 0d4c0b40ad
13 files changed
+1251 -28

No files matched your search

@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
internal data class MessageCompleteNotificationPlan(
val text: String,
val sessionId: String?,
val isReplyMessage: Boolean,
val completionId: String?,
val correlationText: String?,
val allowInlineReply: Boolean,
)
internal fun messageCompleteNotificationPlan(
event: WsEvent.MessageComplete,
targetSessionId: String?,
newMessageText: String,
failureText: String,
): MessageCompleteNotificationPlan {
if (replyFailureFromPayload(event.rawPayload, fallback = "") != null) {
return MessageCompleteNotificationPlan(
text = failureText,
sessionId = targetSessionId,
isReplyMessage = false,
completionId = null,
correlationText = null,
allowInlineReply = false,
)
}
return MessageCompleteNotificationPlan(
text =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { newMessageText },
sessionId = targetSessionId,
isReplyMessage = true,
completionId = event.completionId,
correlationText = event.text,
allowInlineReply = true,
)
}
/**
* Foreground service that keeps the WebSocket connection alive while the app
* is backgrounded and posts a notification when a new assistant reply
@@ -111,19 +152,22 @@ class ChatNotificationService : Service() {
if (!isAppInForeground.get()) {
when (event) {
is WsEvent.MessageComplete -> {
val preview =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
val plan =
messageCompleteNotificationPlan(
event = event,
targetSessionId = targetSessionId,
newMessageText = getString(R.string.notif_new_message),
failureText = getString(R.string.chat_reply_failed_title),
)
showReplyNotification(
text = preview,
sessionId = targetSessionId,
isReplyMessage = true,
completionId = event.completionId,
text = plan.text,
sessionId = plan.sessionId,
isReplyMessage = plan.isReplyMessage,
completionId = plan.completionId,
allowInlineReply = plan.allowInlineReply,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
@@ -132,7 +176,9 @@ class ChatNotificationService : Service() {
// is strictly better than dismissing the wrong
// duplicate reply.
serverMessageId =
coalesceTurnRow(targetSessionId, event.text),
plan.correlationText?.let {
coalesceTurnRow(plan.sessionId, it)
},
)
// The wait is over — retire the foreground
// service. The reply notification above
@@ -216,6 +262,7 @@ class ChatNotificationService : Service() {
isReplyMessage: Boolean = false,
completionId: String? = null,
serverMessageId: Int? = null,
allowInlineReply: Boolean = true,
) {
val builder =
NotificationCompat
@@ -262,7 +309,7 @@ class ChatNotificationService : Service() {
)
}
if (!sessionId.isNullOrBlank()) {
if (allowInlineReply && !sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput =
RemoteInput
@@ -64,9 +64,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@@ -76,8 +78,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.LogsScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -102,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
@@ -784,6 +789,39 @@ fun ChatScreen(
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
onImageClick = { viewingImage = it },
replyErrorContent =
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
{
val clipboard = LocalClipboardManager.current
val copiedMessage = stringResource(R.string.chat_reply_failed_copied)
val shareTitle = stringResource(R.string.chat_reply_failed_share)
val shareUnavailable = stringResource(R.string.chat_reply_failed_share_unavailable)
ReplyErrorCard(
failure = failure,
onDismiss = { viewModel.dismissReplyFailure(failure.id) },
onOpenLogs = { NavigationController.navigateTo(LogsScreen) },
onCopy = { details ->
clipboard.setText(AnnotatedString(details))
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
},
onShare = { details ->
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
val intent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, report)
}
try {
launchExternalActivity {
context.startActivity(Intent.createChooser(intent, shareTitle))
}
} catch (_: ActivityNotFoundException) {
scrollScope.launch { snackbarHostState.showSnackbar(shareUnavailable) }
}
},
)
}
},
)
// Loading overlay
@@ -137,6 +137,10 @@ data class ChatUiState(
/** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null,
/** Persistent until dismissed/new turn; distinct from one-shot RPC/network snackbars. */
val replyFailure: ReplyFailure? = null,
/** Identity of the single non-durable failed assistant projection for this session lifecycle. */
val replyFailureProjection: ReplyFailureProjection? = null,
// Background job completion toast (issue #527) — non-blocking snackbar
val backgroundCompleteMessage: String? = null,
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
@@ -842,6 +846,23 @@ class ChatViewModel(
}
private fun handleWsEvent(event: WsEvent) {
// Filter explicitly scoped terminal/start events before buffer resets and reduction.
// Legacy unscoped failures are ambiguous across sessions, so fail closed: gateways
// must include session_id for failed replies to be surfaced by this client.
val turnSessionId =
when (event) {
is WsEvent.MessageStart -> event.sessionId
is WsEvent.MessageComplete -> event.sessionId
is WsEvent.MessageDone -> event.sessionId
else -> null
}
if (turnSessionId != null && !isCurrentSession(turnSessionId)) return
if (event is WsEvent.MessageComplete && event.rawPayload?.get("status") == "error" &&
event.sessionId == null
) {
return
}
// RpcError is reduced before ViewModel request handling. Drop stale
// session errors here so the shared reducer cannot clear loading or
// surface an error for a newly selected session.
@@ -1238,10 +1259,13 @@ class ChatViewModel(
// ── Message streaming ────────────────────────────────────────────────
/**
* Checks if an incoming WS event belongs to the currently active
* session. Returns true if the event should be processed.
*/
fun dismissReplyFailure(id: String) {
_uiState.update { state ->
if (state.replyFailure?.id == id) state.copy(replyFailure = null) else state
}
}
/** Checks if an incoming WS event belongs to the currently active session. */
private fun isCurrentSession(eventSessionId: String?): Boolean {
// If the event has no session ID, process it (legacy compatibility)
if (eventSessionId == null) return true
@@ -1510,20 +1534,23 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?>
// A resume response may carry retained terminal state. Correlate the
// exact request and stored session before binding the runtime id or
// projecting any failure; a late response must not touch the new chat.
val resumeRequest = request ?: return
val sessionId = resumeRequest.sessionId ?: return
if (sessionId != _uiState.value.currentSessionId) return
val resumedStoredId = (resultMap?.get("resumed") as? String)?.takeIf { it.isNotBlank() }
if (resumedStoredId != null && resumedStoredId != sessionId) return
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
handleResumeFailure(sessionId, resumeRequest.generation, "Invalid session resume response")
return
}
runtimeSessionId = runtimeId
connectionOperationDelegate.bindSession(runtimeId)
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
val sessionId =
request?.sessionId
?: (resultMap["resumed"] as? String)
?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort
val infoMap = resultMap["info"] as? Map<String, Any?>
@@ -1592,7 +1619,8 @@ class ChatViewModel(
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
addSystemMessage("Session resumed")
fetchContextUsage()
val generation = request?.generation ?: sessionGeneration
projectRetainedReplyFailure(resultMap["inflight"] as? Map<String, Any?>, runtimeId)
val generation = resumeRequest.generation
resumedGeneration = generation
finishResumeWhenHydrated(generation)
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
@@ -1663,6 +1691,25 @@ class ChatViewModel(
}
}
/**
* Replays a gateway-retained failed turn through the same reducer path as a
* live terminal `message.complete(status=error)`. The assistant field is
* the partial display projection; user/transcript fields are never exported
* as diagnostics, and reducer effects intentionally do not persist it.
*/
private fun projectRetainedReplyFailure(
inflight: Map<String, Any?>?,
runtimeId: String,
) {
if (inflight?.get("status") != "error") return
val result =
ChatWsEventReducer.reduceRetainedReplyFailure(_uiState.value, inflight, runtimeId)
_uiState.value = result.state
_streamingState.value = result.streamingState
dispatchReducerEffects(result.effects)
streamingController.resetStreaming()
}
@Suppress("UNCHECKED_CAST")
private fun handleDispatchResult(result: Any?) {
val map = result as? Map<*, *> ?: return
@@ -3546,6 +3593,8 @@ class ChatViewModel(
hasOlderMessages = false,
streamingMessage = null,
errorMessage = null,
replyFailure = null,
replyFailureProjection = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
@@ -69,6 +69,40 @@ private fun stripSealedOrphanPrefix(
* Android or ViewModel classes. Easy to unit test.
*/
object ChatWsEventReducer {
/**
* Projects retained terminal state without consulting any live streaming buffers.
* Only `inflight.assistant` may become assistant prose; stale UI text, reasoning,
* and message ids are deliberately excluded.
*/
fun reduceRetainedReplyFailure(
state: ChatUiState,
inflight: Map<String, Any?>,
currentSessionId: String,
): ReducerResult {
if (inflight["status"] != "error") return ReducerResult(state, StreamingState())
val assistant = (inflight["assistant"] as? String).orEmpty()
val payload = inflight.toMutableMap().apply { put("partial", assistant.isNotBlank()) }
val cleanState =
state.copy(
isAgentTyping = false,
isThinking = false,
thinkingText = "",
streamingMessage = null,
)
return reduce(
cleanState,
StreamingState(),
WsEvent.MessageComplete(
text = assistant,
sessionId = currentSessionId,
// Retained inflight has no completion identity; suppress the event's random default.
completionId = "",
rawPayload = payload,
),
currentSessionId,
)
}
fun reduce(
state: ChatUiState,
streamingState: StreamingState,
@@ -239,6 +273,18 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageStart,
): ReducerResult {
val previousFailureMessageId = state.replyFailureProjection?.messageId
val turnState =
state.copy(
messages =
if (previousFailureMessageId == null) {
state.messages
} else {
state.messages.filterNot { it.id == previousFailureMessageId }
},
replyFailure = null,
replyFailureProjection = null,
)
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
@@ -252,10 +298,10 @@ object ChatWsEventReducer {
val effects = mutableListOf<ReducerEffect>()
val cleanedSubagents =
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) {
emptyList()
} else {
state.subagentIndicators
turnState.subagentIndicators
}
// Build new state: finalize any orphan streaming message, then set the new one
@@ -270,13 +316,13 @@ object ChatWsEventReducer {
tps = null,
)
orphan = finalized
state.copy(
messages = state.messages.upsertById(finalized),
turnState.copy(
messages = turnState.messages.upsertById(finalized),
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
} else {
state.copy(
turnState.copy(
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
@@ -406,6 +452,8 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageComplete,
): ReducerResult {
val failure = replyFailureFromPayload(event.rawPayload, event.text)
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
@@ -520,6 +568,86 @@ object ChatWsEventReducer {
)
}
// Keep terminal diagnostics out of normal assistant messages and persistence.
private fun onReplyFailure(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageComplete,
failure: ReplyFailure,
): ReducerResult {
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
val streaming = streamingState.streamingMessage
val text =
if (event.rawPayload?.get("partial") == true) {
event.text
.takeIf { it.isNotBlank() }
?.let { stripSealedOrphanPrefix(it, state.messages, streamingState.sealedOrphanIds) }
?: streaming?.content.orEmpty()
} else {
streaming?.content.orEmpty()
}
val reasoning = streamingState.reasoningText.ifBlank { streaming?.reasoningText.orEmpty() }
val existingProjection = state.replyFailureProjection
val completionId = event.completionId?.takeIf { it.isNotBlank() } ?: existingProjection?.completionId
val sessionIdentity = state.currentSessionId ?: event.sessionId.orEmpty()
val failureId =
existingProjection?.failureId
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-$sessionIdentity-$it" }
?: failure.id
val projectionMessageId =
existingProjection?.messageId
?: streaming?.id
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-message-$it" }
?: if (text.isNotBlank() || reasoning.isNotBlank()) "reply-failure-message-$failureId" else null
val projection = ReplyFailureProjection(projectionMessageId, failureId, completionId)
val existingProjectedMessage =
projectionMessageId?.let { id -> usageState.messages.firstOrNull { it.id == id } }
val partial =
if (text.isNotBlank() || reasoning.isNotBlank()) {
val base =
streaming
?: existingProjectedMessage
?: ChatMessage(
id = checkNotNull(projectionMessageId),
role = MessageRole.ASSISTANT,
content = "",
)
base.copy(
id = checkNotNull(projectionMessageId),
content = text,
reasoningText = reasoning,
isStreaming = false,
finishTimestamp = System.currentTimeMillis(),
completionId = completionId,
)
} else {
null
}
val messagesWithoutOldProjection =
existingProjection?.messageId?.let { oldId -> usageState.messages.filterNot { it.id == oldId } }
?: usageState.messages
val projectedMessages =
partial?.let { messagesWithoutOldProjection.upsertById(it) } ?: messagesWithoutOldProjection
// A failed partial is a transient projection of gateway `inflight`, not a
// successful transcript row. Persisting it would erase failure provenance on
// hydration and make a later session reopen look healthy.
val effects = listOf(ReducerEffect.RefreshSessions, ReducerEffect.RefreshContextUsage)
return ReducerResult(
state =
usageState.copy(
messages = projectedMessages,
replyFailure = failure.copy(id = failureId),
replyFailureProjection = projection,
isAgentTyping = false,
isThinking = false,
streamingMessage = null,
clarifyRequest = null,
),
streamingState = StreamingState(),
effects = effects,
)
}
// ── MessageDone ───────────────────────────────────────────────────
private fun onMessageDone(
@@ -0,0 +1,59 @@
package com.m57.hermescontrol.ui.chat
import java.util.UUID
/** Session-scoped presentation, never a send receipt or a persisted transcript row. */
data class ReplyFailure(
val details: String,
val id: String = UUID.randomUUID().toString(),
)
/** Stable identity for the one transient failed-reply projection in a session lifecycle. */
data class ReplyFailureProjection(
val messageId: String?,
val failureId: String,
val completionId: String? = null,
)
/** Only a terminal turn failure is a failed reply; RPC/transport errors are separate. */
internal fun replyFailureFromPayload(
payload: Map<String, Any?>?,
fallback: String,
): ReplyFailure? {
if (payload?.get("status") != "error") return null
val surface = payload["error_surface"] as? Map<*, *>
val details =
buildList {
for (key in listOf("layer", "code", "provider", "model")) {
(surface?.get(key) as? String)?.takeIf { it.isNotBlank() }?.let { add("$key: $it") }
}
val error = (payload["error"] as? String)?.takeIf { it.isNotBlank() }
add(error ?: if (payload["partial"] == true) "Unknown gateway error" else fallback)
}.joinToString("\n")
return ReplyFailure(sanitizeReplyErrorDetails(details))
}
/** Best-effort scrubbing before display/copy/share; never includes a transcript or request payload. */
internal fun sanitizeReplyErrorDetails(raw: String?): String {
if (raw.isNullOrBlank()) return "Unknown gateway error"
var safe = raw.take(8_192)
safe = PRIVATE_KEY.replace(safe, "[REDACTED]")
safe = HEADER.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
safe = URL.replace(safe, "[REDACTED URL]")
safe = SECRET_ASSIGNMENT.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
safe = BEARER.replace(safe, "Bearer [REDACTED]")
safe = PROVIDER_KEY.replace(safe, "[REDACTED]")
return safe.take(8_192)
}
private val PRIVATE_KEY = Regex("-----BEGIN [^-]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)")
private val HEADER = Regex("(?im)^([ \\t]*(?:authorization|proxy-authorization|set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*")
private val URL = Regex("(?i)(?:https?|wss?)://[^\\s<>\\\"']+")
private val SECRET_ASSIGNMENT =
Regex(
"(?i)((?<![\\w-])[\\\"']?[\\w-]{0,128}" +
"(?:token|secret|password|passwd|api[_-]?key|authorization|cookie)[\\w-]{0,128}" +
"[\\\"']?\\s*[:=]\\s*)(?:\\\"[^\\\"]*\\\"?|'[^']*'?|[^\\r\\n,;}]+)",
)
private val BEARER = Regex("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+")
private val PROVIDER_KEY = Regex("\\b(?:sk-[A-Za-z0-9_-]+|gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\\b")
@@ -0,0 +1,122 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ReplyFailure
import com.m57.hermescontrol.ui.chat.sanitizeReplyErrorDetails
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyErrorCard(
failure: ReplyFailure,
onDismiss: () -> Unit,
onOpenLogs: () -> Unit,
onCopy: (String) -> Unit,
onShare: (String) -> Unit,
) {
var expanded by rememberSaveable(failure.id) { mutableStateOf(false) }
val details = remember(failure.details) { sanitizeReplyErrorDetails(failure.details) }
val detailsStateDescription =
stringResource(
if (expanded) {
R.string.chat_reply_failed_details_expanded
} else {
R.string.chat_reply_failed_details_collapsed
},
)
val colors = LocalHermesStatusColors.current
Surface(
modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("reply_error_card"),
shape = MaterialTheme.shapes.medium,
color = colors.errorContainer,
contentColor = colors.onErrorContainer,
border = BorderStroke(1.dp, colors.error),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.ErrorOutline, contentDescription = null)
Text(
stringResource(R.string.chat_reply_failed_title),
modifier =
Modifier
.weight(
1f,
).padding(start = 8.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
style = MaterialTheme.typography.titleSmall,
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.chat_reply_failed_dismiss))
}
}
Text(stringResource(R.string.chat_reply_failed_description), style = MaterialTheme.typography.bodyMedium)
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.semantics { stateDescription = detailsStateDescription },
) {
Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null)
Text(stringResource(R.string.chat_reply_failed_details))
}
if (expanded) {
SelectionContainer {
Text(
details,
modifier = Modifier.heightIn(max = 200.dp).verticalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
Text(stringResource(R.string.chat_reply_failed_share_note), style = MaterialTheme.typography.bodySmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onOpenLogs) { Text(stringResource(R.string.chat_reply_failed_logs)) }
OutlinedButton(onClick = { onCopy(details) }) { Text(stringResource(R.string.chat_reply_failed_copy)) }
OutlinedButton(
onClick = { onShare(details) },
enabled = expanded,
) { Text(stringResource(R.string.chat_reply_failed_share)) }
}
}
}
}
@@ -104,8 +104,9 @@ fun FullBleedChatList(
hasOlderMessages: Boolean = false,
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
replyErrorContent: (@Composable () -> Unit)? = null,
) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -145,6 +146,7 @@ fun FullBleedChatList(
// A single ordered definition supplies both emitted tail rows and anchor keys.
val tailItems =
buildMap<String, @Composable () -> Unit> {
replyErrorContent?.let { put("reply_error", it) }
agentStatus?.let { status ->
put("agent_status") {
AgentStatusIndicator(status = status)