git.iko.soy/opencode-android
on master help sign in
Fix unsafe casts, cleartext credential exposure, SSE reconnect deadlock, main-thread disk I/O, preview job race, and add accessibility loading/expand states
commit2ec6140167eedc671dabeeba8282771c2e9ccb9d authoriko <mail@iko.soy> dateTue 30 Jun 2026, 04:45 (3 months ago) parent3e723a1
19 files changed+208−47
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−4 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+10−4 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+15−3 app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+6 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−3 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12−1 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+12−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+30−5 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+30−2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+5−3 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+14−3 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+4−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+13−9 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+9−1 app/src/main/res/values/strings.xml+10 app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt+3−3
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+3−4
@@ -139,11 +139,10 @@ class EventStreamClient(
139139 throw c
140140 } catch (e: Exception) {
141141 if (isSseAuthFailure(e)) {
142− Log.w("EventStream", "SSE auth failed, stopping reconnect", e)
143− _state.value = ConnectionState.Disconnected
144− throw e
142+ Log.w("EventStream", "SSE auth failed, will retry with backoff", e)
143+ } else {
144+ Log.w("EventStream", "SSE stream error, will retry", e)
145145 }
146− Log.w("EventStream", "SSE stream error, will retry", e)
147146 }
148147 _state.value = ConnectionState.Disconnected
149148 if (!isActive) break
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+10−4
@@ -60,8 +60,12 @@ object HttpClientFactory {
6060 password = profile.password.orEmpty(),
6161 )
6262 }
63− // Send eagerly so opencode doesn't need a 401 challenge round-trip.
64− sendWithoutRequest { true }
63+ // Send eagerly so opencode doesn't need a 401 challenge round-trip,
64+ // but only over HTTPS — sending credentials over cleartext HTTP
65+ // would expose them on the network.
66+ sendWithoutRequest { request ->
67+ request.url.protocol.name == "https"
68+ }
6569 }
6670 }
6771 }
@@ -71,13 +75,15 @@ object HttpClientFactory {
7175 }
7276 }
7377
74− /** Ensure a scheme and a single trailing slash so relative paths resolve correctly. */
78+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
79+ * Defaults to [https] when no scheme is given so credentials aren't accidentally
80+ * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
7581 fun normalizeBaseUrl(raw: String): String {
7682 val trimmed = raw.trim()
7783 val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
7884 trimmed
7985 } else {
80− "http://$trimmed"
86+ "https://$trimmed"
8187 }
8288 return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
8389 }
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+15−3
@@ -1,6 +1,8 @@
11package soy.iko.opencode.data.repo
22
33import android.content.Context
4+import java.util.concurrent.CountDownLatch
5+import java.util.concurrent.TimeUnit
46import kotlinx.coroutines.CoroutineScope
57import kotlinx.coroutines.launch
68
@@ -15,19 +17,28 @@ import kotlinx.coroutines.launch
1517class DraftStore(context: Context, scope: CoroutineScope) {
1618
1719 private val appContext = context.applicationContext
20+ private val prefsReady = CountDownLatch(1)
1821 private val prefs by lazy {
1922 appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
2023 }
2124
2225 init {
2326 // Eagerly load the prefs file on a background thread so the first main-thread
24− // get() doesn't block on disk I/O. The lazy delegate ensures the same instance.
25− scope.launch { prefs.all }
27+ // get() doesn't block on disk I/O. The latch ensures get() waits for this
28+ // background load instead of triggering its own lazy load on the main thread.
29+ scope.launch {
30+ prefs.all
31+ prefsReady.countDown()
32+ }
2633 }
2734
28− fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
35+ fun get(sessionId: String): String {
36+ prefsReady.await(2, TimeUnit.SECONDS)
37+ return prefs.getString(sessionId, "").orEmpty()
38+ }
2939
3040 fun set(sessionId: String, text: String) {
41+ prefsReady.await(2, TimeUnit.SECONDS)
3142 prefs.edit().apply {
3243 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
3344 }.apply()
@@ -35,6 +46,7 @@ class DraftStore(context: Context, scope: CoroutineScope) {
3546
3647 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
3748 fun remove(sessionId: String) {
49+ prefsReady.await(2, TimeUnit.SECONDS)
3850 prefs.edit().remove(sessionId).apply()
3951 }
4052}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+1−1
@@ -20,7 +20,7 @@ object NotificationChannels {
2020
2121 fun create(context: Context) {
2222 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
23− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
23+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
2424 nm.createNotificationChannel(
2525 NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
2626 description = context.getString(R.string.notif_channel_status_desc)
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12−1
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
1212import androidx.compose.material.icons.Icons
1313import androidx.compose.material.icons.filled.Check
14+import androidx.compose.material3.CircularProgressIndicator
1415import androidx.compose.material3.ExperimentalMaterial3Api
1516import androidx.compose.material3.Icon
1617import androidx.compose.material3.MaterialTheme
@@ -25,6 +26,8 @@ import androidx.compose.ui.semantics.role
2526import androidx.compose.ui.semantics.selected
2627import androidx.compose.ui.semantics.semantics
2728import androidx.compose.ui.unit.dp
29+import androidx.compose.foundation.layout.Box
30+import androidx.compose.foundation.layout.fillMaxWidth
2831import soy.iko.opencode.data.model.Agent
2932import soy.iko.opencode.R
3033
@@ -34,6 +37,7 @@ import soy.iko.opencode.R
3437fun AgentPickerSheet(
3538 agents: List<Agent>,
3639 selected: String?,
40+ loading: Boolean,
3741 onSelect: (Agent?) -> Unit,
3842 onDismiss: () -> Unit,
3943) {
@@ -43,7 +47,14 @@ fun AgentPickerSheet(
4347 style = MaterialTheme.typography.titleMedium,
4448 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
4549 )
46− if (agents.isEmpty()) {
50+ if (loading) {
51+ Box(
52+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
53+ contentAlignment = Alignment.Center,
54+ ) {
55+ CircularProgressIndicator()
56+ }
57+ } else if (agents.isEmpty()) {
4758 Text(
4859 stringResource(R.string.no_agents),
4960 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+6
@@ -101,12 +101,15 @@ fun ChatScreen(
101101 val error by vm.error.collectAsStateWithLifecycle()
102102 val loading by vm.loading.collectAsStateWithLifecycle()
103103 val models by vm.models.collectAsStateWithLifecycle()
104+ val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
104105 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
105106 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
106107 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
107108 val agents by vm.agents.collectAsStateWithLifecycle()
109+ val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
108110 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
109111 val commands by vm.commands.collectAsStateWithLifecycle()
112+ val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
110113 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
111114 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
112115 val draft by vm.draft.collectAsStateWithLifecycle()
@@ -353,6 +356,7 @@ fun ChatScreen(
353356 ModelPickerSheet(
354357 options = models,
355358 selected = selectedModel,
359+ loading = modelsLoading,
356360 onSelect = { vm.selectModel(it) },
357361 onDismiss = { showModelPicker = false },
358362 )
@@ -362,6 +366,7 @@ fun ChatScreen(
362366 AgentPickerSheet(
363367 agents = agents,
364368 selected = selectedAgent,
369+ loading = agentsLoading,
365370 onSelect = { vm.selectAgent(it?.name) },
366371 onDismiss = { showAgentPicker = false },
367372 )
@@ -370,6 +375,7 @@ fun ChatScreen(
370375 if (showCommandPicker) {
371376 CommandPickerSheet(
372377 commands = commands,
378+ loading = commandsLoading,
373379 onSelect = { vm.runCommand(it) },
374380 onDismiss = { showCommandPicker = false },
375381 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−3
@@ -67,6 +67,9 @@ class ChatViewModel(
6767 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
6868 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
6969
70+ private val _modelsLoading = MutableStateFlow(true)
71+ val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
72+
7073 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
7174 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
7275
@@ -85,12 +88,18 @@ class ChatViewModel(
8588 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
8689 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
8790
91+ private val _agentsLoading = MutableStateFlow(true)
92+ val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
93+
8894 private val _selectedAgent = MutableStateFlow<String?>(null)
8995 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
9096
9197 private val _commands = MutableStateFlow<List<Command>>(emptyList())
9298 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
9399
100+ private val _commandsLoading = MutableStateFlow(true)
101+ val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
102+
94103 private val _sessionTitle = MutableStateFlow<String?>(null)
95104 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
96105
@@ -144,7 +153,8 @@ class ChatViewModel(
144153 // sending with no model just uses the server's default agent/model.
145154 viewModelScope.launch {
146155 container.activeConnection.collectLatest { conn ->
147− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; return@collectLatest }
156+ if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
157+ _modelsLoading.value = true
148158 runCatching { conn.api.providers() }
149159 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150160 .getOrNull()?.let { resp ->
@@ -152,24 +162,29 @@ class ChatViewModel(
152162 _models.value = options
153163 _selectedModel.value = resp.defaultOption(options)
154164 }
165+ _modelsLoading.value = false
155166 }
156167 }
157168 // Load the agent catalog (non-fatal).
158169 viewModelScope.launch {
159170 container.activeConnection.collectLatest { conn ->
160− if (conn == null) { _agents.value = emptyList(); return@collectLatest }
171+ if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
172+ _agentsLoading.value = true
161173 runCatching { conn.api.agents() }
162174 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163175 .getOrNull()?.let { _agents.value = it }
176+ _agentsLoading.value = false
164177 }
165178 }
166179 // Load the command catalog (non-fatal).
167180 viewModelScope.launch {
168181 container.activeConnection.collectLatest { conn ->
169− if (conn == null) { _commands.value = emptyList(); return@collectLatest }
182+ if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
183+ _commandsLoading.value = true
170184 runCatching { conn.api.commands() }
171185 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172186 .getOrNull()?.let { _commands.value = it }
187+ _commandsLoading.value = false
173188 }
174189 }
175190 // Resolve the human-readable session title for the app bar (non-fatal).
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12−1
@@ -9,18 +9,21 @@ import androidx.compose.foundation.lazy.LazyColumn
99import androidx.compose.foundation.lazy.items
1010import androidx.compose.material.icons.Icons
1111import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
12+import androidx.compose.material3.CircularProgressIndicator
1213import androidx.compose.material3.ExperimentalMaterial3Api
1314import androidx.compose.material3.Icon
1415import androidx.compose.material3.MaterialTheme
1516import androidx.compose.material3.ModalBottomSheet
1617import androidx.compose.material3.Text
1718import androidx.compose.runtime.Composable
19+import androidx.compose.ui.Alignment
1820import androidx.compose.ui.Modifier
1921import androidx.compose.ui.res.stringResource
2022import androidx.compose.ui.semantics.Role
2123import androidx.compose.ui.semantics.role
2224import androidx.compose.ui.text.style.TextOverflow
2325import androidx.compose.ui.unit.dp
26+import androidx.compose.foundation.layout.Box
2427import soy.iko.opencode.data.model.Command
2528import soy.iko.opencode.R
2629
@@ -29,6 +32,7 @@ import soy.iko.opencode.R
2932@Composable
3033fun CommandPickerSheet(
3134 commands: List<Command>,
35+ loading: Boolean,
3236 onSelect: (Command) -> Unit,
3337 onDismiss: () -> Unit,
3438) {
@@ -38,7 +42,14 @@ fun CommandPickerSheet(
3842 style = MaterialTheme.typography.titleMedium,
3943 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
4044 )
41− if (commands.isEmpty()) {
45+ if (loading) {
46+ Box(
47+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
48+ contentAlignment = Alignment.Center,
49+ ) {
50+ CircularProgressIndicator()
51+ }
52+ } else if (commands.isEmpty()) {
4253 Text(
4354 stringResource(R.string.no_commands),
4455 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+12−1
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
1212import androidx.compose.material.icons.Icons
1313import androidx.compose.material.icons.filled.Check
14+import androidx.compose.material3.CircularProgressIndicator
1415import androidx.compose.material3.ExperimentalMaterial3Api
1516import androidx.compose.material3.Icon
1617import androidx.compose.material3.MaterialTheme
@@ -25,6 +26,8 @@ import androidx.compose.ui.semantics.role
2526import androidx.compose.ui.semantics.selected
2627import androidx.compose.ui.semantics.semantics
2728import androidx.compose.ui.unit.dp
29+import androidx.compose.foundation.layout.Box
30+import androidx.compose.foundation.layout.fillMaxWidth
2831import soy.iko.opencode.data.model.ModelOption
2932import soy.iko.opencode.R
3033
@@ -34,6 +37,7 @@ import soy.iko.opencode.R
3437fun ModelPickerSheet(
3538 options: List<ModelOption>,
3639 selected: ModelOption?,
40+ loading: Boolean,
3741 onSelect: (ModelOption) -> Unit,
3842 onDismiss: () -> Unit,
3943) {
@@ -43,7 +47,14 @@ fun ModelPickerSheet(
4347 style = MaterialTheme.typography.titleMedium,
4448 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
4549 )
46− if (options.isEmpty()) {
50+ if (loading) {
51+ Box(
52+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
53+ contentAlignment = Alignment.Center,
54+ ) {
55+ CircularProgressIndicator()
56+ }
57+ } else if (options.isEmpty()) {
4758 Text(
4859 stringResource(R.string.no_models),
4960 style = MaterialTheme.typography.bodyMedium,
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+30−5
@@ -34,8 +34,10 @@ import androidx.compose.ui.Modifier
3434import androidx.compose.ui.draw.clip
3535import androidx.compose.ui.platform.LocalContext
3636import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.contentDescription
3738import androidx.compose.ui.semantics.role
3839import androidx.compose.ui.semantics.semantics
40+import androidx.compose.ui.semantics.stateDescription
3941import androidx.compose.ui.text.font.FontFamily
4042import androidx.compose.ui.res.stringResource
4143import androidx.compose.ui.unit.dp
@@ -92,12 +94,17 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
9294 if (text.isBlank()) return
9395 val context = LocalContext.current
9496 var expanded by remember { mutableStateOf(false) }
97+ val expandedState = stringResource(R.string.state_expanded)
98+ val collapsedState = stringResource(R.string.state_collapsed)
9599 Column(modifier = modifier.fillMaxWidth()) {
96100 Row(
97101 modifier = Modifier
98102 .clickable { expanded = !expanded }
99103 .padding(vertical = 4.dp)
100− .semantics { role = Role.Button },
104+ .semantics {
105+ role = Role.Button
106+ stateDescription = if (expanded) expandedState else collapsedState
107+ },
101108 verticalAlignment = Alignment.CenterVertically,
102109 ) {
103110 if (streaming) {
@@ -167,6 +174,8 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
167174 if (detail != null) {
168175 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
169176 var expanded by remember(detail) { mutableStateOf(false) }
177+ val expandedState = stringResource(R.string.state_expanded)
178+ val collapsedState = stringResource(R.string.state_collapsed)
170179 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
171180 if (looksLikeDiff(display)) {
172181 DiffView(display)
@@ -182,6 +191,9 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
182191 TextButton(
183192 onClick = { expanded = !expanded },
184193 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
194+ modifier = Modifier.semantics {
195+ stateDescription = if (expanded) expandedState else collapsedState
196+ },
185197 ) {
186198 Icon(
187199 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -210,13 +222,26 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
210222private fun ToolStatusIcon(state: ToolState) {
211223 when (state) {
212224 is ToolPending, is ToolRunning ->
213− CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
225+ CircularProgressIndicator(
226+ modifier = Modifier.size(16.dp).semantics { contentDescription = "" },
227+ strokeWidth = 2.dp,
228+ )
214229 is ToolCompleted ->
215− Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
230+ Icon(
231+ Icons.Filled.CheckCircle,
232+ contentDescription = stringResource(R.string.tool_completed),
233+ modifier = Modifier.size(16.dp),
234+ tint = MaterialTheme.colorScheme.primary,
235+ )
216236 is ToolError ->
217− Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
237+ Icon(
238+ Icons.Filled.Error,
239+ contentDescription = stringResource(R.string.tool_error),
240+ modifier = Modifier.size(16.dp),
241+ tint = MaterialTheme.colorScheme.error,
242+ )
218243 is ToolUnknown ->
219− Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
244+ Icon(Icons.Filled.Bolt, contentDescription = null, modifier = Modifier.size(16.dp))
220245 }
221246}
222247
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1
@@ -96,7 +96,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
9696 ) {
9797 IconButton(
9898 onClick = { copyToClipboard(context, "code", code) },
99− modifier = Modifier.size(28.dp),
99+ modifier = Modifier.size(48.dp),
100100 ) {
101101 Icon(
102102 Icons.Filled.ContentCopy,
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+30−2
@@ -1,18 +1,26 @@
11package soy.iko.opencode.ui.components
22
33import android.util.Base64
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxWidth
46import androidx.compose.foundation.layout.heightIn
57import androidx.compose.foundation.shape.RoundedCornerShape
8+import androidx.compose.material3.MaterialTheme
69import androidx.compose.runtime.Composable
710import androidx.compose.runtime.remember
11+import androidx.compose.ui.Alignment
812import androidx.compose.ui.Modifier
913import androidx.compose.ui.draw.clip
1014import androidx.compose.ui.layout.ContentScale
1115import androidx.compose.ui.platform.LocalContext
1216import androidx.compose.ui.res.stringResource
1317import androidx.compose.ui.unit.dp
14−import coil.compose.AsyncImage
18+import coil.compose.SubcomposeAsyncImage
1519import coil.request.ImageRequest
20+import androidx.compose.material3.CircularProgressIndicator
21+import androidx.compose.material3.Icon
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.BrokenImage
1624import soy.iko.opencode.R
1725import soy.iko.opencode.data.model.FilePart
1826import soy.iko.opencode.data.model.ServerProfile
@@ -88,12 +96,32 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
8896 .crossfade(true)
8997 .build()
9098 }
91− AsyncImage(
99+ SubcomposeAsyncImage(
92100 model = request,
93101 contentDescription = part.filename ?: stringResource(R.string.image),
94102 contentScale = ContentScale.FillWidth,
95103 modifier = modifier
96104 .heightIn(max = 320.dp)
97105 .clip(RoundedCornerShape(12.dp)),
106+ loading = {
107+ Box(
108+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
109+ contentAlignment = Alignment.Center,
110+ ) {
111+ CircularProgressIndicator()
112+ }
113+ },
114+ error = {
115+ Box(
116+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
117+ contentAlignment = Alignment.Center,
118+ ) {
119+ Icon(
120+ Icons.Filled.BrokenImage,
121+ contentDescription = stringResource(R.string.image_failed),
122+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
123+ )
124+ }
125+ },
98126 )
99127}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+5−3
@@ -133,8 +133,8 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
133133 .padding(horizontal = 16.dp, vertical = 8.dp),
134134 verticalAlignment = Alignment.CenterVertically,
135135 ) {
136− IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
137− Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(18.dp))
136+ IconButton(onClick = { onNavigate("") }) {
137+ Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
138138 }
139139 var acc = ""
140140 segments.forEachIndexed { index, segment ->
@@ -150,7 +150,9 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
150150 segment,
151151 style = MaterialTheme.typography.bodyMedium,
152152 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
153− modifier = Modifier.clickable(role = Role.Button) { onNavigate(target) }.padding(vertical = 2.dp),
153+ modifier = Modifier
154+ .clickable(role = Role.Button) { onNavigate(target) }
155+ .padding(horizontal = 6.dp, vertical = 14.dp),
154156 )
155157 }
156158 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+14−3
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
77import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.imePadding
99import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
1011import androidx.compose.foundation.layout.widthIn
1112import androidx.compose.foundation.rememberScrollState
1213import androidx.compose.foundation.text.KeyboardActions
@@ -19,6 +20,7 @@ import androidx.compose.material3.CircularProgressIndicator
1920import androidx.compose.material3.ExperimentalMaterial3Api
2021import androidx.compose.material3.Icon
2122import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
2224import androidx.compose.material3.OutlinedTextField
2325import androidx.compose.material3.Scaffold
2426import androidx.compose.material3.Text
@@ -111,15 +113,24 @@ fun ServerEditScreen(
111113 singleLine = true,
112114 visualTransformation = PasswordVisualTransformation(),
113115 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
114− keyboardActions = KeyboardActions(onDone = { if (state.canSave) vm.save(onDone) }),
116+ keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
115117 modifier = Modifier.fillMaxWidth(),
116118 )
117119 Button(
118120 onClick = { vm.save(onDone) },
119− enabled = state.canSave,
121+ enabled = state.canSave && !state.saving,
120122 modifier = Modifier.fillMaxWidth(),
121123 ) {
122− Text(stringResource(R.string.save))
124+ if (state.saving) {
125+ CircularProgressIndicator(
126+ modifier = Modifier.size(20.dp),
127+ color = MaterialTheme.colorScheme.onPrimary,
128+ strokeWidth = 2.dp,
129+ )
130+ Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
131+ } else {
132+ Text(stringResource(R.string.save))
133+ }
123134 }
124135 state.error?.let { message ->
125136 Text(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+4−2
@@ -19,6 +19,7 @@ data class ServerEditState(
1919 val username: String = "",
2020 val password: String = "",
2121 val loaded: Boolean = false,
22+ val saving: Boolean = false,
2223 val error: String? = null,
2324) {
2425 val canSave: Boolean get() = baseUrl.isNotBlank()
@@ -61,7 +62,8 @@ class ServerEditViewModel(
6162
6263 fun save(onDone: () -> Unit) {
6364 val s = _state.value
64− if (!s.canSave) return
65+ if (!s.canSave || s.saving) return
66+ _state.value = s.copy(saving = true)
6567 viewModelScope.launch {
6668 val result = runCatching {
6769 val existingLastUsed = if (s.id != null) {
@@ -82,7 +84,7 @@ class ServerEditViewModel(
8284 )
8385 }
8486 result.onSuccess { onDone() }
85− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save") }
87+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save", saving = false) }
8688 }
8789 }
8890}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+13−9
@@ -93,8 +93,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
9393
9494 /** Per-session in-flight preview loads, so rapid SSE events for the same session
9595 * coalesce into a single request instead of firing one per event. Accessed from
96− * multiple coroutines (the SSE event handler and [loadPreview]), so kept concurrent. */
96+ * multiple coroutines (the SSE event handler and [loadPreview]), so the
97+ * remove-then-put sequence is guarded by [previewLock] to stay atomic. */
9798 private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
99+ private val previewLock = Any()
98100
99101 private val activeProfileId: String?
100102 get() = container.activeConnection.value?.profile?.id
@@ -191,14 +193,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
191193 private fun loadPreview(sessionId: String) {
192194 val conn = container.activeConnection.value ?: return
193195 val api = conn.api
194− livePreviewJobs.remove(sessionId)?.cancel()
195− livePreviewJobs[sessionId] = viewModelScope.launch {
196− val job = coroutineContext[kotlinx.coroutines.Job]
197− try {
198− val preview = fetchSessionPreview(api, sessionId) ?: return@launch
199− _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
200− } finally {
201− if (job != null) livePreviewJobs.remove(sessionId, job)
196+ synchronized(previewLock) {
197+ livePreviewJobs.remove(sessionId)?.cancel()
198+ livePreviewJobs[sessionId] = viewModelScope.launch {
199+ val job = coroutineContext[kotlinx.coroutines.Job]
200+ try {
201+ val preview = fetchSessionPreview(api, sessionId) ?: return@launch
202+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
203+ } finally {
204+ if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
205+ }
202206 }
203207 }
204208 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+9−1
@@ -1,6 +1,8 @@
11package soy.iko.opencode.ui.theme
22
33import android.app.Activity
4+import android.content.Context
5+import android.content.ContextWrapper
46import android.os.Build
57import androidx.compose.foundation.isSystemInDarkTheme
68import androidx.compose.material3.MaterialTheme
@@ -45,7 +47,7 @@ fun OpencodeTheme(
4547 val view = LocalView.current
4648 if (!view.isInEditMode) {
4749 SideEffect {
48− val window = (view.context as Activity).window
50+ val window = view.context.findActivity()?.window ?: return@SideEffect
4951 WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
5052 }
5153 }
@@ -54,3 +56,9 @@ fun OpencodeTheme(
5456 content = content,
5557 )
5658}
59+
60+private tailrec fun Context.findActivity(): Activity? = when (this) {
61+ is Activity -> this
62+ is ContextWrapper -> baseContext.findActivity()
63+ else -> null
64+}
app/src/main/res/values/strings.xml+10
@@ -100,6 +100,11 @@
100100 <string name="thoughts">Thoughts</string>
101101 <string name="show_more">Show more</string>
102102 <string name="show_less">Show less</string>
103+ <string name="tool_completed">Completed</string>
104+ <string name="tool_error">Error</string>
105+ <string name="tool_running">In progress</string>
106+ <string name="state_expanded">Expanded</string>
107+ <string name="state_collapsed">Collapsed</string>
103108
104109 <!-- Permission dialog -->
105110 <string name="permission_title">Permission requested</string>
@@ -116,6 +121,11 @@
116121 <string name="default_agent">Default</string>
117122 <string name="default_agent_desc">Server default agent</string>
118123 <string name="no_commands">No commands reported by the server.</string>
124+ <string name="loading_models">Loading models…</string>
125+ <string name="loading_agents">Loading agents…</string>
126+ <string name="loading_commands">Loading commands…</string>
127+ <string name="saving">Saving…</string>
128+ <string name="image_failed">Failed to load image</string>
119129
120130 <!-- File browser -->
121131 <string name="search_files">Search files…</string>
app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt+3−3
@@ -7,7 +7,7 @@ class NormalizeBaseUrlTest {
77
88 @Test
99 fun addsHttpSchemeWhenAbsent() {
10− assertEquals("http://192.168.1.10:4096/", HttpClientFactory.normalizeBaseUrl("192.168.1.10:4096"))
10+ assertEquals("https://192.168.1.10:4096/", HttpClientFactory.normalizeBaseUrl("192.168.1.10:4096"))
1111 }
1212
1313 @Test
@@ -22,7 +22,7 @@ class NormalizeBaseUrlTest {
2222
2323 @Test
2424 fun appendsTrailingSlash() {
25− assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl("host"))
25+ assertEquals("https://host/", HttpClientFactory.normalizeBaseUrl("host"))
2626 }
2727
2828 @Test
@@ -37,6 +37,6 @@ class NormalizeBaseUrlTest {
3737
3838 @Test
3939 fun keepsPathAfterHost() {
40− assertEquals("http://host:4096/opencode/", HttpClientFactory.normalizeBaseUrl("host:4096/opencode"))
40+ assertEquals("https://host:4096/opencode/", HttpClientFactory.normalizeBaseUrl("host:4096/opencode"))
4141 }
4242}