diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aad167b..b8c780d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -242,3 +242,41 @@ 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")) + // 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.dir("jacoco")) { include("*.exec") }, + fileTree(layout.buildDirectory.dir("outputs/unit_test_code_coverage")) { + include("**/*.exec") + }, + ) +} 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 = {}, ) } 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..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 @@ -29,6 +28,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 +49,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 +70,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 +131,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 }