From bd94eb377589ed3cacc82b9e2edd3cd76dc134d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 09:23:04 +0000 Subject: [PATCH 1/4] fix: stop the approval notification from crashing and unblock the question tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving from the notification - `PermissionActionReceiver` ran `respondToPermission` in a bare `scope.launch { try { … } finally { … } }`. Every realistic failure on that path throws: `LocalOpenCodeBackend.delegate()` raises when the on-device runtime is not installed, and `respondPermission` raises `OpenCodeApiException` on any 4xx (most commonly a request that was already answered in the app) or on a network error. Nothing caught it, so the exception escaped the coroutine and took the process down. The whole body is now wrapped, the scope carries a `CoroutineExceptionHandler`, `goAsync().finish()` cannot throw, and the call is bounded by an 8s timeout so a hung request never outlives the broadcast window. - The stale-notification case that triggers the 4xx is fixed too: answering a request anywhere (chat card, activity screen, notification) now runs through `RuntimeActivityRepository.resolvePermission`, which cancels the notification and publishes the id on a new `resolvedPermissions` flow. OpenCode emits no `permission.replied` event, so the chat screen previously kept showing a card for a request that was already settled; it now drops it. - When the response genuinely fails, the approval notification is kept and a short error notification explains that the tap did not get through. - Notification taps also passed the session id under `session_id`, but `MainActivity` reads `target_session_id`, so opening a notification never jumped to its session. Activity intents now use the key the activity reads. Question tool - The free-text field only rendered when a prompt had no options or carried an explicit placeholder, so a question with choices could not be answered with anything but one of those choices. It is now always offered, labelled as a free-form alternative when options are present. The existing answer sanitisation already supported a typed fallback alongside options. - The card had exactly one action, disabled until answered, and no way out. Added a cancel action: it drops the card and aborts the turn that is waiting on the answer, which is the only honest escape since the question API has no "declined" reply. Tests: `testDebugUnitTest` (249 pass), `lintDebug`, `assembleDebug`, `detekt`, `spotlessCheck`. New coverage for the cancel path, free-text answers alongside options, the resolved-permission fan-out, and the chat card dropping a permission answered from the notification. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba --- .../opencode/android/OpenCodeApplication.kt | 1 + .../notification/PermissionActionReceiver.kt | 68 ++++++++++++++++--- .../notification/RuntimeNotificationHelper.kt | 9 ++- .../repository/RuntimeActivityRepository.kt | 11 +++ .../android/feature/chat/ChatHomeScreen.kt | 2 + .../android/feature/chat/ChatViewModel.kt | 36 ++++++++++ .../android/feature/chat/QuestionCard.kt | 62 +++++++++++------ .../com/opencode/android/ui/OpenCodeApp.kt | 2 + app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../RuntimeActivityRepositoryTest.kt | 37 ++++++++++ .../feature/chat/ChatViewModelQuestionTest.kt | 58 +++++++++++++++- .../android/feature/chat/ChatViewModelTest.kt | 32 +++++++++ 19 files changed, 310 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/opencode/android/OpenCodeApplication.kt b/app/src/main/java/com/opencode/android/OpenCodeApplication.kt index 6c51ce5..ab10984 100644 --- a/app/src/main/java/com/opencode/android/OpenCodeApplication.kt +++ b/app/src/main/java/com/opencode/android/OpenCodeApplication.kt @@ -186,6 +186,7 @@ class OpenCodeApplication : Application() { registry = runtimeRegistry, scope = applicationScope, onPermissionAsked = notifications::notifyPermission, + onPermissionResolved = notifications::cancelPermission, onSessionIdle = notifications::notifySessionComplete, onSessionError = notifications::notifySessionError, unreadStore = settings, diff --git a/app/src/main/java/com/opencode/android/core/notification/PermissionActionReceiver.kt b/app/src/main/java/com/opencode/android/core/notification/PermissionActionReceiver.kt index 1fdce6d..c5c90af 100644 --- a/app/src/main/java/com/opencode/android/core/notification/PermissionActionReceiver.kt +++ b/app/src/main/java/com/opencode/android/core/notification/PermissionActionReceiver.kt @@ -3,12 +3,16 @@ package com.opencode.android.core.notification import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Log import com.opencode.android.OpenCodeApplication +import com.opencode.android.R import com.opencode.android.runtime.PermissionResponse +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout class PermissionActionReceiver : BroadcastReceiver() { override fun onReceive( @@ -23,22 +27,70 @@ class PermissionActionReceiver : BroadcastReceiver() { val response = PermissionResponse.entries.firstOrNull { it.apiValue == responseValue } ?: return val app = context.applicationContext as? OpenCodeApplication ?: return + val appContext = context.applicationContext val pending = goAsync() scope.launch { try { - val backend = app.runtimeRegistry.selected.value ?: return@launch - val ok = backend.respondToPermission(sessionId, permissionId, response, remember) - if (ok) { - app.activityRepository.resolvePermission(permissionId) - app.notifications.cancelPermission(permissionId) - } + // Everything below can throw: the local runtime may no longer be installed, + // the permission may already have been answered in-app (4xx), or the network + // may be down. An escaping exception here kills the whole process, so the tap + // on the notification must never propagate one. + val result = + runCatching { + // goAsync() only keeps the process alive for a short window, so a hung + // request must not outlive it. + withTimeout(RESPONSE_TIMEOUT_MS) { + val backend = + app.runtimeRegistry.selected.value + ?: error("No OpenCode runtime is selected") + backend.respondToPermission(sessionId, permissionId, response, remember) + } + } + + result + .onSuccess { accepted -> + if (accepted) { + // Also cancels the notification and tells the chat screen to drop + // the card for this request. + runCatching { app.activityRepository.resolvePermission(permissionId) } + } else { + notifyFailure(app, appContext, sessionId) + } + } + .onFailure { error -> + Log.w(TAG, "Failed to answer permission $permissionId", error) + notifyFailure(app, appContext, sessionId) + } } finally { - pending.finish() + runCatching { pending.finish() } } } } + /** + * Leaves the approval notification in place (so the request is not silently lost) and tells + * the user the tap did not get through. + */ + private fun notifyFailure( + app: OpenCodeApplication, + context: Context, + sessionId: String, + ) { + runCatching { + app.notifications.notifySessionError( + sessionId, + context.getString(R.string.notification_permission_failed), + ) + } + } + companion object { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private const val TAG = "PermissionAction" + private const val RESPONSE_TIMEOUT_MS = 8_000L + private val handler = + CoroutineExceptionHandler { _, error -> + Log.e(TAG, "Unhandled permission response failure", error) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + handler) } } diff --git a/app/src/main/java/com/opencode/android/core/notification/RuntimeNotificationHelper.kt b/app/src/main/java/com/opencode/android/core/notification/RuntimeNotificationHelper.kt index 40fc9e8..55f1036 100644 --- a/app/src/main/java/com/opencode/android/core/notification/RuntimeNotificationHelper.kt +++ b/app/src/main/java/com/opencode/android/core/notification/RuntimeNotificationHelper.kt @@ -33,7 +33,7 @@ class RuntimeNotificationHelper(private val context: Context) { extras = mapOf( EXTRA_OPEN_ACTIVITY to true, - EXTRA_SESSION_ID to request.sessionId, + EXTRA_TARGET_SESSION_ID to request.sessionId, ), ) val allowOnce = permissionActionIntent(request, PermissionResponse.ONCE, remember = false) @@ -75,7 +75,7 @@ class RuntimeNotificationHelper(private val context: Context) { extras = mapOf( EXTRA_OPEN_CHAT to true, - EXTRA_SESSION_ID to sessionId, + EXTRA_TARGET_SESSION_ID to sessionId, ), ) val notification = @@ -100,7 +100,7 @@ class RuntimeNotificationHelper(private val context: Context) { extras = mapOf( EXTRA_OPEN_ACTIVITY to true, - EXTRA_SESSION_ID to (sessionId.orEmpty()), + EXTRA_TARGET_SESSION_ID to (sessionId.orEmpty()), ), ) val notification = @@ -213,6 +213,9 @@ class RuntimeNotificationHelper(private val context: Context) { const val EXTRA_OPEN_ACTIVITY = "open_activity" const val EXTRA_OPEN_CHAT = "open_chat" const val EXTRA_SESSION_ID = "session_id" + + /** The key [com.opencode.android.MainActivity] reads to jump straight to a session. */ + const val EXTRA_TARGET_SESSION_ID = "target_session_id" const val EXTRA_PERMISSION_ID = "permission_id" const val EXTRA_PERMISSION_RESPONSE = "permission_response" const val EXTRA_PERMISSION_REMEMBER = "permission_remember" diff --git a/app/src/main/java/com/opencode/android/data/repository/RuntimeActivityRepository.kt b/app/src/main/java/com/opencode/android/data/repository/RuntimeActivityRepository.kt index 660062f..b85c233 100644 --- a/app/src/main/java/com/opencode/android/data/repository/RuntimeActivityRepository.kt +++ b/app/src/main/java/com/opencode/android/data/repository/RuntimeActivityRepository.kt @@ -47,6 +47,7 @@ class RuntimeActivityRepository( private val retryDelayMillis: Long = 2_000L, private val maxRetryDelayMillis: Long = 30_000L, private val onPermissionAsked: ((PermissionRequest) -> Unit)? = null, + private val onPermissionResolved: ((String) -> Unit)? = null, private val onSessionIdle: ((String) -> Unit)? = null, private val onSessionError: ((String?, String?) -> Unit)? = null, private val unreadStore: UnreadSessionStore? = null, @@ -67,6 +68,14 @@ class RuntimeActivityRepository( private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 128) val events: SharedFlow = mutableEvents.asSharedFlow() + /** + * Permission ids that have been answered somewhere in the app (notification action, activity + * screen, chat). OpenCode has no `permission.replied` event, so this is how other surfaces + * learn that a request they are still showing is already settled. + */ + private val mutableResolvedPermissions = MutableSharedFlow(extraBufferCapacity = 64) + val resolvedPermissions: SharedFlow = mutableResolvedPermissions.asSharedFlow() + init { scope.launch { registry.selected.collectLatest selected@{ target -> @@ -125,6 +134,8 @@ class RuntimeActivityRepository( mutableState.update { current -> current.copy(permissions = current.permissions.filterNot { it.id == permissionId }) } + mutableResolvedPermissions.tryEmit(permissionId) + onPermissionResolved?.invoke(permissionId) } fun markSessionRead(sessionId: String) { diff --git a/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt index be864cc..932d79d 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt @@ -147,6 +147,7 @@ fun ChatHomeScreen( onToggleFavorite: (String, String) -> Unit = { _, _ -> }, onSelectQuestionAnswer: (String, Int, String) -> Unit, onSubmitQuestion: (String) -> Unit, + onCancelQuestion: (String) -> Unit = {}, autoAcceptPermissions: Boolean = false, onToggleAutoAccept: (Boolean) -> Unit = {}, sendBehavior: String = "interrupt", @@ -334,6 +335,7 @@ fun ChatHomeScreen( question = question, onAnswerSelected = onSelectQuestionAnswer, onSubmit = onSubmitQuestion, + onCancel = onCancelQuestion, ) } if (state.isThinking) { diff --git a/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt b/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt index c74a4bb..960cc53 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt @@ -222,6 +222,7 @@ class ChatViewModel( * test clock advances through forever, so it stays off unless the real app asks for it. */ private val monitorConnectionQuality: Boolean = false, + private val resolvedPermissionFlow: Flow? = null, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -248,6 +249,17 @@ class ChatViewModel( private val connectionMonitor = ConnectionQualityMonitor(viewModelScope) init { + // A permission answered from the notification (or the activity screen) never comes back + // as an event, so without this the chat keeps showing a card for a settled request. + resolvedPermissionFlow?.let { flow -> + viewModelScope.launch { + flow.collect { permissionId -> + _uiState.update { state -> + state.copy(permissions = state.permissions.filterNot { it.id == permissionId }) + } + } + } + } if (backend != null) { viewModelScope.launch { // Only real transitions are reported, so merely opening an idle chat is not @@ -812,6 +824,30 @@ class ChatViewModel( } } + /** + * Dismisses a question without answering it. OpenCode has no "declined" reply for the question + * tool, so the only honest way out is to stop the turn that is waiting on the answer — leaving + * the card up with no escape is what made answering feel mandatory. + */ + fun cancelQuestion(questionId: String) { + val pendingQuestion = _uiState.value.pendingQuestions.firstOrNull { it.request.id == questionId } ?: return + _uiState.update { state -> + state.copy( + pendingQuestions = state.pendingQuestions.filterNot { it.request.id == questionId }, + ) + } + val currentBackend = backend ?: return + viewModelScope.launch { + runCatching { currentBackend.abortSession(pendingQuestion.request.sessionId) } + .onSuccess { + _uiState.update { it.copy(isRunning = false, isThinking = false) } + } + .onFailure { error -> + _uiState.update { it.copy(error = error.safeMessage()) } + } + } + } + fun abort() { val currentBackend = backend ?: return val sessionId = _uiState.value.sessionId ?: return diff --git a/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt b/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt index f405470..7702c7d 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +32,7 @@ fun QuestionCard( question: PendingQuestionUi, onAnswerSelected: (String, Int, String) -> Unit, onSubmit: (String) -> Unit, + onCancel: (String) -> Unit = {}, ) { Card( modifier = @@ -98,17 +100,27 @@ fun QuestionCard( } } - if (prompt.options.isEmpty() || prompt.placeholder != null) { - OutlinedTextField( - value = fallbackText, - onValueChange = { onAnswerSelected(question.request.id, index, it) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text(prompt.placeholder ?: stringResource(R.string.message_hint)) - }, - singleLine = !question.request.multiple, + // Always offered, including alongside options: the presented choices are not + // always exhaustive, and there was previously no way to answer anything else. + if (prompt.options.isNotEmpty()) { + Text( + text = stringResource(R.string.question_free_text_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + OutlinedTextField( + value = fallbackText, + onValueChange = { onAnswerSelected(question.request.id, index, it) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("question-free-text-${question.request.id}-$index"), + placeholder = { + Text(prompt.placeholder ?: stringResource(R.string.message_hint)) + }, + singleLine = !question.request.multiple, + ) } } @@ -122,18 +134,28 @@ fun QuestionCard( Spacer(Modifier.height(2.dp)) - Button( - onClick = { onSubmit(question.request.id) }, - enabled = question.canSubmit && !question.isSubmitting, - modifier = - Modifier - .align(Alignment.End) - .testTag("question-submit-${question.request.id}"), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, ) { - if (question.isSubmitting) { - CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) - } else { - Text(stringResource(R.string.continue_label)) + TextButton( + onClick = { onCancel(question.request.id) }, + enabled = !question.isSubmitting, + modifier = Modifier.testTag("question-cancel-${question.request.id}"), + ) { + Text(stringResource(R.string.question_cancel)) + } + Button( + onClick = { onSubmit(question.request.id) }, + enabled = question.canSubmit && !question.isSubmitting, + modifier = Modifier.testTag("question-submit-${question.request.id}"), + ) { + if (question.isSubmitting) { + CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.continue_label)) + } } } } diff --git a/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt b/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt index 5f17e63..b70cd6a 100644 --- a/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt +++ b/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt @@ -229,6 +229,7 @@ fun OpenCodeApp( } }, monitorConnectionQuality = true, + resolvedPermissionFlow = app.activityRepository.resolvedPermissions, ) }, ) @@ -688,6 +689,7 @@ fun OpenCodeApp( onToggleFavorite = settingsViewModel::toggleFavoriteModel, onSelectQuestionAnswer = chatViewModel::selectQuestionAnswer, onSubmitQuestion = chatViewModel::submitQuestion, + onCancelQuestion = chatViewModel::cancelQuestion, autoAcceptPermissions = settingsState.autoAcceptPermissions, onToggleAutoAccept = settingsViewModel::setAutoAcceptPermissions, onSendMessage = chatViewModel::sendMessage, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d245ad8..7091c71 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -107,6 +107,7 @@ الجلسة %1$s خاملة خطأ في OpenCode فشلت جلسة + تعذّر إرسال الموافقة. افتح التطبيق وحاول مرة أخرى. اتصالات الموفرين اتصل بالموفرين من خلال بيئة تشغيل OpenCode المحددة. يتم توفير طرق تسجيل الدخول المتاحة بواسطة OpenCode. معرف الموفر (مثال: openai) @@ -130,6 +131,8 @@ جارٍ الاتصال… فشلت مصادقة الموفر. حاول مرة أخرى. متابعة + أخرى (اكتب إجابتك) + الإجابة لاحقًا (إيقاف) بيئة تشغيل المساعد مسار مساحة عمل المساعد استيراد مجلد إلى مساحة العمل المحلية diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 023178e..db186aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -107,6 +107,7 @@ La sesión %1$s está inactiva Error de OpenCode Una sesión falló + No se pudo enviar la aprobación. Abre la app e inténtalo de nuevo. Conexiones de proveedores Conecta proveedores a través del entorno de ejecución OpenCode seleccionado. Los métodos de inicio de sesión disponibles son proporcionados por OpenCode. ID del proveedor (ej. openai) @@ -130,6 +131,8 @@ Conectando… La autenticación del proveedor falló. Inténtalo de nuevo. Continuar + Otro (escribe tu respuesta) + Responder después (detener) Entorno de ejecución del asistente Ruta del espacio de trabajo del asistente Importar carpeta al espacio de trabajo local diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1a85b73..aa71f07 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -107,6 +107,7 @@ La session %1$s est inactive Erreur OpenCode Une session a échoué + Impossible d\'envoyer l\'approbation. Ouvrez l\'application et réessayez. Connexions des fournisseurs Connectez les fournisseurs via l\'environnement d\'exécution OpenCode sélectionné. Les méthodes de connexion disponibles sont fournies par OpenCode. ID du fournisseur (ex. openai) @@ -130,6 +131,8 @@ Connexion… L\'authentification du fournisseur a échoué. Réessayez. Continuer + Autre (saisissez votre réponse) + Répondre plus tard (arrêter) Environnement d\'exécution de l\'assistant Chemin de l\'espace de travail de l\'assistant Importer un dossier dans l\'espace de travail local diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1121cd0..5f34c0f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -107,6 +107,7 @@ セッション %1$s が待機中です OpenCodeエラー セッションが失敗しました + 承認を送信できませんでした。アプリを開いてもう一度お試しください。 プロバイダー接続 選択中のOpenCode実行先を通じて接続します。利用可能なログイン方法はOpenCodeから取得します。 プロバイダーID(例: openai) @@ -130,6 +131,8 @@ 接続中… プロバイダー認証に失敗しました。もう一度お試しください。 続ける + その他(自由入力) + 回答せず中断 アシスト実行先 アシスト作業フォルダ フォルダをローカル作業領域へ取り込む diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 879fbb4..6c43701 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -107,6 +107,7 @@ A sessão %1$s está ociosa Erro do OpenCode Uma sessão falhou + Não foi possível enviar a aprovação. Abra o app e tente novamente. Conexões de provedores Conecte provedores através do ambiente de execução OpenCode selecionado. Os métodos de login disponíveis são fornecidos pelo OpenCode. ID do provedor (ex. openai) @@ -130,6 +131,8 @@ Conectando… A autenticação do provedor falhou. Tente novamente. Continuar + Outro (digite sua resposta) + Responder depois (parar) Ambiente de execução do assistente Caminho do espaço de trabalho do assistente Importar pasta para o espaço de trabalho local diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2a07a2c..dfe984b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -107,6 +107,7 @@ Сессия %1$s бездействует Ошибка OpenCode Сессия завершилась с ошибкой + Не удалось отправить подтверждение. Откройте приложение и попробуйте снова. Подключения провайдеров Подключайте провайдеров через выбранную среду выполнения OpenCode. Доступные способы входа предоставляются OpenCode. ID провайдера (напр. openai) @@ -130,6 +131,8 @@ Подключение… Аутентификация провайдера не удалась. Попробуйте снова. Продолжить + Другое (введите свой ответ) + Ответить позже (остановить) Среда выполнения ассистента Путь к рабочему пространству ассистента Импортировать папку в локальное рабочее пространство diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b9f6e8c..c4514ec 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -107,6 +107,7 @@ 会话 %1$s 处于空闲状态 OpenCode 错误 会话失败 + 无法发送授权。请打开应用后重试。 提供商连接 通过所选的 OpenCode 运行时连接提供商。可用的登录方式由 OpenCode 提供。 提供商 ID(例如 openai) @@ -130,6 +131,8 @@ 正在连接… 提供商身份验证失败。请重试。 继续 + 其他(自行输入答案) + 稍后回答(停止) 助手运行时 助手工作区路径 将文件夹导入本地工作区 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cebb0a..f581ad5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,7 @@ Session %1$s is idle OpenCode error A session failed + Could not send the approval. Open the app and try again. Provider connections Connect providers through the selected OpenCode runtime. Available sign-in methods are supplied by OpenCode. Provider id (e.g. openai) @@ -130,6 +131,8 @@ Connecting… Provider authentication failed. Try again. Continue + Other (type your own answer) + Answer later (stop) Assistant runtime Assistant workspace path Import folder into local workspace diff --git a/app/src/test/java/com/opencode/android/data/repository/RuntimeActivityRepositoryTest.kt b/app/src/test/java/com/opencode/android/data/repository/RuntimeActivityRepositoryTest.kt index 634cbfa..574d53c 100644 --- a/app/src/test/java/com/opencode/android/data/repository/RuntimeActivityRepositoryTest.kt +++ b/app/src/test/java/com/opencode/android/data/repository/RuntimeActivityRepositoryTest.kt @@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -197,6 +199,41 @@ class RuntimeActivityRepositoryTest { assertTrue(unread.unreadSessionIds.isEmpty()) } + @Test + fun `resolving a permission cancels its notification and tells other surfaces`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val cancelled = mutableListOf() + val repository = + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + onPermissionResolved = { cancelled += it }, + ) + val observed = mutableListOf() + val collector = + launch(dispatcher) { + repository.resolvedPermissions.collect { observed += it } + } + // runCurrent, not advanceUntilIdle: the event stream retries with a backoff delay + // forever, so advancing virtual time here would never go idle. + runCurrent() + + repository.resolvePermission("perm-1") + runCurrent() + + assertEquals("notification cancel callback", listOf("perm-1"), cancelled) + assertEquals("resolvedPermissions subscribers", listOf("perm-1"), observed) + collector.cancel() + } + private class FakeUnreadStore( override var unreadSessionIds: Set, ) : UnreadSessionStore diff --git a/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt b/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt index 96366e0..11fb9d1 100644 --- a/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt +++ b/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt @@ -142,6 +142,58 @@ class ChatViewModelQuestionTest { assertFalse(pending.isSubmitting) } + @Test + fun `free text answer is accepted even when the question offers options`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + + viewModel.openSession("session-1") + advanceUntilIdle() + backend.events.emit( + OpenCodeEvent.QuestionAsked( + request( + id = "q-1", + sessionId = "session-1", + options = listOf("src", "docs"), + ), + ), + ) + advanceUntilIdle() + + viewModel.selectQuestionAnswer("q-1", 0, "neither, use scripts/") + assertTrue(viewModel.uiState.value.pendingQuestions.single().canSubmit) + + viewModel.submitQuestion("q-1") + advanceUntilIdle() + + assertEquals(listOf(listOf("neither, use scripts/")), backend.answeredQuestions.single().answers) + assertTrue(viewModel.uiState.value.pendingQuestions.isEmpty()) + } + + @Test + fun `cancelling a question drops the card and stops the waiting turn`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + + viewModel.openSession("session-1") + advanceUntilIdle() + backend.events.emit( + OpenCodeEvent.QuestionAsked(request(id = "q-1", sessionId = "session-1")), + ) + advanceUntilIdle() + assertEquals("q-1", viewModel.uiState.value.pendingQuestions.single().request.id) + + viewModel.cancelQuestion("q-1") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.pendingQuestions.isEmpty()) + assertEquals(listOf("session-1"), backend.abortedSessions) + assertTrue(backend.answeredQuestions.isEmpty()) + assertFalse(viewModel.uiState.value.isRunning) + } + private fun request( id: String, sessionId: String, @@ -171,6 +223,7 @@ class ChatViewModelQuestionTest { override val kind: BackendKind = BackendKind.REMOTE val events = MutableSharedFlow(extraBufferCapacity = 20) val answeredQuestions = mutableListOf() + val abortedSessions = mutableListOf() override suspend fun health(): OpenCodeHealth = OpenCodeHealth(true, "test") @@ -198,7 +251,10 @@ class ChatViewModelQuestionTest { request: PromptRequest, ) = Unit - override suspend fun abortSession(sessionId: String): Boolean = true + override suspend fun abortSession(sessionId: String): Boolean { + abortedSessions += sessionId + return true + } override suspend fun respondToPermission( sessionId: String, diff --git a/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelTest.kt b/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelTest.kt index 5ff9f02..095c359 100644 --- a/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelTest.kt @@ -449,6 +449,38 @@ class ChatViewModelTest { assertEquals(PermissionResponse.ONCE, backend.permissionResponses.single().third) } + @Test + fun `permission answered from the notification removes the chat card`() = + runTest(dispatcher) { + val backend = FakeBackend() + val resolved = MutableSharedFlow(extraBufferCapacity = 8) + val viewModel = ChatViewModel(backend, resolvedPermissionFlow = resolved) + advanceUntilIdle() + viewModel.sendMessage("Check git") + advanceUntilIdle() + + backend.events.emit( + OpenCodeEvent.PermissionAsked( + PermissionRequest( + id = "perm1", + sessionId = "s1", + permission = "bash", + patterns = listOf("git status"), + ), + ), + ) + advanceUntilIdle() + assertEquals("perm1", viewModel.uiState.value.permissions.single().id) + + // OpenCode emits no "permission.replied" event, so the notification receiver + // announces the resolution through this flow instead. + resolved.emit("perm1") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.permissions.isEmpty()) + assertTrue(backend.permissionResponses.isEmpty()) + } + @Test fun `history load maps tool parts alongside text parts`() = runTest(dispatcher) { From a52caa60dd9c4099e12c938e9b75feadedfeb3ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 09:44:16 +0000 Subject: [PATCH 2/4] test: assert the composer instead of the removed workspace chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `captureReviewedScreens` asserted that a node with the text "project" — the composer's workspace chip — was displayed on the empty chat screen. The paseo composer redesign (5e290b1) narrowed that button row to attach / model / thinking … mic / send and dropped the workspace chip; `CompactWorkspaceButton` has been dead code ever since, so the assertion could never pass again. The emulator screenshot job has failed identically on every PR since (#80, #81, #82, #83 all show the same `UiScreenshotInstrumentedTest.kt:103` failure and were merged anyway). Assert the composer's own placeholder instead, which keeps the screen's intent covered — 02-runtime-not-ready already asserts the same text is *absent* while the runtime is not ready, so the two cases now check the same element from both sides. Verified `:app:compileDebugAndroidTestKotlin`. The emulator run itself could not be reproduced locally (no emulator in this environment). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba --- .../com/opencode/android/ui/UiScreenshotInstrumentedTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt b/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt index bd1a218..3547f51 100644 --- a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt +++ b/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt @@ -100,7 +100,10 @@ class UiScreenshotInstrumentedTest { capture("01-chat-empty", { composeRule.onNodeWithText("チャット").assertIsDisplayed() composeRule.onNodeWithText("GLM-5").assertIsDisplayed() - composeRule.onNodeWithText("project").assertIsDisplayed() + // The composer button row is attach / model / thinking … mic / send since the + // paseo redesign (5e290b1); the workspace chip it used to carry is gone, so this + // asserts the composer itself rather than a chip that no longer exists. + composeRule.onNodeWithText("OpenCodeにメッセージを送る…").assertIsDisplayed() }) { PreviewChatHome() } capture("02-runtime-not-ready", { From d2d61b0798874527f1f4f3cb69205e57a03cd4cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 10:04:32 +0000 Subject: [PATCH 3/4] test: point provider and voice screenshot assertions at current UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more assertions in `captureReviewedScreens` referenced UI that no longer exists. Both string resources are orphaned — no `R.string` reference to them remains anywhere in the app: - `09-provider-settings` looked for the "保存済みの認証情報" / "APIキーを追加・更新" sections. `ProviderSettingsScreen` is now a search field over provider rows plus a GitHub section, so assert the search placeholder and the preview provider's row instead. - `10-voice-settings` looked for the "ウェイクワード用の追加パックはまだ導入されていません。" notice. That notice is gone; the wake word row carries the invocation description now. Verified `:app:compileDebugAndroidTestKotlin`. Checked every remaining literal asserted in the file against the resources actually referenced by the screens under test, so these should be the last of the stale ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba --- .../android/ui/UiScreenshotInstrumentedTest.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt b/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt index 3547f51..3027d39 100644 --- a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt +++ b/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt @@ -261,8 +261,11 @@ class UiScreenshotInstrumentedTest { capture("09-provider-settings", { composeRule.onNodeWithText("プロバイダ設定").assertIsDisplayed() - composeRule.onNodeWithText("保存済みの認証情報").assertIsDisplayed() - composeRule.onNodeWithText("APIキーを追加・更新").assertIsDisplayed() + // The screen is now a search field over provider rows; the old + // "saved credentials" / "add API key" sections are gone and their string + // resources are orphaned. + composeRule.onNodeWithText("プロバイダを検索…").assertIsDisplayed() + composeRule.onNodeWithText("Z.ai").assertIsDisplayed() }) { ProviderSettingsScreen( state = @@ -286,7 +289,10 @@ class UiScreenshotInstrumentedTest { capture("10-voice-settings", { composeRule.onNodeWithText("音声設定").assertIsDisplayed() composeRule.onNodeWithText("ウェイクワード").assertIsDisplayed() - composeRule.onNodeWithText("ウェイクワード用の追加パックはまだ導入されていません。").assertIsDisplayed() + // The "extra pack not installed" notice was dropped along with its string + // resource; the row now carries the invocation description instead. + composeRule.onNodeWithText("「Hey Mycroft」でハンズフリーでアシスタントを起動します。") + .assertIsDisplayed() }) { VoiceSettingsScreen( ttsEnabled = true, From e1877c74918c3c11a8e19ef9931e9cba41778b1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 10:15:59 +0000 Subject: [PATCH 4/4] feat: let a question be closed without answering or stopping the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "回答せず中断" ends the run, which is the wrong tool when the user just wants to ignore the question and type something else. Added a close (×) control in the question card's top-right that only clears the card: no answer is sent, the turn keeps running, and the composer is free for a normal message. The two escapes now differ clearly: - × — hide the card, leave the turn alone - 回答せず中断 — hide the card and abort the turn Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba --- .../android/feature/chat/ChatHomeScreen.kt | 2 ++ .../android/feature/chat/ChatViewModel.kt | 19 ++++++++++-- .../android/feature/chat/QuestionCard.kt | 31 ++++++++++++++++++- .../com/opencode/android/ui/OpenCodeApp.kt | 1 + app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../feature/chat/ChatViewModelQuestionTest.kt | 21 +++++++++++++ 13 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt index 932d79d..445457c 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt @@ -148,6 +148,7 @@ fun ChatHomeScreen( onSelectQuestionAnswer: (String, Int, String) -> Unit, onSubmitQuestion: (String) -> Unit, onCancelQuestion: (String) -> Unit = {}, + onDismissQuestion: (String) -> Unit = {}, autoAcceptPermissions: Boolean = false, onToggleAutoAccept: (Boolean) -> Unit = {}, sendBehavior: String = "interrupt", @@ -336,6 +337,7 @@ fun ChatHomeScreen( onAnswerSelected = onSelectQuestionAnswer, onSubmit = onSubmitQuestion, onCancel = onCancelQuestion, + onDismiss = onDismissQuestion, ) } if (state.isThinking) { diff --git a/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt b/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt index 960cc53..e649a19 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/ChatViewModel.kt @@ -825,9 +825,22 @@ class ChatViewModel( } /** - * Dismisses a question without answering it. OpenCode has no "declined" reply for the question - * tool, so the only honest way out is to stop the turn that is waiting on the answer — leaving - * the card up with no escape is what made answering feel mandatory. + * Hides the question card and leaves the turn alone, so the user can ignore the question and + * just keep typing. The request stays open on the OpenCode side; this only affects what the + * chat shows. + */ + fun dismissQuestion(questionId: String) { + _uiState.update { state -> + state.copy( + pendingQuestions = state.pendingQuestions.filterNot { it.request.id == questionId }, + ) + } + } + + /** + * Dismisses a question and stops the turn that is waiting on the answer. OpenCode has no + * "declined" reply for the question tool, so this is the way to end a turn outright rather + * than answering it — [dismissQuestion] is the lighter option that only clears the card. */ fun cancelQuestion(questionId: String) { val pendingQuestion = _uiState.value.pendingQuestions.firstOrNull { it.request.id == questionId } ?: return diff --git a/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt b/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt index 7702c7d..cc7a1e7 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/QuestionCard.kt @@ -8,11 +8,16 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton @@ -33,6 +38,7 @@ fun QuestionCard( onAnswerSelected: (String, Int, String) -> Unit, onSubmit: (String) -> Unit, onCancel: (String) -> Unit = {}, + onDismiss: (String) -> Unit = {}, ) { Card( modifier = @@ -45,9 +51,32 @@ fun QuestionCard( ), ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + // Closing the card leaves the turn running, so the question can simply be ignored and + // a normal message typed instead. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { onDismiss(question.request.id) }, + enabled = !question.isSubmitting, + modifier = + Modifier + .size(28.dp) + .testTag("question-dismiss-${question.request.id}"), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.question_dismiss), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + question.request.questions.forEachIndexed { index, prompt -> val selectedAnswers = question.selectedAnswers.getOrElse(index) { emptyList() } val optionLabels = prompt.options.map { it.label }.toSet() diff --git a/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt b/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt index b70cd6a..6c6f84c 100644 --- a/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt +++ b/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt @@ -690,6 +690,7 @@ fun OpenCodeApp( onSelectQuestionAnswer = chatViewModel::selectQuestionAnswer, onSubmitQuestion = chatViewModel::submitQuestion, onCancelQuestion = chatViewModel::cancelQuestion, + onDismissQuestion = chatViewModel::dismissQuestion, autoAcceptPermissions = settingsState.autoAcceptPermissions, onToggleAutoAccept = settingsViewModel::setAutoAcceptPermissions, onSendMessage = chatViewModel::sendMessage, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7091c71..77fe44f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -133,6 +133,7 @@ متابعة أخرى (اكتب إجابتك) الإجابة لاحقًا (إيقاف) + إغلاق السؤال بيئة تشغيل المساعد مسار مساحة عمل المساعد استيراد مجلد إلى مساحة العمل المحلية diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index db186aa..d3cd5bd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -133,6 +133,7 @@ Continuar Otro (escribe tu respuesta) Responder después (detener) + Cerrar la pregunta Entorno de ejecución del asistente Ruta del espacio de trabajo del asistente Importar carpeta al espacio de trabajo local diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aa71f07..254a01a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -133,6 +133,7 @@ Continuer Autre (saisissez votre réponse) Répondre plus tard (arrêter) + Fermer la question Environnement d\'exécution de l\'assistant Chemin de l\'espace de travail de l\'assistant Importer un dossier dans l\'espace de travail local diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5f34c0f..9209e54 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -133,6 +133,7 @@ 続ける その他(自由入力) 回答せず中断 + 質問を閉じる アシスト実行先 アシスト作業フォルダ フォルダをローカル作業領域へ取り込む diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6c43701..7973d4b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -133,6 +133,7 @@ Continuar Outro (digite sua resposta) Responder depois (parar) + Fechar a pergunta Ambiente de execução do assistente Caminho do espaço de trabalho do assistente Importar pasta para o espaço de trabalho local diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dfe984b..4a4c007 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -133,6 +133,7 @@ Продолжить Другое (введите свой ответ) Ответить позже (остановить) + Закрыть вопрос Среда выполнения ассистента Путь к рабочему пространству ассистента Импортировать папку в локальное рабочее пространство diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c4514ec..10193a7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -133,6 +133,7 @@ 继续 其他(自行输入答案) 稍后回答(停止) + 关闭问题 助手运行时 助手工作区路径 将文件夹导入本地工作区 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f581ad5..55a9e40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -133,6 +133,7 @@ Continue Other (type your own answer) Answer later (stop) + Dismiss question Assistant runtime Assistant workspace path Import folder into local workspace diff --git a/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt b/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt index 11fb9d1..3a62f3d 100644 --- a/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt +++ b/app/src/test/java/com/opencode/android/feature/chat/ChatViewModelQuestionTest.kt @@ -171,6 +171,27 @@ class ChatViewModelQuestionTest { assertTrue(viewModel.uiState.value.pendingQuestions.isEmpty()) } + @Test + fun `dismissing a question only hides the card and leaves the turn running`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + + viewModel.openSession("session-1") + advanceUntilIdle() + backend.events.emit( + OpenCodeEvent.QuestionAsked(request(id = "q-1", sessionId = "session-1")), + ) + advanceUntilIdle() + + viewModel.dismissQuestion("q-1") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.pendingQuestions.isEmpty()) + assertTrue(backend.abortedSessions.isEmpty()) + assertTrue(backend.answeredQuestions.isEmpty()) + } + @Test fun `cancelling a question drops the card and stops the waiting turn`() = runTest(dispatcher) {