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 package com.m57.hermescontrol.ui.chat.components
import android.Manifest import android.Manifest
import android.content.ClipData
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow 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.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -19,6 +25,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.notification.correlationScopeId 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.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable @Composable
fun ChatLifecycleEffects( fun ChatLifecycleEffects(
@@ -137,10 +145,24 @@ fun ChatLifecycleEffects(
} }
// Show error as snackbar // 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) { LaunchedEffect(errorMessage) {
errorMessage?.let { error -> errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError() 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 package com.m57.hermescontrol.ui.common
import android.content.ClipData
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Inbox
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -30,8 +32,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Brush
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector 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.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -48,6 +54,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import kotlinx.coroutines.launch
// Unified loading / error / empty / skeleton placeholders so every screen // Unified loading / error / empty / skeleton placeholders so every screen
// shares the same visual language. // shares the same visual language.
@@ -96,6 +103,8 @@ fun ErrorState(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Column( Column(
modifier = modifier =
modifier modifier
@@ -117,6 +126,20 @@ fun ErrorState(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, 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) { if (onRetry != null) {
Spacer(modifier = Modifier.height(spacing.md)) Spacer(modifier = Modifier.height(spacing.md))
Button( Button(
+1
View File
@@ -19,6 +19,7 @@
<string name="chat_next_match_desc">النتيجة التالية</string> <string name="chat_next_match_desc">النتيجة التالية</string>
<string name="chat_close_search_desc">إغلاق البحث</string> <string name="chat_close_search_desc">إغلاق البحث</string>
<string name="action_retry">إعادة المحاولة</string> <string name="action_retry">إعادة المحاولة</string>
<string name="action_copy_error">نسخ الخطأ</string>
<string name="action_cancel">إلغاء</string> <string name="action_cancel">إلغاء</string>
<string name="action_confirm">تأكيد</string> <string name="action_confirm">تأكيد</string>
<string name="action_clear">مسح</string> <string name="action_clear">مسح</string>
+1
View File
@@ -22,6 +22,7 @@
<!-- Actions --> <!-- Actions -->
<string name="action_retry">재시도</string> <string name="action_retry">재시도</string>
<string name="action_copy_error">오류 복사</string>
<string name="action_cancel">취소</string> <string name="action_cancel">취소</string>
<string name="action_clear">지우기</string> <string name="action_clear">지우기</string>
<string name="action_ok">확인</string> <string name="action_ok">확인</string>
+1
View File
@@ -19,6 +19,7 @@
<string name="chat_next_match_desc">下一个匹配</string> <string name="chat_next_match_desc">下一个匹配</string>
<string name="chat_close_search_desc">关闭搜索</string> <string name="chat_close_search_desc">关闭搜索</string>
<string name="action_retry">重试</string> <string name="action_retry">重试</string>
<string name="action_copy_error">复制错误</string>
<string name="action_cancel">取消</string> <string name="action_cancel">取消</string>
<string name="action_confirm">确认</string> <string name="action_confirm">确认</string>
<string name="action_clear">清除</string> <string name="action_clear">清除</string>
+1
View File
@@ -29,6 +29,7 @@
<!-- Actions --> <!-- Actions -->
<string name="action_retry">Retry</string> <string name="action_retry">Retry</string>
<string name="action_copy_error">Copy error</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_confirm">Confirm</string> <string name="action_confirm">Confirm</string>
<string name="action_clear">Clear</string> <string name="action_clear">Clear</string>