From 57a03ee45a35ca23bd4758a5f571707ac72ad00f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:18:10 +0000 Subject: [PATCH 1/5] fix: deliver chat events, end runs on time, and show real chat state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round of fixes shipped in v0.3.7/v0.3.8 and the symptoms stayed. Digging into what actually happens at runtime turned up four different causes, none of which the earlier work touched. Events never reached the app. The stream only opened once a runtime reported Connected, but every REST call — sending a prompt, loading a transcript — works regardless of that flag. A runtime whose state never reached Connected therefore looked completely functional while silently delivering no events at all: no live reply, no drawer activity, no idle notification. The stream now follows the selected runtime and relies on its own retry, which is both simpler and strictly more robust. Runs never ended. The response poll asked the session object for a completion time, but sessions do not carry one — only assistant messages do — so completion was never detected and the composer sat on the stop button, still showing "thinking", until the two minute timeout expired. Completion is now read off the transcript. Drawer state was event-only. Active chats came purely from stream events, so with no events every chat sat on the grey idle dot even while plainly working. A chat now reports its own run, and unread markers are persisted so a chat that finished while the app was closed is still unread on the next launch. Markers did not match what was asked for: working now shows a spinner rather than a static green dot, and finished-but-unread shows a filled blue dot rather than a hollow ring. Also fixed, both pre-existing on main and both blocking CI: - ChatViewModel started an unbounded 30s connection probe from init. Under a virtual test clock that loop never ends, so every chat test hung and the suite could not run — which is a large part of why chat regressions kept shipping. The probe is now opt-in and enabled by the app; the suite runs in seconds again. - The quick input widget declared an EditText, which RemoteViews cannot inflate, so the widget could not render. It is a tap target that opens the input activity, so it is now a TextView. - Six locales were missing three strings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RLvEkDwiWT8R4S23gdPsSB --- .../opencode/android/OpenCodeApplication.kt | 1 + .../connection/SecureSettingsRepository.kt | 19 ++- .../repository/RuntimeActivityRepository.kt | 96 +++++++++--- .../android/feature/chat/ChatViewModel.kt | 75 ++++++++-- .../com/opencode/android/ui/OpenCodeApp.kt | 29 ++++ .../android/ui/components/StatusDot.kt | 32 ++-- .../com/opencode/android/ui/theme/Color.kt | 8 + .../main/res/layout/widget_quick_input.xml | 18 ++- app/src/main/res/values-ar/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values/strings.xml | 1 + .../core/api/ChatStreamDecodingTest.kt | 139 ++++++++++++++++++ .../RuntimeActivityRepositoryTest.kt | 103 ++++++++++--- 18 files changed, 467 insertions(+), 79 deletions(-) create mode 100644 app/src/test/java/com/opencode/android/core/api/ChatStreamDecodingTest.kt diff --git a/app/src/main/java/com/opencode/android/OpenCodeApplication.kt b/app/src/main/java/com/opencode/android/OpenCodeApplication.kt index 074e073..6c51ce5 100644 --- a/app/src/main/java/com/opencode/android/OpenCodeApplication.kt +++ b/app/src/main/java/com/opencode/android/OpenCodeApplication.kt @@ -188,6 +188,7 @@ class OpenCodeApplication : Application() { onPermissionAsked = notifications::notifyPermission, onSessionIdle = notifications::notifySessionComplete, onSessionError = notifications::notifySessionError, + unreadStore = settings, ) scheduleDeferredInitialization() } diff --git a/app/src/main/java/com/opencode/android/data/connection/SecureSettingsRepository.kt b/app/src/main/java/com/opencode/android/data/connection/SecureSettingsRepository.kt index 86f71ca..1c789ff 100644 --- a/app/src/main/java/com/opencode/android/data/connection/SecureSettingsRepository.kt +++ b/app/src/main/java/com/opencode/android/data/connection/SecureSettingsRepository.kt @@ -4,9 +4,10 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import com.opencode.android.data.repository.UnreadSessionStore import com.opencode.android.runtime.RuntimeConnectionStore -class SecureSettingsRepository(context: Context) : RuntimeConnectionStore { +class SecureSettingsRepository(context: Context) : RuntimeConnectionStore, UnreadSessionStore { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -180,6 +181,21 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore { get() = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false) set(value) = preferences.edit().putBoolean(KEY_ONBOARDING_COMPLETED, value).apply() + /** Chats that finished without being read, kept across restarts for the drawer markers. */ + override var unreadSessionIds: Set + get() = + preferences + .getStringSet(KEY_UNREAD_SESSIONS, emptySet()) + .orEmpty() + .filter { it.isNotBlank() } + .toSet() + set(value) { + preferences + .edit() + .putStringSet(KEY_UNREAD_SESSIONS, value.filter { it.isNotBlank() }.toSet()) + .apply() + } + var githubToken: String? get() = preferences.getString(KEY_GITHUB_TOKEN, null) set(value) = preferences.edit().putString(KEY_GITHUB_TOKEN, value).apply() @@ -259,6 +275,7 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore { private const val KEY_SAF_WORKSPACE_URIS = "saf_workspace_uris" private const val KEY_PROJECT_PATHS = "project_paths" private const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" + private const val KEY_UNREAD_SESSIONS = "unread_sessions" private const val KEY_GITHUB_TOKEN = "github_token" private const val KEY_GITHUB_LOGIN = "github_login" private const val KEY_THEME = "theme" 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 f2b913b..d82ea12 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 @@ -29,6 +29,11 @@ data class RuntimeEventLog( val sessionId: String? = null, ) +/** Persists which chats finished without the user having read them. */ +interface UnreadSessionStore { + var unreadSessionIds: Set +} + data class RuntimeActivityState( val activeSessionIds: Set = emptySet(), val completedSessionIds: Set = emptySet(), @@ -45,13 +50,19 @@ class RuntimeActivityRepository( private val onPermissionAsked: ((PermissionRequest) -> Unit)? = null, private val onSessionIdle: ((String) -> Unit)? = null, private val onSessionError: ((String?, String?) -> Unit)? = null, + private val unreadStore: UnreadSessionStore? = null, ) { init { require(retryDelayMillis >= 0L) require(maxRetryDelayMillis >= retryDelayMillis) } - private val mutableState = MutableStateFlow(RuntimeActivityState()) + // Unread markers outlive the process: a chat that finished while the app was closed is still + // unread the next time the drawer is opened. + private val mutableState = + MutableStateFlow( + RuntimeActivityState(completedSessionIds = unreadStore?.unreadSessionIds.orEmpty()), + ) val state: StateFlow = mutableState.asStateFlow() private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 128) @@ -60,33 +71,27 @@ class RuntimeActivityRepository( init { scope.launch { registry.selected.collectLatest selected@{ target -> - mutableState.value = RuntimeActivityState() + mutableState.value = + RuntimeActivityState(completedSessionIds = mutableState.value.completedSessionIds) if (target == null) return@selected - // The event stream is kept alive across transient runtime-state churn (e.g. a - // health recheck that briefly reports Connecting). Restarting it on every such - // blip used to drop whole replies on the floor. + // The stream follows the selected runtime, not its connection state. + // + // It used to only open once the runtime reported Connected. Every REST call + // (sending a prompt, loading a transcript) works regardless of that flag, so a + // runtime whose state never reached Connected still looked completely functional + // while silently never delivering a single event: no live reply, no drawer + // activity, no idle notification. Reconnection is handled by the retry below, so + // opening eagerly and retrying is both simpler and strictly more robust. coroutineScope { - var streamJob: Job? = null + launch { streamEvents(target) } + + // Losing the runtime clears in-flight state, but never the unread markers: + // a dropped connection says nothing about what the user has read. target.state.collect { runtimeState -> - when (runtimeState) { - is RuntimeState.Connected -> { - if (streamJob?.isActive != true) { - streamJob = launch { streamEvents(target) } - } - } - RuntimeState.Connecting -> Unit - else -> { - streamJob?.cancel() - streamJob = null - mutableState.update { - it.copy( - activeSessionIds = emptySet(), - completedSessionIds = emptySet(), - permissions = emptyList(), - ) - } - } + if (runtimeState is RuntimeState.Connected || runtimeState is RuntimeState.Connecting) return@collect + mutableState.update { + it.copy(activeSessionIds = emptySet(), permissions = emptyList()) } } } @@ -127,6 +132,49 @@ class RuntimeActivityRepository( mutableState.update { current -> current.copy(completedSessionIds = current.completedSessionIds - sessionId) } + persistUnread() + } + + /** + * Records a run the app started itself. + * + * Session state used to be derived purely from stream events, so with no event stream every + * chat sat on the grey idle dot even while it was plainly working. The chat reports its own + * runs here so the drawer stays truthful whether or not events are flowing. + */ + fun markSessionRunning(sessionId: String) { + if (sessionId.isBlank()) return + mutableState.update { current -> + current.copy( + activeSessionIds = current.activeSessionIds + sessionId, + completedSessionIds = current.completedSessionIds - sessionId, + ) + } + persistUnread() + } + + /** Records that a run finished, leaving the chat unread until it is opened. */ + fun markSessionFinished( + sessionId: String, + unread: Boolean, + ) { + if (sessionId.isBlank()) return + mutableState.update { current -> + current.copy( + activeSessionIds = current.activeSessionIds - sessionId, + completedSessionIds = + if (unread) { + current.completedSessionIds + sessionId + } else { + current.completedSessionIds - sessionId + }, + ) + } + persistUnread() + } + + private fun persistUnread() { + unreadStore?.unreadSessionIds = mutableState.value.completedSessionIds } private fun handle(event: OpenCodeEvent) { 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 b08965b..c74a4bb 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 @@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -209,7 +211,17 @@ class ChatViewModel( private val eventFlow: Flow? = null, private val onPermissionResolved: (String) -> Unit = {}, private val onSessionCreated: () -> Unit = {}, + /** + * Reports whether this chat is working, so the drawer shows real state even when no stream + * events arrive. Deriving it from events alone left every chat on the idle marker. + */ + private val onRunStateChanged: (String, Boolean) -> Unit = { _, _ -> }, private val draftRepo: DraftRepository? = null, + /** + * Starts the periodic connection probe. It runs an unbounded polling loop, which a virtual + * test clock advances through forever, so it stays off unless the real app asks for it. + */ + private val monitorConnectionQuality: Boolean = false, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -237,6 +249,27 @@ class ChatViewModel( init { if (backend != null) { + viewModelScope.launch { + // Only real transitions are reported, so merely opening an idle chat is not + // mistaken for a run that just ended. + var runningSession: String? = null + uiState + .map { it.sessionId to it.isRunning } + .distinctUntilChanged() + .collect { (sessionId, running) -> + if (sessionId == null) return@collect + when { + running && runningSession != sessionId -> { + runningSession = sessionId + onRunStateChanged(sessionId, true) + } + !running && runningSession == sessionId -> { + runningSession = null + onRunStateChanged(sessionId, false) + } + } + } + } eventJob = viewModelScope.launch { (eventFlow ?: backend.events()) @@ -262,10 +295,12 @@ class ChatViewModel( } reportError(lastError) } - connectionMonitor.startMonitoring { backend.health() } - viewModelScope.launch { - connectionMonitor.quality.collect { quality -> - _uiState.update { it.copy(connectionQuality = quality) } + if (monitorConnectionQuality) { + connectionMonitor.startMonitoring { backend.health() } + viewModelScope.launch { + connectionMonitor.quality.collect { quality -> + _uiState.update { it.copy(connectionQuality = quality) } + } } } } @@ -569,15 +604,13 @@ class ChatViewModel( if (uiMessages.isNotEmpty() && uiMessages != _uiState.value.messages) { _uiState.update { it.copy(messages = uiMessages) } } - } - if (!isStillActive()) return@withTimeoutOrNull - runCatching { currentBackend.session(targetSessionId) } - .onSuccess { sessionInfo -> - if (sessionInfo.time.completed != null) { + // Completion is read off the transcript. A session object + // carries no completion time — only assistant messages do — so + // polling the session never ended the run and the composer sat + // on the stop button until the 2 minute timeout expired. + if (turnFinished(serverMessages, messageIdsBeforeSend)) { sessionCompleted = true - if (isStillActive()) { - _uiState.update { it.copy(isRunning = false, isThinking = false) } - } + _uiState.update { it.copy(isRunning = false, isThinking = false) } } } } @@ -620,6 +653,24 @@ class ChatViewModel( } } + /** + * True once the assistant has answered this prompt and marked its reply finished. + * + * The newest message being a completed assistant turn is the signal every runtime reports; + * requiring a reply that did not exist before the prompt keeps the previous turn's completion + * from ending the new one instantly. + */ + private fun turnFinished( + messages: List, + messageIdsBeforeSend: Set, + ): Boolean { + val newest = messages.lastOrNull()?.info ?: return false + if (newest.role == "user") return false + val answeredThisPrompt = + messages.any { it.info.role != "user" && it.info.id !in messageIdsBeforeSend } + return answeredThisPrompt && newest.time.completed != null + } + private fun shouldSummarizeBeforePrompt(): Boolean = contextLimit > 0L && _uiState.value.contextTokensUsed.toDouble() / contextLimit.toDouble() >= 0.9 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 ed7b366..5f17e63 100644 --- a/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt +++ b/app/src/main/java/com/opencode/android/ui/OpenCodeApp.kt @@ -202,6 +202,10 @@ fun OpenCodeApp( ) val settingsState by settingsViewModel.state.collectAsState() + // Which chat the user is actually looking at. A run that finishes while they are elsewhere has + // to stay unread, so this is tracked separately from the chat view model's own session. + val visibleChatSessionId = remember { mutableStateOf(null) } + val chatViewModel: ChatViewModel = viewModel( key = "chat-${selectedRuntime?.id ?: "none"}", @@ -212,6 +216,19 @@ fun OpenCodeApp( eventFlow = app.activityRepository.events, onPermissionResolved = app.activityRepository::resolvePermission, onSessionCreated = app.catalogRepository::refreshSessionsOnly, + onRunStateChanged = { sessionId, running -> + if (running) { + app.activityRepository.markSessionRunning(sessionId) + } else { + // A chat the user is not currently looking at stays unread; the one + // on screen has just been read by definition. + app.activityRepository.markSessionFinished( + sessionId = sessionId, + unread = visibleChatSessionId.value != sessionId, + ) + } + }, + monitorConnectionQuality = true, ) }, ) @@ -456,6 +473,18 @@ fun OpenCodeApp( val drawerScope = rememberCoroutineScope() val currentRoute = backStackEntry?.destination?.route + LaunchedEffect(currentRoute, chatState.sessionId) { + visibleChatSessionId.value = chatState.sessionId?.takeIf { currentRoute == ROUTE_CHAT } + } + + // Opening a chat and watching it finish counts as reading it. + LaunchedEffect(currentRoute, chatState.sessionId, chatState.isRunning) { + val sessionId = chatState.sessionId + if (currentRoute == ROUTE_CHAT && sessionId != null && !chatState.isRunning) { + app.activityRepository.markSessionRead(sessionId) + } + } + val subagentInfos = remember(activityState.sessions, chatState.sessionId, activityState.activeSessionIds) { val parentId = chatState.sessionId ?: return@remember emptyList() diff --git a/app/src/main/java/com/opencode/android/ui/components/StatusDot.kt b/app/src/main/java/com/opencode/android/ui/components/StatusDot.kt index 673644d..c58f20f 100644 --- a/app/src/main/java/com/opencode/android/ui/components/StatusDot.kt +++ b/app/src/main/java/com/opencode/android/ui/components/StatusDot.kt @@ -3,15 +3,14 @@ package com.opencode.android.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.opencode.android.ui.theme.LocalThemeColors @@ -25,6 +24,12 @@ enum class SessionStatus { IDLE, } +/** + * Chat state at a glance. + * + * Working shows a spinner, a finished-but-unread chat shows a filled blue dot, and a chat the user + * has already read shows a muted grey dot. + */ @Composable fun StatusDot( status: SessionStatus, @@ -32,13 +37,23 @@ fun StatusDot( size: Dp = 8.dp, ) { val tc = LocalThemeColors.current + + if (status == SessionStatus.RUNNING) { + CircularProgressIndicator( + modifier = modifier.size(size + 4.dp), + color = tc.statusRunning, + strokeWidth = 1.5.dp, + ) + return + } + val targetColor = when (status) { SessionStatus.RUNNING -> tc.statusRunning SessionStatus.WAITING -> tc.statusWaiting SessionStatus.ERROR -> tc.statusError SessionStatus.PERMISSION -> tc.statusPermission - SessionStatus.COMPLETED_UNREAD -> tc.statusWaiting + SessionStatus.COMPLETED_UNREAD -> tc.statusUnread SessionStatus.IDLE -> tc.statusIdle } val color by animateColorAsState( @@ -51,15 +66,6 @@ fun StatusDot( modifier .size(if (status == SessionStatus.COMPLETED_UNREAD) size + 2.dp else size) .clip(CircleShape) - .then( - if (status == SessionStatus.COMPLETED_UNREAD) { - Modifier.border(2.dp, color, CircleShape) - } else { - Modifier - }, - ) - .background( - if (status == SessionStatus.COMPLETED_UNREAD) Color.Transparent else color, - ), + .background(color), ) } diff --git a/app/src/main/java/com/opencode/android/ui/theme/Color.kt b/app/src/main/java/com/opencode/android/ui/theme/Color.kt index 431e6d3..5a3b5e1 100644 --- a/app/src/main/java/com/opencode/android/ui/theme/Color.kt +++ b/app/src/main/java/com/opencode/android/ui/theme/Color.kt @@ -28,6 +28,8 @@ data class ThemeColors( val statusError: Color, val statusPermission: Color, val statusIdle: Color, + /** Finished-but-unread chats in the drawer. */ + val statusUnread: Color, ) val DarkTheme = @@ -57,6 +59,7 @@ val DarkTheme = statusError = Color(0xFFF44336), statusPermission = Color(0xFFFFC107), statusIdle = Color(0xFF757575), + statusUnread = Color(0xFF4C8DF6), ) val LightTheme = @@ -86,6 +89,7 @@ val LightTheme = statusError = Color(0xFFF44336), statusPermission = Color(0xFFFFC107), statusIdle = Color(0xFF9E9E9E), + statusUnread = Color(0xFF4C8DF6), ) val ZincTheme = @@ -115,6 +119,7 @@ val ZincTheme = statusError = Color(0xFFF44336), statusPermission = Color(0xFFFFC107), statusIdle = Color(0xFF71717A), + statusUnread = Color(0xFF4C8DF6), ) val MidnightTheme = @@ -144,6 +149,7 @@ val MidnightTheme = statusError = Color(0xFFF44336), statusPermission = Color(0xFFFFC107), statusIdle = Color(0xFF5A5F70), + statusUnread = Color(0xFF4C8DF6), ) val ClaudeTheme = @@ -173,6 +179,7 @@ val ClaudeTheme = statusError = Color(0xFFF44336), statusPermission = Color(0xFFFFC107), statusIdle = Color(0xFF78716C), + statusUnread = Color(0xFF4C8DF6), ) val GhosttyTheme = @@ -202,6 +209,7 @@ val GhosttyTheme = statusError = Color(0xFFF44336), statusPermission = Color(0xFFFFC107), statusIdle = Color(0xFF6B7385), + statusUnread = Color(0xFF4C8DF6), ) val OpenCodeBackground get() = DarkTheme.surface0 diff --git a/app/src/main/res/layout/widget_quick_input.xml b/app/src/main/res/layout/widget_quick_input.xml index b2c5618..e4884ca 100644 --- a/app/src/main/res/layout/widget_quick_input.xml +++ b/app/src/main/res/layout/widget_quick_input.xml @@ -15,20 +15,26 @@ android:src="@mipmap/ic_launcher" android:contentDescription="@string/app_name" /> - + اختيار سمة بناء الجملة الاستخدام حجم الخط + أرسل رسالة إلى OpenCode… + النماذج المرئية + النماذج المرئية + أزل التحديد عن النماذج لإخفائها من قائمة اختيار النموذج. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 724f1be..023178e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -564,4 +564,8 @@ Elegir tema de sintaxis Uso Tamaño de fuente + Enviar un mensaje a OpenCode… + Modelos visibles + Modelos visibles + Desmarca los modelos para ocultarlos del selector de modelos. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 63741c6..1a85b73 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -564,4 +564,8 @@ Choisir le thème de syntaxe Utilisation Taille de police + Envoyer un message à OpenCode… + Modèles visibles + Modèles visibles + Décochez les modèles pour les masquer dans le sélecteur de modèles. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 3dd1fc9..1121cd0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -581,4 +581,5 @@ シンタックステーマを選択 使用量 フォントサイズ + OpenCodeにメッセージを送る… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 256782c..879fbb4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -564,4 +564,8 @@ Escolher tema de sintaxe Uso Tamanho da fonte + Enviar mensagem para o OpenCode… + Modelos visíveis + Modelos visíveis + Desmarque os modelos para ocultá-los do seletor de modelos. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2be842a..2a07a2c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -564,4 +564,8 @@ Выбрать тему синтаксиса Использование Размер шрифта + Отправить сообщение OpenCode… + Показываемые модели + Показываемые модели + Снимите отметку с моделей, чтобы скрыть их из списка выбора. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ebcca69..b9f6e8c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -564,4 +564,8 @@ 选择语法主题 用量 字体大小 + 向 OpenCode 发送消息… + 显示的模型 + 显示的模型 + 取消勾选的模型将不会出现在模型选择器中。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee6f828..2cebb0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -581,4 +581,5 @@ Unified Split +%1$d / -%2$d + Message OpenCode… diff --git a/app/src/test/java/com/opencode/android/core/api/ChatStreamDecodingTest.kt b/app/src/test/java/com/opencode/android/core/api/ChatStreamDecodingTest.kt new file mode 100644 index 0000000..ec7e640 --- /dev/null +++ b/app/src/test/java/com/opencode/android/core/api/ChatStreamDecodingTest.kt @@ -0,0 +1,139 @@ +package com.opencode.android.core.api + +import com.opencode.android.data.connection.ConnectionProfile +import com.opencode.android.runtime.remote.RemoteOpenCodeBackend +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * Guards the live-reply path at the point it actually broke before: decoding. + * + * The chat drops any streamed part whose session, message or part id fails to decode, and it ends + * a run off the assistant message's completion time. A regression in any of those fields produces + * a chat that looks connected, accepts prompts, and simply never updates — so each is pinned here + * against the wire format the OpenCode server really emits. + */ +class ChatStreamDecodingTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun backend(): RemoteOpenCodeBackend { + val profile = + ConnectionProfile( + id = "remote", + name = "Remote", + baseUrl = server.url("/").toString(), + username = "opencode", + ) + val http = + OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + return RemoteOpenCodeBackend(profile, OpenCodeApiClient(profile, http)) + } + + @Test + fun `streamed parts arrive over a real event stream with their identity intact`() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody( + "data: " + + """{"type":"message.part.updated","properties":{"part":{"id":"prt_1",""" + + """"sessionID":"ses_1","messageID":"msg_a","type":"text","text":"Hello"}}}""" + + "\n\n" + + "data: " + + """{"type":"session.idle","properties":{"sessionID":"ses_1"}}""" + + "\n\n", + ), + ) + + val events = + withTimeout(10_000) { + backend().events().take(2).toList() + } + + val part = (events[0] as OpenCodeEvent.MessagePartUpdated).part + assertEquals("ses_1", part.sessionId) + assertEquals("msg_a", part.messageId) + assertEquals("prt_1", part.id) + assertEquals("Hello", part.text) + assertEquals("ses_1", (events[1] as OpenCodeEvent.SessionIdle).sessionId) + } + + @Test + fun `tool part keeps the state the chat renders from`() { + val event = + OpenCodeEventParser().parse( + """{"type":"message.part.updated","properties":{"part":{"id":"prt_2",""" + + """"sessionID":"ses_1","messageID":"msg_a","type":"tool","tool":"bash",""" + + """"callID":"call_1","state":{"status":"running","input":{"command":"ls -la"}}}}}""", + ) + + assertTrue("parsed as $event", event is OpenCodeEvent.MessagePartUpdated) + val part = (event as OpenCodeEvent.MessagePartUpdated).part + assertEquals("tool", part.type) + assertEquals("bash", part.tool) + assertTrue("tool state was dropped: ${part.state}", part.state != null) + } + + @Test + fun `assistant completion time survives decoding`() = + runBlocking { + // Ending a run is driven off this field; without it the composer sits on the stop + // button until the fallback timeout expires. + server.enqueue( + MockResponse().setBody( + """[{"info":{"id":"msg_a","sessionID":"ses_1","role":"assistant",""" + + """"time":{"created":1,"completed":2}},"parts":[{"id":"prt_1",""" + + """"sessionID":"ses_1","messageID":"msg_a","type":"text","text":"Stored"}]}]""", + ), + ) + + val messages = backend().listMessages("ses_1") + + assertEquals(1, messages.size) + assertEquals("ses_1", messages.single().info.sessionId) + assertEquals(2L, messages.single().info.time.completed) + assertEquals("Stored", messages.single().text) + } + + @Test + fun `a running assistant message reports no completion time`() = + runBlocking { + server.enqueue( + MockResponse().setBody( + """[{"info":{"id":"msg_a","sessionID":"ses_1","role":"assistant",""" + + """"time":{"created":1}},"parts":[{"id":"prt_1","sessionID":"ses_1",""" + + """"messageID":"msg_a","type":"text","text":"Working"}]}]""", + ), + ) + + val messages = backend().listMessages("ses_1") + + assertEquals(null, messages.single().info.time.completed) + } +} 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 50831fa..634cbfa 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 @@ -32,10 +32,12 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class RuntimeActivityRepositoryTest { @Test - fun `does not open events until selected runtime is connected`() = + fun `opens events for the selected runtime without waiting for a connected state`() = runTest { + // REST calls work regardless of the runtime's connection flag, so gating the stream on + // it left runtimes that never reported Connected permanently event-less. val dispatcher = StandardTestDispatcher(testScheduler) - val target = FakeTarget() + val target = FakeTarget(requireConnected = false) val registry = RuntimeRegistry( store = FakeStore(selectedRuntimeId = target.id), @@ -46,11 +48,6 @@ class RuntimeActivityRepositoryTest { advanceUntilIdle() - assertEquals(0, target.eventCalls) - assertTrue(repository.state.value.logs.isEmpty()) - - target.state.value = RuntimeState.Connected("1.18.3") - advanceUntilIdle() assertEquals(1, target.eventCalls) target.eventFlow.emit(OpenCodeEvent.ServerConnected) @@ -60,11 +57,11 @@ class RuntimeActivityRepositoryTest { } @Test - fun `retries events after stream failure while runtime stays connected`() = + fun `retries events after stream failure`() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val target = - FakeTarget().apply { + FakeTarget(requireConnected = false).apply { eventFlows += flow { throw IllegalStateException("stream dropped") } eventFlows += eventFlow } @@ -81,7 +78,6 @@ class RuntimeActivityRepositoryTest { retryDelayMillis = 1L, ) - target.state.value = RuntimeState.Connected("1.18.3") advanceUntilIdle() assertEquals(2, target.eventCalls) @@ -95,10 +91,10 @@ class RuntimeActivityRepositoryTest { } @Test - fun `a brief Connecting blip does not restart the event stream`() = + fun `runtime state churn never restarts the event stream`() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) - val target = FakeTarget() + val target = FakeTarget(requireConnected = false) val registry = RuntimeRegistry( store = FakeStore(selectedRuntimeId = target.id), @@ -107,45 +103,104 @@ class RuntimeActivityRepositoryTest { ) RuntimeActivityRepository(registry, TestScope(dispatcher)) - target.state.value = RuntimeState.Connected("1.18.3") advanceUntilIdle() assertEquals(1, target.eventCalls) - // A health recheck on an already-connected runtime dips back to Connecting and returns; - // the existing SSE connection must survive it rather than being torn down and reopened. + // Health rechecks and short drops must not tear the live stream down; the stream's own + // retry handles a connection that genuinely died. target.state.value = RuntimeState.Connecting advanceUntilIdle() target.state.value = RuntimeState.Connected("1.18.3") advanceUntilIdle() + target.state.value = RuntimeState.Disconnected + advanceUntilIdle() assertEquals(1, target.eventCalls) } @Test - fun `going disconnected then reconnecting does reopen the stream`() = + fun `losing the runtime clears activity but keeps unread markers`() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) - val target = FakeTarget() + val target = FakeTarget(requireConnected = false) val registry = RuntimeRegistry( store = FakeStore(selectedRuntimeId = target.id), localTarget = target, remoteFactory = { error("unused") }, ) - RuntimeActivityRepository(registry, TestScope(dispatcher)) + val repository = RuntimeActivityRepository(registry, TestScope(dispatcher)) + advanceUntilIdle() target.state.value = RuntimeState.Connected("1.18.3") advanceUntilIdle() - assertEquals(1, target.eventCalls) + + repository.markSessionRunning("ses_running") + repository.markSessionFinished("ses_done", unread = true) target.state.value = RuntimeState.Disconnected advanceUntilIdle() - target.state.value = RuntimeState.Connected("1.18.3") + + assertTrue(repository.state.value.activeSessionIds.isEmpty()) + assertEquals(setOf("ses_done"), repository.state.value.completedSessionIds) + } + + @Test + fun `a chat reports its own run so the drawer works without events`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val repository = RuntimeActivityRepository(registry, TestScope(dispatcher)) advanceUntilIdle() - assertEquals(2, target.eventCalls) + repository.markSessionRunning("ses_1") + assertEquals(setOf("ses_1"), repository.state.value.activeSessionIds) + + repository.markSessionFinished("ses_1", unread = true) + assertTrue(repository.state.value.activeSessionIds.isEmpty()) + assertEquals(setOf("ses_1"), repository.state.value.completedSessionIds) + + repository.markSessionRead("ses_1") + assertTrue(repository.state.value.completedSessionIds.isEmpty()) } + @Test + fun `unread markers are restored from the store on startup`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val unread = FakeUnreadStore(setOf("ses_old")) + + val repository = + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + unreadStore = unread, + ) + advanceUntilIdle() + + assertEquals(setOf("ses_old"), repository.state.value.completedSessionIds) + + repository.markSessionRead("ses_old") + assertTrue(unread.unreadSessionIds.isEmpty()) + } + + private class FakeUnreadStore( + override var unreadSessionIds: Set, + ) : UnreadSessionStore + private class FakeStore( override var selectedRuntimeId: String?, ) : RuntimeConnectionStore { @@ -156,7 +211,9 @@ class RuntimeActivityRepositoryTest { override fun deleteConnection(id: String) = Unit } - private class FakeTarget : RuntimeTarget { + private class FakeTarget( + private val requireConnected: Boolean = true, + ) : RuntimeTarget { override val id: String = "local-android" override val displayName: String = "このAndroid端末" override val kind: BackendKind = BackendKind.LOCAL @@ -202,7 +259,7 @@ class RuntimeActivityRepositoryTest { ): Boolean = true override fun events(): Flow { - check(state.value is RuntimeState.Connected) { "runtime is not connected" } + if (requireConnected) check(state.value is RuntimeState.Connected) { "runtime is not connected" } eventCalls++ return eventFlows.removeFirstOrNull() ?: eventFlow } From 117880d519cca0a370cde4a919c06d4d556bcd5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:22:32 +0000 Subject: [PATCH 2/5] style: drop the import left behind by removing the stream job spotlessCheck flagged kotlinx.coroutines.Job as unused after the stream lifecycle stopped tracking a job handle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RLvEkDwiWT8R4S23gdPsSB --- .../android/data/repository/RuntimeActivityRepository.kt | 1 - 1 file changed, 1 deletion(-) 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 d82ea12..660062f 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 @@ -6,7 +6,6 @@ import com.opencode.android.runtime.RuntimeRegistry import com.opencode.android.runtime.RuntimeState import com.opencode.android.runtime.RuntimeTarget import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow From 4803db75c2c6eeb77d8d23050a55df52a761512b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:43:39 +0000 Subject: [PATCH 3/5] fix: repair the screenshot test against the current composable signatures The androidTest source set is only compiled by the emulator capture job, so it silently rotted when AppDrawerContent gained project selection and VoiceSettingsScreen gained the wake-word toggle. The capture job has been failing to compile on main since; this restores it and exercises the new running and unread drawer markers while it is there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RLvEkDwiWT8R4S23gdPsSB --- .../ui/UiScreenshotInstrumentedTest.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 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 453e42d..bd1a218 100644 --- a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt +++ b/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt @@ -40,6 +40,7 @@ import com.opencode.android.feature.workspace.RemoteConnectionScreen import com.opencode.android.runtime.LocalRuntimeStatus import com.opencode.android.runtime.RuntimeType import com.opencode.android.runtime.WorkspaceRef +import com.opencode.android.ui.components.SessionStatus import com.opencode.android.ui.theme.OpenCodeAndroidTheme import org.junit.Rule import org.junit.Test @@ -128,13 +129,29 @@ class UiScreenshotInstrumentedTest { AppDrawerContent( recentSessions = listOf( - DrawerRecentSession("1", "認証エラーの調査", "3時間前"), - DrawerRecentSession("2", "READMEを更新", "昨日"), + DrawerRecentSession( + "1", + "認証エラーの調査", + "3時間前", + status = SessionStatus.RUNNING, + ), + DrawerRecentSession( + "2", + "READMEを更新", + "昨日", + status = SessionStatus.COMPLETED_UNREAD, + ), DrawerRecentSession("3", "テスト失敗を修正", "2日前"), DrawerRecentSession("4", "APIレスポンスを整理", "4日前"), DrawerRecentSession("5", "依存関係を更新", "1週間前"), ), + workspaces = + listOf( + WorkspaceRef("1", "project", "/workspace/project"), + ), + selectedWorkspacePath = "/workspace/project", onNewChat = {}, + onSelectProject = {}, onOpenSession = { _, _ -> }, onNavigate = {}, ) @@ -271,8 +288,10 @@ class UiScreenshotInstrumentedTest { VoiceSettingsScreen( ttsEnabled = true, continuousConversation = false, + wakeWordEnabled = false, onTtsChange = {}, onContinuousChange = {}, + onWakeWordChange = {}, onBack = {}, ) } From 3a019ed24b63e5b6f6772a1348cb2462a777c54c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:45:20 +0000 Subject: [PATCH 4/5] build: define the jacocoTestReport task CI asks for The jacoco plugin only attaches a report task to the JVM `test` task, which an Android module does not have, so `:app:jacocoTestReport` never existed and CI's coverage step failed on every run. Registers a report over the debug unit tests, writing where the workflow expects it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RLvEkDwiWT8R4S23gdPsSB --- app/build.gradle.kts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aad167b..d2accfe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -242,3 +242,35 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } + +// The base jacoco plugin only wires a report task to the JVM `test` task, which an Android module +// does not have — so `:app:jacocoTestReport` did not exist and CI's coverage step failed outright. +tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") + group = "verification" + description = "Generates a coverage report for the debug unit tests." + + reports { + html.required.set(true) + xml.required.set(true) + } + + val generated = + listOf( + "**/R.class", + "**/R$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + "**/*_Factory*.*", + "**/*Composable*.*", + "**/*\$\$serializer.*", + ) + classDirectories.setFrom( + fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(generated) }, + ) + sourceDirectories.setFrom(files("src/main/java")) + executionData.setFrom( + fileTree(layout.buildDirectory) { include("**/*.exec", "**/*.ec") }, + ) +} From e3eae6740cb1ce0ccd162cb5aba0cf584d90fd66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:56:00 +0000 Subject: [PATCH 5/5] build: scope the coverage execution data to the unit test output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning the whole build directory for exec files made Gradle treat every other task's output as an undeclared input, so the report failed as soon as a release variant was assembled in the same invocation — which is exactly what CI does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RLvEkDwiWT8R4S23gdPsSB --- app/build.gradle.kts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2accfe..b8c780d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -270,7 +270,13 @@ tasks.register("jacocoTestReport") { fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(generated) }, ) sourceDirectories.setFrom(files("src/main/java")) + // Only the unit test coverage directories, never the whole build directory: scanning that + // makes Gradle treat every other task's output as an undeclared input of this one, which fails + // the build as soon as a release variant is assembled in the same invocation. executionData.setFrom( - fileTree(layout.buildDirectory) { include("**/*.exec", "**/*.ec") }, + fileTree(layout.buildDirectory.dir("jacoco")) { include("*.exec") }, + fileTree(layout.buildDirectory.dir("outputs/unit_test_code_coverage")) { + include("**/*.exec") + }, ) }