git.iko.soy/opencode-android
on master help sign in
Fix idempotency key per retry, stale two-pane ViewModel, loading stuck on error, cancellation swallowing, server-switch disconnect, shutdown race, reconnect TOCTOU, SSE close race, activeRuns overflow, premature running reset, missing retry on delete/abort, plaintext password fallback, non-atomic profile writes, picker error states, loading a11y, nested a11y in session cards, SSE jitter clamp, and injectable SSE timing
a9143f4iko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+10−6
@@ -39,6 +39,10 @@ import kotlin.random.Random
39class EventStreamClient(
39class EventStreamClient(
40 private val client: HttpClient,
40 private val client: HttpClient,
41 scope: CoroutineScope,
41 scope: CoroutineScope,
42+ private val idleTimeoutMs: Long = NetworkConfig.sseIdleTimeoutMs,
43+ private val initialBackoffMs: Long = NetworkConfig.sseInitialBackoffMs,
44+ private val maxBackoffMs: Long = NetworkConfig.sseMaxBackoffMs,
45+ private val bufferCapacity: Int = NetworkConfig.sseEventBufferCapacity,
42) {
46) {
43 enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
47 enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
44
48
@@ -49,7 +53,7 @@ class EventStreamClient(
49 // Buffer so a slow subscriber (e.g. a reducer under lock) can't suspend send()
53 // Buffer so a slow subscriber (e.g. a reducer under lock) can't suspend send()
50 // and stall the SSE read loop. Drop oldest on overflow — the next event carries
54 // and stall the SSE read loop. Drop oldest on overflow — the next event carries
51 // the freshest state, so a dropped interim delta is harmless.
55 // the freshest state, so a dropped interim delta is harmless.
52− .buffer(NetworkConfig.sseEventBufferCapacity, BufferOverflow.DROP_OLDEST)
56+ .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
53 .shareIn(
57 .shareIn(
54 scope = scope,
58 scope = scope,
55 started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
59 started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
@@ -84,7 +88,7 @@ class EventStreamClient(
84 // within the idle timeout, treat the connection as half-open and drop it.
88 // within the idle timeout, treat the connection as half-open and drop it.
85 val result: ChannelResult<ServerSentEvent>? = select {
89 val result: ChannelResult<ServerSentEvent>? = select {
86 events.onReceiveCatching { it }
90 events.onReceiveCatching { it }
87− onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
91+ onTimeout(idleTimeoutMs) { null }
88 }
92 }
89 if (result == null) throw IOException("SSE idle timeout, reconnecting")
93 if (result == null) throw IOException("SSE idle timeout, reconnecting")
90 val sse = result.getOrNull() ?: break
94 val sse = result.getOrNull() ?: break
@@ -115,7 +119,7 @@ class EventStreamClient(
115 // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
119 // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
116 // stopped the upstream while triggerReconnect() queued a signal).
120 // stopped the upstream while triggerReconnect() queued a signal).
117 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
121 while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
118− var backoffMs = NetworkConfig.sseInitialBackoffMs
122+ var backoffMs = initialBackoffMs
119 while (isActive) {
123 while (isActive) {
120 _state.value = ConnectionState.Connecting
124 _state.value = ConnectionState.Connecting
121 try {
125 try {
@@ -138,7 +142,7 @@ class EventStreamClient(
138 }
142 }
139 },
143 },
140 ) {
144 ) {
141− backoffMs = NetworkConfig.sseInitialBackoffMs
145+ backoffMs = initialBackoffMs
142 // Clear any stale reconnect signal from a triggerReconnect() call
146 // Clear any stale reconnect signal from a triggerReconnect() call
143 // made while the previous stream was healthy, so it doesn't suppress
147 // made while the previous stream was healthy, so it doesn't suppress
144 // the next legitimate backoff.
148 // the next legitimate backoff.
@@ -173,9 +177,9 @@ class EventStreamClient(
173 val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
177 val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
174 select {
178 select {
175 reconnectSignal.onReceive { signaled = true }
179 reconnectSignal.onReceive { signaled = true }
176− onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
180+ onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
177 }
181 }
178− backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
182+ backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
179 }
183 }
180 }
184 }
181}
185}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+23−20
@@ -46,9 +46,9 @@ class OpencodeApiClient(private val client: HttpClient) {
46 // between sessions on the same server.
46 // between sessions on the same server.
47 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
47 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
48 private val cacheMutex = Mutex()
48 private val cacheMutex = Mutex()
49− @Volatile private var cachedProviders: CachedEntry<ProvidersResponse>? = null
49+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50− @Volatile private var cachedAgents: CachedEntry<List<Agent>>? = null
50+ private var cachedAgents: CachedEntry<List<Agent>>? = null
51− @Volatile private var cachedCommands: CachedEntry<List<Command>>? = null
51+ private var cachedCommands: CachedEntry<List<Command>>? = null
52
52
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
54 suspend fun ping() {
54 suspend fun ping() {
@@ -74,7 +74,7 @@ class OpencodeApiClient(private val client: HttpClient) {
74 }
74 }
75
75
76 suspend fun deleteSession(id: String) {
76 suspend fun deleteSession(id: String) {
77− client.delete("session/${encode(id)}")
77+ withRetry { client.delete("session/${encode(id)}") }
78 }
78 }
79
79
80 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
80 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
@@ -86,26 +86,29 @@ class OpencodeApiClient(private val client: HttpClient) {
86 text: String,
86 text: String,
87 model: ModelRef? = null,
87 model: ModelRef? = null,
88 agent: String? = null,
88 agent: String? = null,
89− ): MessageWithParts = withRetry {
89+ ): MessageWithParts {
90− // Send an idempotency key so the server can safely deduplicate if a transient
90+ // Generate the idempotency key *before* entering withRetry so all retry
91− // network failure causes this request to be retried. The key is unique per call
91+ // attempts share the same key. If the key were generated inside withRetry,
92− // so a genuine re-send (e.g. retry-failed) still creates a new message.
92+ // each attempt would get a fresh UUID and the server couldn't deduplicate a
93+ // request that reached it but whose response was lost (e.g. timeout).
93 val idempotencyKey = java.util.UUID.randomUUID().toString()
94 val idempotencyKey = java.util.UUID.randomUUID().toString()
95+ return withRetry {
94− client.post("session/${encode(sessionId)}/message") {
96+ client.post("session/${encode(sessionId)}/message") {
95− contentType(ContentType.Application.Json)
97+ contentType(ContentType.Application.Json)
96− header("Idempotency-Key", idempotencyKey)
98+ header("Idempotency-Key", idempotencyKey)
97− setBody(
99+ setBody(
98− PromptRequest(
100+ PromptRequest(
99− parts = listOf(PromptPart(text = text)),
101+ parts = listOf(PromptPart(text = text)),
100− model = model,
102+ model = model,
101− agent = agent,
103+ agent = agent,
102− ),
104+ ),
103− )
105+ )
104− }.body()
106+ }.body()
107+ }
105 }
108 }
106
109
107 suspend fun abort(sessionId: String) {
110 suspend fun abort(sessionId: String) {
108− client.post("session/${encode(sessionId)}/abort")
111+ withRetry { client.post("session/${encode(sessionId)}/abort") }
109 }
112 }
110
113
111 /** Invoke a slash-command by name via `POST /session/:id/command`. */
114 /** Invoke a slash-command by name via `POST /session/:id/command`. */
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+33−17
@@ -29,7 +29,7 @@ class ProfileStore(context: Context) {
29 private val appContext = context.applicationContext
29 private val appContext = context.applicationContext
30 private val profilesKey = stringPreferencesKey("profiles_json")
30 private val profilesKey = stringPreferencesKey("profiles_json")
31
31
32− private val securePrefs: SharedPreferences by lazy {
32+ private val securePrefs: SharedPreferences? by lazy {
33 runCatching {
33 runCatching {
34 val masterKey = MasterKey.Builder(appContext)
34 val masterKey = MasterKey.Builder(appContext)
35 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
35 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -42,11 +42,23 @@ class ProfileStore(context: Context) {
42 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
42 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
43 )
43 )
44 }.getOrElse {
44 }.getOrElse {
45− Log.e("ProfileStore", "Encrypted prefs unavailable, falling back to plaintext", it)
45+ Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
46− appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
46+ null
47 }
47 }
48 }
48 }
49
49
50+ /** True when EncryptedSharedPreferences could not be initialized. */
51+ val securePrefsUnavailable: Boolean get() = securePrefs == null
52+
53+ /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
54+ * can still connect — but passwords won't survive an app reinstall. */
55+ private val fallbackPrefs: SharedPreferences by lazy {
56+ appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
57+ }
58+
59+ private fun prefsForPasswords(): SharedPreferences =
60+ securePrefs ?: fallbackPrefs
61+
50 /** Stored shape on DataStore (everything except the secret password). */
62 /** Stored shape on DataStore (everything except the secret password). */
51 @Serializable
63 @Serializable
52 private data class StoredProfile(
64 private data class StoredProfile(
@@ -85,9 +97,22 @@ class ProfileStore(context: Context) {
85 }
97 }
86
98
87 private suspend fun passwordFor(id: String): String? =
99 private suspend fun passwordFor(id: String): String? =
88− withContext(Dispatchers.IO) { securePrefs.getString(passwordKey(id), null) }
100+ withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(id), null) }
89
101
90 suspend fun save(profile: ServerProfile) {
102 suspend fun save(profile: ServerProfile) {
103+ // Write the password first so that if the DataStore write fails, we're left
104+ // with an orphaned password (harmless, cleaned up on next save/delete) rather
105+ // than a profile with no password (which would break authentication).
106+ withContext(Dispatchers.IO) {
107+ prefsForPasswords().edit().apply {
108+ val pw = profile.password
109+ if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
110+ putString(passwordKey(profile.id), pw)
111+ } else {
112+ remove(passwordKey(profile.id))
113+ }
114+ }.apply()
115+ }
91 appContext.dataStore.edit { prefs ->
116 appContext.dataStore.edit { prefs ->
92 val current = prefs[profilesKey]?.let {
117 val current = prefs[profilesKey]?.let {
93 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
118 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
@@ -103,21 +128,12 @@ class ProfileStore(context: Context) {
103 val updated = current.filterNot { it.id == profile.id } + stored
128 val updated = current.filterNot { it.id == profile.id } + stored
104 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
129 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
105 }
130 }
106− // Secret goes to encrypted storage (or is cleared when auth removed).
107− // EncryptedSharedPreferences encrypts on the calling thread, so move to IO.
108− withContext(Dispatchers.IO) {
109− securePrefs.edit().apply {
110− val pw = profile.password
111− if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
112− putString(passwordKey(profile.id), pw)
113− } else {
114− remove(passwordKey(profile.id))
115− }
116− }.apply()
117− }
118 }
131 }
119
132
120 suspend fun delete(id: String) {
133 suspend fun delete(id: String) {
134+ // Delete the profile from DataStore first, then the password. If the process
135+ // dies between the two, an orphaned password remains (harmless, cleaned up on
136+ // next save/delete) rather than a profile with no password.
121 appContext.dataStore.edit { prefs ->
137 appContext.dataStore.edit { prefs ->
122 val current = prefs[profilesKey]?.let {
138 val current = prefs[profilesKey]?.let {
123 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
139 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
@@ -129,7 +145,7 @@ class ProfileStore(context: Context) {
129 )
145 )
130 }
146 }
131 withContext(Dispatchers.IO) {
147 withContext(Dispatchers.IO) {
132− securePrefs.edit().remove(passwordKey(id)).apply()
148+ prefsForPasswords().edit().remove(passwordKey(id)).apply()
133 }
149 }
134 }
150 }
135
151
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+12−3
@@ -14,6 +14,7 @@ import soy.iko.opencode.data.model.UnknownMessage
14import soy.iko.opencode.data.network.EventStreamClient
14import soy.iko.opencode.data.network.EventStreamClient
15import soy.iko.opencode.data.network.NetworkConfig
15import soy.iko.opencode.data.network.NetworkConfig
16import soy.iko.opencode.data.network.OpencodeApiClient
16import soy.iko.opencode.data.network.OpencodeApiClient
17+import soy.iko.opencode.util.runCatchingCancellable
17import android.util.Log
18import android.util.Log
18import kotlinx.coroutines.flow.Flow
19import kotlinx.coroutines.flow.Flow
19import kotlinx.coroutines.flow.channelFlow
20import kotlinx.coroutines.flow.channelFlow
@@ -76,13 +77,21 @@ class SessionRepository(
76 // by the server during the disconnection gap are lost from the in-memory state;
77 // by the server during the disconnection gap are lost from the in-memory state;
77 // re-fetching the current snapshot fills those holes. The seed merge logic
78 // re-fetching the current snapshot fills those holes. The seed merge logic
78 // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
79 // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
80+ //
81+ // Both the REST fetch and the seed are performed under the lock so that events
82+ // arriving via SSE between the fetch and the seed are not incorrectly pruned
83+ // by the prune=true pass.
79 launch {
84 launch {
80 var wasConnected = false
85 var wasConnected = false
81 eventStream.state.collect { state ->
86 eventStream.state.collect { state ->
82 if (state == EventStreamClient.ConnectionState.Connected) {
87 if (state == EventStreamClient.ConnectionState.Connected) {
83 if (wasConnected) {
88 if (wasConnected) {
89+ lock.withLock {
84− val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
90+ val fresh = runCatchingCancellable { api.listMessages(sessionId) }
91+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE", it) }
92+ .getOrDefault(emptyList())
85− lock.withLock { store.seed(fresh, prune = true) }
93+ store.seed(fresh, prune = true)
94+ }
86 publish()
95 publish()
87 }
96 }
88 wasConnected = true
97 wasConnected = true
@@ -93,7 +102,7 @@ class SessionRepository(
93 }
102 }
94 }
103 }
95
104
96− val initial = runCatching { api.listMessages(sessionId) }
105+ val initial = runCatchingCancellable { api.listMessages(sessionId) }
97 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
106 .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
98 .getOrDefault(emptyList())
107 .getOrDefault(emptyList())
99 lock.withLock { store.seed(initial) }
108 lock.withLock { store.seed(initial) }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+16−4
@@ -141,8 +141,15 @@ class AppContainer(context: Context) {
141
141
142 /** Release resources held for the process lifetime (network callback, app scope). */
142 /** Release resources held for the process lifetime (network callback, app scope). */
143 fun shutdown() {
143 fun shutdown() {
144+ // Acquire the connection mutex so a concurrent connect() can't leak a new
145+ // connection after we've nulled the reference. This runs on a shutdown hook
146+ // thread (or onTerminate), so a brief blocking wait is acceptable here.
147+ kotlinx.coroutines.runBlocking {
148+ connectionMutex.withLock {
144− _activeConnection.value?.close()
149+ _activeConnection.value?.close()
145− _activeConnection.value = null
150+ _activeConnection.value = null
151+ }
152+ }
146 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
153 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
147 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
154 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
148 appScope.cancel()
155 appScope.cancel()
@@ -180,7 +187,12 @@ class AppContainer(context: Context) {
180 // represent a finished run worth notifying about.
187 // represent a finished run worth notifying about.
181 if (event is MessagePartUpdated || event is MessageUpdated) {
188 if (event is MessagePartUpdated || event is MessageUpdated) {
182 synchronized(activeRuns) {
189 synchronized(activeRuns) {
190+ // Evict the oldest entry when the cap is reached instead of
191+ // clearing everything, so completion notifications for the
192+ // remaining active runs still fire on SessionIdle.
183− if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
193+ if (activeRuns.size >= activeRunsLimit) {
194+ activeRuns.remove(activeRuns.iterator().next())
195+ }
184 activeRuns.add(sid)
196 activeRuns.add(sid)
185 }
197 }
186 }
198 }
@@ -220,7 +232,7 @@ class AppContainer(context: Context) {
220 * returning user lands in their session list instead of the empty server screen.
232 * returning user lands in their session list instead of the empty server screen.
221 */
233 */
222 private suspend fun autoConnect() {
234 private suspend fun autoConnect() {
223− val recent = runCatching { profileStore.profiles.first() }
235+ val recent = runCatchingCancellable { profileStore.profiles.first() }
224 .getOrDefault(emptyList())
236 .getOrDefault(emptyList())
225 .firstOrNull() ?: return
237 .firstOrNull() ?: return
226 _reconnecting.value = true
238 _reconnecting.value = true
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+10−1
@@ -7,6 +7,7 @@ import soy.iko.opencode.data.network.OpencodeApiClient
7import soy.iko.opencode.data.repo.SessionRepository
7import soy.iko.opencode.data.repo.SessionRepository
8import kotlinx.coroutines.CoroutineScope
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.Job
10import kotlinx.coroutines.SupervisorJob
11import kotlinx.coroutines.SupervisorJob
11import kotlinx.coroutines.cancel
12import kotlinx.coroutines.cancel
12
13
@@ -18,14 +19,22 @@ import kotlinx.coroutines.cancel
18class OpencodeConnection(val profile: ServerProfile) {
19class OpencodeConnection(val profile: ServerProfile) {
19
20
20 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
21 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
22+ private val scopeJob: Job get() = scope.coroutineContext[Job]!!
21
23
22 private val client = HttpClientFactory.create(profile)
24 private val client = HttpClientFactory.create(profile)
23 val api = OpencodeApiClient(client)
25 val api = OpencodeApiClient(client)
24 val events = EventStreamClient(client, scope)
26 val events = EventStreamClient(client, scope)
25 val repository = SessionRepository(api, events)
27 val repository = SessionRepository(api, events)
26
28
29+ /**
30+ * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
31+ * to wind down before closing the HTTP client. Calling client.close() while the SSE
32+ * coroutine is still mid-request produces a spurious "stream error, will retry"
33+ * warning during normal teardown.
34+ */
27− fun close() {
35+ suspend fun close() {
28 scope.cancel()
36 scope.cancel()
37+ scopeJob.join()
29 client.close()
38 client.close()
30 }
39 }
31}
40}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12
@@ -37,7 +37,9 @@ fun AgentPickerSheet(
37 agents: List<Agent>,
37 agents: List<Agent>,
38 selected: String?,
38 selected: String?,
39 loading: Boolean,
39 loading: Boolean,
40+ error: Boolean,
40 onSelect: (Agent?) -> Unit,
41 onSelect: (Agent?) -> Unit,
42+ onRetry: () -> Unit,
41 onDismiss: () -> Unit,
43 onDismiss: () -> Unit,
42) {
44) {
43 ModalBottomSheet(onDismissRequest = onDismiss) {
45 ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -53,6 +55,16 @@ fun AgentPickerSheet(
53 ) {
55 ) {
54 CircularProgressIndicator()
56 CircularProgressIndicator()
55 }
57 }
58+ } else if (error && agents.isEmpty()) {
59+ Text(
60+ stringResource(R.string.load_failed),
61+ style = MaterialTheme.typography.bodyMedium,
62+ color = MaterialTheme.colorScheme.error,
63+ modifier = Modifier
64+ .fillMaxWidth()
65+ .clickable { onRetry() }
66+ .padding(horizontal = 24.dp, vertical = 16.dp),
67+ )
56 } else if (agents.isEmpty()) {
68 } else if (agents.isEmpty()) {
57 Text(
69 Text(
58 stringResource(R.string.no_agents),
70 stringResource(R.string.no_agents),
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+16−2
@@ -97,21 +97,24 @@ fun ChatScreen(
97 sessionId: String,
97 sessionId: String,
98 onBack: () -> Unit,
98 onBack: () -> Unit,
99) {
99) {
100− val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
100+ val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
101 val messages by vm.messages.collectAsStateWithLifecycle()
101 val messages by vm.messages.collectAsStateWithLifecycle()
102 val running by vm.running.collectAsStateWithLifecycle()
102 val running by vm.running.collectAsStateWithLifecycle()
103 val error by vm.error.collectAsStateWithLifecycle()
103 val error by vm.error.collectAsStateWithLifecycle()
104 val loading by vm.loading.collectAsStateWithLifecycle()
104 val loading by vm.loading.collectAsStateWithLifecycle()
105 val models by vm.models.collectAsStateWithLifecycle()
105 val models by vm.models.collectAsStateWithLifecycle()
106 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
106 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
107+ val modelsError by vm.modelsError.collectAsStateWithLifecycle()
107 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
108 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
108 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
109 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
109 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
110 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
110 val agents by vm.agents.collectAsStateWithLifecycle()
111 val agents by vm.agents.collectAsStateWithLifecycle()
111 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
112 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
113+ val agentsError by vm.agentsError.collectAsStateWithLifecycle()
112 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
114 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
113 val commands by vm.commands.collectAsStateWithLifecycle()
115 val commands by vm.commands.collectAsStateWithLifecycle()
114 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
116 val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
117+ val commandsError by vm.commandsError.collectAsStateWithLifecycle()
115 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
118 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
116 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
119 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
117 val draft by vm.draft.collectAsStateWithLifecycle()
120 val draft by vm.draft.collectAsStateWithLifecycle()
@@ -291,7 +294,12 @@ fun ChatScreen(
291 modifier = Modifier.align(Alignment.TopCenter),
294 modifier = Modifier.align(Alignment.TopCenter),
292 )
295 )
293 if (loading && messages.isEmpty()) {
296 if (loading && messages.isEmpty()) {
297+ val loadingLabel = stringResource(R.string.loading)
294− CircularProgressIndicator(Modifier.align(Alignment.Center))
298+ CircularProgressIndicator(
299+ Modifier
300+ .align(Alignment.Center)
301+ .semantics { contentDescription = loadingLabel },
302+ )
295 } else if (messages.isEmpty() && !running) {
303 } else if (messages.isEmpty() && !running) {
296 EmptyConversation(
304 EmptyConversation(
297 modifier = Modifier.align(Alignment.Center),
305 modifier = Modifier.align(Alignment.Center),
@@ -365,7 +373,9 @@ fun ChatScreen(
365 options = models,
373 options = models,
366 selected = selectedModel,
374 selected = selectedModel,
367 loading = modelsLoading,
375 loading = modelsLoading,
376+ error = modelsError,
368 onSelect = { vm.selectModel(it) },
377 onSelect = { vm.selectModel(it) },
378+ onRetry = { vm.reloadModels() },
369 onDismiss = { showModelPicker = false },
379 onDismiss = { showModelPicker = false },
370 )
380 )
371 }
381 }
@@ -375,7 +385,9 @@ fun ChatScreen(
375 agents = agents,
385 agents = agents,
376 selected = selectedAgent,
386 selected = selectedAgent,
377 loading = agentsLoading,
387 loading = agentsLoading,
388+ error = agentsError,
378 onSelect = { vm.selectAgent(it?.name) },
389 onSelect = { vm.selectAgent(it?.name) },
390+ onRetry = { vm.reloadAgents() },
379 onDismiss = { showAgentPicker = false },
391 onDismiss = { showAgentPicker = false },
380 )
392 )
381 }
393 }
@@ -384,7 +396,9 @@ fun ChatScreen(
384 CommandPickerSheet(
396 CommandPickerSheet(
385 commands = commands,
397 commands = commands,
386 loading = commandsLoading,
398 loading = commandsLoading,
399+ error = commandsError,
387 onSelect = { vm.runCommand(it) },
400 onSelect = { vm.runCommand(it) },
401+ onRetry = { vm.reloadCommands() },
388 onDismiss = { showCommandPicker = false },
402 onDismiss = { showCommandPicker = false },
389 )
403 )
390 }
404 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+75−10
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
26import kotlinx.coroutines.flow.SharingStarted
26import kotlinx.coroutines.flow.SharingStarted
27import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.asStateFlow
29+import kotlinx.coroutines.flow.catch
29import kotlinx.coroutines.flow.collectLatest
30import kotlinx.coroutines.flow.collectLatest
30import kotlinx.coroutines.flow.debounce
31import kotlinx.coroutines.flow.debounce
31import kotlinx.coroutines.flow.drop
32import kotlinx.coroutines.flow.drop
@@ -55,6 +56,10 @@ class ChatViewModel(
55 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
56 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
56 }
57 }
57 .onEach { _loading.value = false }
58 .onEach { _loading.value = false }
59+ .catch {
60+ _loading.value = false
61+ _error.value = container.friendlyError(it)
62+ }
58 .stateIn(
63 .stateIn(
59 scope = viewModelScope,
64 scope = viewModelScope,
60 started = SharingStarted.WhileSubscribed(5_000),
65 started = SharingStarted.WhileSubscribed(5_000),
@@ -73,6 +78,9 @@ class ChatViewModel(
73 private val _modelsLoading = MutableStateFlow(true)
78 private val _modelsLoading = MutableStateFlow(true)
74 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
79 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
75
80
81+ private val _modelsError = MutableStateFlow(false)
82+ val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
83+
76 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
84 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
77 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
85 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
78
86
@@ -94,6 +102,9 @@ class ChatViewModel(
94 private val _agentsLoading = MutableStateFlow(true)
102 private val _agentsLoading = MutableStateFlow(true)
95 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
103 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
96
104
105+ private val _agentsError = MutableStateFlow(false)
106+ val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
107+
97 private val _selectedAgent = MutableStateFlow<String?>(null)
108 private val _selectedAgent = MutableStateFlow<String?>(null)
98 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
109 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
99
110
@@ -103,6 +114,9 @@ class ChatViewModel(
103 private val _commandsLoading = MutableStateFlow(true)
114 private val _commandsLoading = MutableStateFlow(true)
104 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
115 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
105
116
117+ private val _commandsError = MutableStateFlow(false)
118+ val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
119+
106 private val _sessionTitle = MutableStateFlow<String?>(null)
120 private val _sessionTitle = MutableStateFlow<String?>(null)
107 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
121 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
108
122
@@ -133,6 +147,49 @@ class ChatViewModel(
133
147
134 fun selectAgent(name: String?) { _selectedAgent.value = name }
148 fun selectAgent(name: String?) { _selectedAgent.value = name }
135
149
150+ fun reloadModels() {
151+ val conn = connection ?: return
152+ viewModelScope.launch {
153+ conn.api.invalidateCache()
154+ _modelsLoading.value = true
155+ _modelsError.value = false
156+ runCatchingCancellable { conn.api.providers() }
157+ .onFailure { Log.w("ChatViewModel", "Failed to reload model catalog", it); _modelsError.value = true }
158+ .getOrNull()?.let { resp ->
159+ val options = resp.toOptions()
160+ _models.value = options
161+ _selectedModel.value = resp.defaultOption(options)
162+ }
163+ _modelsLoading.value = false
164+ }
165+ }
166+
167+ fun reloadAgents() {
168+ val conn = connection ?: return
169+ viewModelScope.launch {
170+ conn.api.invalidateCache()
171+ _agentsLoading.value = true
172+ _agentsError.value = false
173+ runCatchingCancellable { conn.api.agents() }
174+ .onFailure { Log.w("ChatViewModel", "Failed to reload agent catalog", it); _agentsError.value = true }
175+ .getOrNull()?.let { _agents.value = it }
176+ _agentsLoading.value = false
177+ }
178+ }
179+
180+ fun reloadCommands() {
181+ val conn = connection ?: return
182+ viewModelScope.launch {
183+ conn.api.invalidateCache()
184+ _commandsLoading.value = true
185+ _commandsError.value = false
186+ runCatchingCancellable { conn.api.commands() }
187+ .onFailure { Log.w("ChatViewModel", "Failed to reload command catalog", it); _commandsError.value = true }
188+ .getOrNull()?.let { _commands.value = it }
189+ _commandsLoading.value = false
190+ }
191+ }
192+
136 fun updateDraft(text: String) {
193 fun updateDraft(text: String) {
137 _draft.value = text
194 _draft.value = text
138 }
195 }
@@ -181,10 +238,11 @@ class ChatViewModel(
181 // sending with no model just uses the server's default agent/model.
238 // sending with no model just uses the server's default agent/model.
182 viewModelScope.launch {
239 viewModelScope.launch {
183 container.activeConnection.collectLatest { conn ->
240 container.activeConnection.collectLatest { conn ->
184− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
241+ if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; _modelsError.value = false; return@collectLatest }
185 _modelsLoading.value = true
242 _modelsLoading.value = true
243+ _modelsError.value = false
186 runCatchingCancellable { conn.api.providers() }
244 runCatchingCancellable { conn.api.providers() }
187− .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
245+ .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it); _modelsError.value = true }
188 .getOrNull()?.let { resp ->
246 .getOrNull()?.let { resp ->
189 val options = resp.toOptions()
247 val options = resp.toOptions()
190 _models.value = options
248 _models.value = options
@@ -196,10 +254,11 @@ class ChatViewModel(
196 // Load the agent catalog (non-fatal).
254 // Load the agent catalog (non-fatal).
197 viewModelScope.launch {
255 viewModelScope.launch {
198 container.activeConnection.collectLatest { conn ->
256 container.activeConnection.collectLatest { conn ->
199− if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
257+ if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; _agentsError.value = false; return@collectLatest }
200 _agentsLoading.value = true
258 _agentsLoading.value = true
259+ _agentsError.value = false
201 runCatchingCancellable { conn.api.agents() }
260 runCatchingCancellable { conn.api.agents() }
202− .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
261+ .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it); _agentsError.value = true }
203 .getOrNull()?.let { _agents.value = it }
262 .getOrNull()?.let { _agents.value = it }
204 _agentsLoading.value = false
263 _agentsLoading.value = false
205 }
264 }
@@ -207,10 +266,11 @@ class ChatViewModel(
207 // Load the command catalog (non-fatal).
266 // Load the command catalog (non-fatal).
208 viewModelScope.launch {
267 viewModelScope.launch {
209 container.activeConnection.collectLatest { conn ->
268 container.activeConnection.collectLatest { conn ->
210− if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
269+ if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; _commandsError.value = false; return@collectLatest }
211 _commandsLoading.value = true
270 _commandsLoading.value = true
271+ _commandsError.value = false
212 runCatchingCancellable { conn.api.commands() }
272 runCatchingCancellable { conn.api.commands() }
213− .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
273+ .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it); _commandsError.value = true }
214 .getOrNull()?.let { _commands.value = it }
274 .getOrNull()?.let { _commands.value = it }
215 _commandsLoading.value = false
275 _commandsLoading.value = false
216 }
276 }
@@ -264,11 +324,14 @@ class ChatViewModel(
264 // Only restore the draft if the user hasn't typed anything new since.
324 // Only restore the draft if the user hasn't typed anything new since.
265 if (_draft.value.isBlank()) updateDraft(trimmed)
325 if (_draft.value.isBlank()) updateDraft(trimmed)
266 _error.value = container.friendlyError(it)
326 _error.value = container.friendlyError(it)
327+ _running.value = false
267 }.onSuccess {
328 }.onSuccess {
268 // Send succeeded — now it's safe to persist the empty draft.
329 // Send succeeded — now it's safe to persist the empty draft.
269 container.draftStore.set(sessionId, "")
330 container.draftStore.set(sessionId, "")
331+ // Don't reset _running here: the agent continues streaming via SSE.
332+ // _running is cleared on SessionIdle/SessionError (see event collector)
333+ // or when the SSE stream drops (see connection state watcher below).
270 }
334 }
271− _running.value = false
272 }
335 }
273 return true
336 return true
274 }
337 }
@@ -288,8 +351,10 @@ class ChatViewModel(
288 viewModelScope.launch {
351 viewModelScope.launch {
289 runCatchingCancellable {
352 runCatchingCancellable {
290 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
353 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
354+ }.onFailure {
291− }.onFailure { _error.value = container.friendlyError(it) }
355+ _error.value = container.friendlyError(it)
292− _running.value = false
356+ _running.value = false
357+ }
293 }
358 }
294 }
359 }
295
360
@@ -307,7 +372,7 @@ class ChatViewModel(
307 if (container.activeConnection.value != null) return
372 if (container.activeConnection.value != null) return
308 viewModelScope.launch {
373 viewModelScope.launch {
309 _loading.value = true
374 _loading.value = true
310− val recent = runCatching { container.profileStore.profiles.first() }
375+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
311 .getOrDefault(emptyList())
376 .getOrDefault(emptyList())
312 .firstOrNull() ?: run { _loading.value = false; return@launch }
377 .firstOrNull() ?: run { _loading.value = false; return@launch }
313 runCatchingCancellable {
378 runCatchingCancellable {
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12
@@ -33,7 +33,9 @@ import soy.iko.opencode.R
33fun CommandPickerSheet(
33fun CommandPickerSheet(
34 commands: List<Command>,
34 commands: List<Command>,
35 loading: Boolean,
35 loading: Boolean,
36+ error: Boolean,
36 onSelect: (Command) -> Unit,
37 onSelect: (Command) -> Unit,
38+ onRetry: () -> Unit,
37 onDismiss: () -> Unit,
39 onDismiss: () -> Unit,
38) {
40) {
39 ModalBottomSheet(onDismissRequest = onDismiss) {
41 ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -49,6 +51,16 @@ fun CommandPickerSheet(
49 ) {
51 ) {
50 CircularProgressIndicator()
52 CircularProgressIndicator()
51 }
53 }
54+ } else if (error && commands.isEmpty()) {
55+ Text(
56+ stringResource(R.string.load_failed),
57+ style = MaterialTheme.typography.bodyMedium,
58+ color = MaterialTheme.colorScheme.error,
59+ modifier = Modifier
60+ .fillMaxWidth()
61+ .clickable { onRetry() }
62+ .padding(horizontal = 24.dp, vertical = 16.dp),
63+ )
52 } else if (commands.isEmpty()) {
64 } else if (commands.isEmpty()) {
53 Text(
65 Text(
54 stringResource(R.string.no_commands),
66 stringResource(R.string.no_commands),
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+12
@@ -37,7 +37,9 @@ fun ModelPickerSheet(
37 options: List<ModelOption>,
37 options: List<ModelOption>,
38 selected: ModelOption?,
38 selected: ModelOption?,
39 loading: Boolean,
39 loading: Boolean,
40+ error: Boolean,
40 onSelect: (ModelOption) -> Unit,
41 onSelect: (ModelOption) -> Unit,
42+ onRetry: () -> Unit,
41 onDismiss: () -> Unit,
43 onDismiss: () -> Unit,
42) {
44) {
43 ModalBottomSheet(onDismissRequest = onDismiss) {
45 ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -53,6 +55,16 @@ fun ModelPickerSheet(
53 ) {
55 ) {
54 CircularProgressIndicator()
56 CircularProgressIndicator()
55 }
57 }
58+ } else if (error && options.isEmpty()) {
59+ Text(
60+ stringResource(R.string.load_failed),
61+ style = MaterialTheme.typography.bodyMedium,
62+ color = MaterialTheme.colorScheme.error,
63+ modifier = Modifier
64+ .fillMaxWidth()
65+ .clickable { onRetry() }
66+ .padding(horizontal = 24.dp, vertical = 16.dp),
67+ )
56 } else if (options.isEmpty()) {
68 } else if (options.isEmpty()) {
57 Text(
69 Text(
58 stringResource(R.string.no_models),
70 stringResource(R.string.no_models),
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−31
@@ -147,7 +147,13 @@ fun SessionListScreen(
147 )
147 )
148 }
148 }
149 if (switchingId == profile.id) {
149 if (switchingId == profile.id) {
150+ val switchingLabel = stringResource(R.string.loading)
150− CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
151+ CircularProgressIndicator(
152+ Modifier
153+ .size(18.dp)
154+ .semantics { contentDescription = switchingLabel },
155+ strokeWidth = 2.dp,
156+ )
151 }
157 }
152 }
158 }
153 },
159 },
@@ -192,7 +198,14 @@ fun SessionListScreen(
192 modifier = Modifier.align(Alignment.TopCenter),
198 modifier = Modifier.align(Alignment.TopCenter),
193 )
199 )
194 when {
200 when {
201+ state.loading -> {
202+ val loadingLabel = stringResource(R.string.loading)
195− state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
203+ CircularProgressIndicator(
204+ Modifier
205+ .align(Alignment.Center)
206+ .semantics { contentDescription = loadingLabel },
207+ )
208+ }
196 state.sessions.isEmpty() && state.error != null -> Text(
209 state.sessions.isEmpty() && state.error != null -> Text(
197 state.error ?: "",
210 state.error ?: "",
198 modifier = Modifier.align(Alignment.Center).padding(24.dp),
211 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -339,41 +352,50 @@ private fun SessionCard(
339 modifier = modifier,
352 modifier = modifier,
340 ) {
353 ) {
341 Card(
354 Card(
342− modifier = Modifier
355+ modifier = Modifier.fillMaxWidth(),
343− .fillMaxWidth()
344− .clickable(role = Role.Button) { onClick() },
345 ) {
356 ) {
346 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
357 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
347 Row(
358 Row(
348 modifier = Modifier.fillMaxWidth(),
359 modifier = Modifier.fillMaxWidth(),
349 verticalAlignment = Alignment.CenterVertically,
360 verticalAlignment = Alignment.CenterVertically,
350 ) {
361 ) {
362+ // Title area is the primary click target; the action buttons below
363+ // remain separate accessibility nodes so screen readers can activate
364+ // them independently.
365+ Column(
366+ modifier = Modifier
367+ .weight(1f)
368+ .clickable(role = Role.Button) { onClick() },
369+ ) {
370+ Row(verticalAlignment = Alignment.CenterVertically) {
351− if (unread) {
371+ if (unread) {
352− val unreadLabel = stringResource(R.string.unread)
372+ val unreadLabel = stringResource(R.string.unread)
353− Box(
373+ Box(
354− modifier = Modifier
374+ modifier = Modifier
355− .padding(end = 8.dp)
375+ .padding(end = 8.dp)
356− .size(8.dp)
376+ .size(8.dp)
357− .clip(androidx.compose.foundation.shape.CircleShape)
377+ .clip(androidx.compose.foundation.shape.CircleShape)
358− .background(MaterialTheme.colorScheme.primary)
378+ .background(MaterialTheme.colorScheme.primary)
359− .semantics { contentDescription = unreadLabel },
379+ .semantics { contentDescription = unreadLabel },
360− )
380+ )
361− }
381+ }
362− Text(
382+ Text(
363− session.displayTitle,
383+ session.displayTitle,
364− style = MaterialTheme.typography.titleMedium,
384+ style = MaterialTheme.typography.titleMedium,
365− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
385+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
366− maxLines = 1,
386+ maxLines = 1,
367− overflow = TextOverflow.Ellipsis,
387+ overflow = TextOverflow.Ellipsis,
368− modifier = Modifier.weight(1f),
388+ )
369− )
389+ }
370− val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
390+ val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
371− if (time.isNotEmpty()) {
391+ if (time.isNotEmpty()) {
372− Text(
392+ Text(
373− time,
393+ time,
374− style = MaterialTheme.typography.labelSmall,
394+ style = MaterialTheme.typography.labelSmall,
375− color = MaterialTheme.colorScheme.onSurfaceVariant,
395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
376− )
396+ modifier = Modifier.padding(top = 2.dp),
397+ )
398+ }
377 }
399 }
378 IconButton(onClick = onRename) {
400 IconButton(onClick = onRename) {
379 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
401 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
@@ -390,6 +412,7 @@ private fun SessionCard(
390 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
412 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
391 maxLines = 2,
413 maxLines = 2,
392 overflow = TextOverflow.Ellipsis,
414 overflow = TextOverflow.Ellipsis,
415+ modifier = Modifier.clickable { onClick() },
393 )
416 )
394 }
417 }
395 }
418 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−2
@@ -280,6 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
280 fun switchServer(profile: ServerProfile) {
280 fun switchServer(profile: ServerProfile) {
281 if (profile.id == activeProfileId) return
281 if (profile.id == activeProfileId) return
282 if (_switchingId.value != null) return
282 if (_switchingId.value != null) return
283+ val previousProfile = container.activeConnection.value?.profile
283 _switchingId.value = profile.id
284 _switchingId.value = profile.id
284 _state.update { it.copy(loading = true, error = null) }
285 _state.update { it.copy(loading = true, error = null) }
285 viewModelScope.launch {
286 viewModelScope.launch {
@@ -289,9 +290,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
289 }
290 }
290 _switchingId.value = null
291 _switchingId.value = null
291 result.onSuccess { refresh() }
292 result.onSuccess { refresh() }
292− .onFailure {
293+ .onFailure { error ->
294+ // connect() already closed the old connection; if we just disconnect()
295+ // the user is left with nothing. Try to restore the previous profile so
296+ // the session list survives. If there was none, show the error state.
293 container.disconnect()
297 container.disconnect()
298+ if (previousProfile != null) {
299+ runCatchingCancellable { container.connect(previousProfile) }
300+ _state.update { it.copy(loading = false, error = container.friendlyError(error)) }
301+ } else {
294− _state.value = SessionListState(loading = false, error = container.friendlyError(it))
302+ _state.value = SessionListState(loading = false, error = container.friendlyError(error))
303+ }
295 }
304 }
296 }
305 }
297 }
306 }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+5−8
@@ -16,7 +16,6 @@ import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.LaunchedEffect
17import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
18import androidx.compose.runtime.getValue
19−import androidx.compose.runtime.key
20import androidx.compose.runtime.mutableStateOf
19import androidx.compose.runtime.mutableStateOf
21import androidx.compose.runtime.saveable.rememberSaveable
20import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.setValue
21import androidx.compose.runtime.setValue
@@ -93,13 +92,11 @@ fun TwoPaneSessionChat(
93 EmptyDetail()
92 EmptyDetail()
94 } else {
93 } else {
95 BackHandler { selected = null }
94 BackHandler { selected = null }
96− key(sessionId) {
97− ChatScreen(
95+ ChatScreen(
98− container = container,
96+ container = container,
99− sessionId = sessionId,
97+ sessionId = sessionId,
100− onBack = { selected = null },
98+ onBack = { selected = null },
101− )
99+ )
102− }
103 }
100 }
104 }
101 }
105 }
102 }
app/src/main/res/values/strings.xml+2
@@ -22,6 +22,7 @@
22 <string name="stop">Stop</string>
22 <string name="stop">Stop</string>
23 <string name="rename">Rename</string>
23 <string name="rename">Rename</string>
24 <string name="disconnect">Disconnect</string>
24 <string name="disconnect">Disconnect</string>
25+ <string name="loading">Loading…</string>
25
26
26 <!-- Theme modes -->
27 <!-- Theme modes -->
27 <string name="theme_system">System</string>
28 <string name="theme_system">System</string>
@@ -120,6 +121,7 @@
120 <string name="default_agent">Default</string>
121 <string name="default_agent">Default</string>
121 <string name="default_agent_desc">Server default agent</string>
122 <string name="default_agent_desc">Server default agent</string>
122 <string name="no_commands">No commands reported by the server.</string>
123 <string name="no_commands">No commands reported by the server.</string>
124+ <string name="load_failed">Failed to load. Tap retry.</string>
123 <string name="saving">Saving…</string>
125 <string name="saving">Saving…</string>
124 <string name="image_failed">Failed to load image</string>
126 <string name="image_failed">Failed to load image</string>
125
127
config/detekt/detekt.yml+3
@@ -24,6 +24,9 @@ complexity:
24 TooManyFunctions:
24 TooManyFunctions:
25 active: true
25 active: true
26 ignoreOverridden: true
26 ignoreOverridden: true
27+ thresholdInFiles: 16
28+ thresholdInClasses: 16
29+ thresholdInObjects: 16
27
30
28style:
31style:
29 active: true
32 active: true