feat(ui): copy button on error states and chat error snackbar (#1420)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 21:45:06 +03:00
1 parent 26f259ddd8
commit a8dd055426
6 files changed
+50 -1

No files matched your search

@@ -1,17 +1,23 @@
package com.m57.hermescontrol.ui.chat.components
import android.Manifest
import android.content.ClipData
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
@@ -19,6 +25,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.notification.correlationScopeId
@@ -28,6 +35,7 @@ import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable
fun ChatLifecycleEffects(
@@ -137,10 +145,24 @@ fun ChatLifecycleEffects(
}
// Show error as snackbar
val clipboard = LocalClipboard.current
val copyLabel = context.getString(R.string.action_copy_error)
// Own scope + immediate clear: a later `errorMessage = null` elsewhere must not cancel a visible popup.
val snackbarScope = rememberCoroutineScope()
LaunchedEffect(errorMessage) {
errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
snackbarScope.launch {
val result =
snackbarHostState.showSnackbar(
error,
actionLabel = copyLabel,
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, error)))
}
}
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.common
import android.content.ClipData
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -22,6 +23,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
@@ -30,8 +32,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -39,6 +43,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
@@ -48,6 +54,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalSpacing
import kotlinx.coroutines.launch
// Unified loading / error / empty / skeleton placeholders so every screen
// shares the same visual language.
@@ -96,6 +103,8 @@ fun ErrorState(
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Column(
modifier =
modifier
@@ -117,6 +126,20 @@ fun ErrorState(
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = {
scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message))) }
},
modifier = Modifier.testTag("error_copy_button"),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.size(spacing.xs))
Text(stringResource(R.string.action_copy_error))
}
if (onRetry != null) {
Spacer(modifier = Modifier.height(spacing.md))
Button(