git.iko.soy/opencode-android
on master help sign in
Add 14 UX improvements: distinct find-prev/next a11y descriptions, SessionCard nested-button fix, FileRow combined git-status description, IME search-key actions in picker sheets + FindBar, removed double IME inset in chat, catalog-picker loading spinners, atomic settings load to fix default flash, session-creation FAB spinner, server-list pull-to-refresh, animateItem on session/server/diagnostics/file lists, styled no-sessions-match empty state, outlined Save button, queued-follow-up message preview
commite1a6907d90ae8bf326efdd703803ef9b8b2bfd9a authoriko <mail@iko.soy> dateWed 1 Jul 2026, 11:54 (3 months ago) parent28832af
13 files changed+246−106
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+4 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+54−15 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+4 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+23−11 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5−3 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+31−22 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+9 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+36−8 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+71−45 app/src/main/res/values/strings.xml+2
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+4
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardActions
1213import androidx.compose.foundation.text.KeyboardOptions
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.filled.Check
@@ -29,6 +30,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
2930import androidx.compose.runtime.setValue
3031import androidx.compose.ui.Alignment
3132import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3234import androidx.compose.ui.res.stringResource
3335import androidx.compose.ui.semantics.Role
3436import androidx.compose.ui.semantics.contentDescription
@@ -88,6 +90,7 @@ fun AgentPickerSheet(
8890 )
8991 } else {
9092 var query by rememberSaveable { mutableStateOf("") }
93+ val keyboardController = LocalSoftwareKeyboardController.current
9194 OutlinedTextField(
9295 value = query,
9396 onValueChange = { query = it },
@@ -96,6 +99,7 @@ fun AgentPickerSheet(
9699 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
97100 singleLine = true,
98101 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
99103 )
100104 val filtered = remember(agents, query) {
101105 val q = query.trim()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+54−15
@@ -83,6 +83,7 @@ import androidx.compose.ui.semantics.Role
8383import androidx.compose.ui.semantics.contentDescription
8484import androidx.compose.ui.semantics.semantics
8585import androidx.compose.ui.text.input.ImeAction
86+import androidx.compose.ui.text.style.TextOverflow
8687import androidx.compose.ui.unit.dp
8788import androidx.lifecycle.compose.collectAsStateWithLifecycle
8889import androidx.lifecycle.viewmodel.compose.viewModel
@@ -304,14 +305,42 @@ fun ChatScreen(
304305 ) {
305306 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
306307 }
307− IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
308− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
308+ // Show a spinner while a catalog is initially loading (empty list +
309+ // loading flag) so the icon doesn't look permanently disabled. Once
310+ // loaded, the icon is always enabled — tapping a picker with no items
311+ // shows the "no models/agents/commands" or error-with-retry state.
312+ if (commandsLoading && commands.isEmpty()) {
313+ val loadingLabel = stringResource(R.string.loading)
314+ CircularProgressIndicator(
315+ Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
316+ strokeWidth = 2.dp,
317+ )
318+ } else {
319+ IconButton(onClick = { showCommandPicker = true }) {
320+ Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
321+ }
309322 }
310− IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
311− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
323+ if (agentsLoading && agents.isEmpty()) {
324+ val loadingLabel = stringResource(R.string.loading)
325+ CircularProgressIndicator(
326+ Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
327+ strokeWidth = 2.dp,
328+ )
329+ } else {
330+ IconButton(onClick = { showAgentPicker = true }) {
331+ Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
332+ }
312333 }
313− IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
314− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
334+ if (modelsLoading && models.isEmpty()) {
335+ val loadingLabel = stringResource(R.string.loading)
336+ CircularProgressIndicator(
337+ Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
338+ strokeWidth = 2.dp,
339+ )
340+ } else {
341+ IconButton(onClick = { showModelPicker = true }) {
342+ Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
343+ }
315344 }
316345 },
317346 )
@@ -369,9 +398,11 @@ fun ChatScreen(
369398 }
370399
371400 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
372− // imePadding so the jump-to-latest FAB (aligned BottomEnd) is pushed above the
373− // IME instead of being hidden behind it when the keyboard is open.
374− Box(modifier = Modifier.fillMaxSize().padding(padding).imePadding()) {
401+ // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
402+ // content padding accounts for the bottomBar's raised height, so the message
403+ // list is already above the keyboard. Adding imePadding here would double-apply
404+ // the IME inset and push messages too far up.
405+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
375406 if (activeConnection == null) {
376407 Column(
377408 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -620,12 +651,20 @@ private fun ChatInputBar(
620651 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
621652 verticalAlignment = Alignment.CenterVertically,
622653 ) {
623− Text(
624− stringResource(R.string.queued),
625− style = MaterialTheme.typography.labelSmall,
626− color = MaterialTheme.colorScheme.onSurfaceVariant,
627− modifier = Modifier.weight(1f),
628− )
654+ Column(modifier = Modifier.weight(1f)) {
655+ Text(
656+ stringResource(R.string.queued),
657+ style = MaterialTheme.typography.labelSmall,
658+ color = MaterialTheme.colorScheme.onSurfaceVariant,
659+ )
660+ Text(
661+ queuedFollowUp,
662+ style = MaterialTheme.typography.bodySmall,
663+ color = MaterialTheme.colorScheme.onSurfaceVariant,
664+ maxLines = 1,
665+ overflow = TextOverflow.Ellipsis,
666+ )
667+ }
629668 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
630669 Text(stringResource(R.string.cancel))
631670 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.heightIn
77import androidx.compose.foundation.layout.padding
88import androidx.compose.foundation.lazy.LazyColumn
99import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardActions
1011import androidx.compose.foundation.text.KeyboardOptions
1112import androidx.compose.material.icons.Icons
1213import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
@@ -28,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
2829import androidx.compose.runtime.setValue
2930import androidx.compose.ui.Alignment
3031import androidx.compose.ui.Modifier
32+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3133import androidx.compose.ui.res.stringResource
3234import androidx.compose.ui.semantics.Role
3335import androidx.compose.ui.semantics.contentDescription
@@ -86,6 +88,7 @@ fun CommandPickerSheet(
8688 )
8789 } else {
8890 var query by rememberSaveable { mutableStateOf("") }
91+ val keyboardController = LocalSoftwareKeyboardController.current
8992 OutlinedTextField(
9093 value = query,
9194 onValueChange = { query = it },
@@ -94,6 +97,7 @@ fun CommandPickerSheet(
9497 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
9598 singleLine = true,
9699 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
100+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
97101 )
98102 val filtered = remember(commands, query) {
99103 val q = query.trim()
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+4
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardActions
1213import androidx.compose.foundation.text.KeyboardOptions
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.filled.Check
@@ -29,6 +30,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
2930import androidx.compose.runtime.setValue
3031import androidx.compose.ui.Alignment
3132import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3234import androidx.compose.ui.res.stringResource
3335import androidx.compose.ui.semantics.Role
3436import androidx.compose.ui.semantics.contentDescription
@@ -88,6 +90,7 @@ fun ModelPickerSheet(
8890 )
8991 } else {
9092 var query by rememberSaveable { mutableStateOf("") }
93+ val keyboardController = LocalSoftwareKeyboardController.current
9194 OutlinedTextField(
9295 value = query,
9396 onValueChange = { query = it },
@@ -96,6 +99,7 @@ fun ModelPickerSheet(
9699 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
97100 singleLine = true,
98101 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
99103 )
100104 val filtered = remember(options, query) {
101105 val q = query.trim()
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+23−11
@@ -206,6 +206,7 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
206206 label = name,
207207 sublabel = dir.takeIf { it.isNotEmpty() },
208208 onClick = { onOpenFile(path) },
209+ modifier = Modifier.animateItem(),
209210 )
210211 HorizontalDivider()
211212 }
@@ -240,6 +241,7 @@ private fun DirectoryListing(
240241 label = node.name,
241242 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
242243 status = state.statusMap[node.path],
244+ modifier = Modifier.animateItem(),
243245 )
244246 HorizontalDivider()
245247 }
@@ -277,16 +279,29 @@ private fun FileRow(
277279 onClick: () -> Unit,
278280 status: FileStatusEntry? = null,
279281 sublabel: String? = null,
282+ modifier: Modifier = Modifier,
280283) {
281− val desc = if (label == "..") stringResource(R.string.parent_dir)
284+ val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
282285 else if (icon) stringResource(R.string.folder, label)
283286 else stringResource(R.string.file_label, label)
287+ // Build a combined description so TalkBack announces both the file name and its git
288+ // status. The parent sets an explicit contentDescription (mergeDescendants), which
289+ // would otherwise suppress the StatusBadge's own description.
290+ val statusDesc = status?.let {
291+ when (it.status) {
292+ "added" -> stringResource(R.string.git_added)
293+ "modified" -> stringResource(R.string.git_modified)
294+ "deleted" -> stringResource(R.string.git_deleted)
295+ else -> ""
296+ }
297+ }.orEmpty()
298+ val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
284299 Row(
285− modifier = Modifier
300+ modifier = modifier
286301 .fillMaxWidth()
287302 .defaultMinSize(minHeight = 48.dp)
288303 .clickable(role = Role.Button, onClick = onClick)
289− .semantics(mergeDescendants = true) { contentDescription = desc }
304+ .semantics(mergeDescendants = true) { contentDescription = fullDesc }
290305 .padding(horizontal = 16.dp, vertical = 14.dp),
291306 verticalAlignment = Alignment.CenterVertically,
292307 ) {
@@ -319,17 +334,14 @@ private fun FileRow(
319334
320335@Composable
321336private fun StatusBadge(status: FileStatusEntry) {
322− val (letter, color, desc) = when (status.status) {
323− "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
324− "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
325− "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
326− else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
337+ val (letter, color) = when (status.status) {
338+ "added" -> "A" to MaterialTheme.colorScheme.primary
339+ "modified" -> "M" to MaterialTheme.colorScheme.tertiary
340+ "deleted" -> "D" to MaterialTheme.colorScheme.error
341+ else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
327342 }
328343 Row(
329344 verticalAlignment = Alignment.CenterVertically,
330− modifier = Modifier.semantics(mergeDescendants = true) {
331− if (desc.isNotEmpty()) contentDescription = desc
332− },
333345 ) {
334346 Text(
335347 letter,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5−3
@@ -60,6 +60,7 @@ import androidx.compose.runtime.setValue
6060import androidx.compose.ui.Alignment
6161import androidx.compose.ui.Modifier
6262import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
6364import androidx.compose.ui.semantics.contentDescription
6465import androidx.compose.ui.semantics.semantics
6566import androidx.compose.ui.text.font.FontFamily
@@ -386,6 +387,7 @@ private fun FindBar(
386387 onNext: () -> Unit,
387388 onClose: () -> Unit,
388389) {
390+ val keyboardController = LocalSoftwareKeyboardController.current
389391 Row(
390392 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
391393 verticalAlignment = Alignment.CenterVertically,
@@ -397,7 +399,7 @@ private fun FindBar(
397399 placeholder = { Text(stringResource(R.string.find_hint)) },
398400 singleLine = true,
399401 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
400− keyboardActions = KeyboardActions(onSearch = { onQueryChange(query) }),
402+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
401403 trailingIcon = {
402404 val countText = if (query.isBlank()) ""
403405 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
@@ -412,10 +414,10 @@ private fun FindBar(
412414 },
413415 )
414416 IconButton(onClick = onPrev, enabled = matchCount > 0) {
415− Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_in_file))
417+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
416418 }
417419 IconButton(onClick = onNext, enabled = matchCount > 0) {
418− Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_in_file))
420+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
419421 }
420422 IconButton(onClick = onClose) {
421423 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+2−2
@@ -233,7 +233,7 @@ private fun ServerEditForm(
233233 modifier = Modifier.fillMaxWidth(),
234234 horizontalArrangement = Arrangement.spacedBy(12.dp),
235235 ) {
236− Button(
236+ OutlinedButton(
237237 onClick = {
238238 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
239239 vm.save(onDone)
@@ -244,7 +244,7 @@ private fun ServerEditForm(
244244 if (state.saving) {
245245 CircularProgressIndicator(
246246 modifier = Modifier.size(20.dp),
247− color = MaterialTheme.colorScheme.onPrimary,
247+ color = MaterialTheme.colorScheme.primary,
248248 strokeWidth = 2.dp,
249249 )
250250 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+31−22
@@ -39,6 +39,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
3939import androidx.compose.material3.Text
4040import androidx.compose.material3.TextButton
4141import androidx.compose.material3.TopAppBar
42+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
4243import androidx.compose.material3.rememberSwipeToDismissBoxState
4344import androidx.compose.runtime.Composable
4445import androidx.compose.runtime.LaunchedEffect
@@ -145,29 +146,35 @@ fun ServerListScreen(
145146 modifier = Modifier.fillMaxSize().padding(padding),
146147 )
147148 } else {
148− LazyColumn(
149− modifier = Modifier.fillMaxSize().padding(padding),
150− contentPadding = androidx.compose.foundation.layout.PaddingValues(
151− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
152− ),
153− verticalArrangement = Arrangement.spacedBy(12.dp),
149+ PullToRefreshBox(
150+ isRefreshing = connectingId != null,
151+ onRefresh = { vm.refresh(onConnected) },
152+ modifier = Modifier.fillMaxSize(),
154153 ) {
155− items(profiles, key = { it.id }) { profile ->
156− val isActive = profile.id == connectedId
157− if (isActive) {
158− // The active server can't be swipe-deleted (deleting it also
159− // disconnects, which deserves an explicit tap, not an
160− // accidental swipe). Render a plain card without the swipe
161− // affordance for the active row.
162− Card(
163− modifier = Modifier
164− .fillMaxWidth()
165− .testTag("server_card")
166− .clickable(enabled = connectingId == null, role = Role.Button) {
167− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
168− vm.connect(profile, onConnected)
169− },
170− ) {
154+ LazyColumn(
155+ modifier = Modifier.fillMaxSize().padding(padding),
156+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
157+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
158+ ),
159+ verticalArrangement = Arrangement.spacedBy(12.dp),
160+ ) {
161+ items(profiles, key = { it.id }) { profile ->
162+ val isActive = profile.id == connectedId
163+ if (isActive) {
164+ // The active server can't be swipe-deleted (deleting it also
165+ // disconnects, which deserves an explicit tap, not an
166+ // accidental swipe). Render a plain card without the swipe
167+ // affordance for the active row.
168+ Card(
169+ modifier = Modifier
170+ .fillMaxWidth()
171+ .animateItem()
172+ .testTag("server_card")
173+ .clickable(enabled = connectingId == null, role = Role.Button) {
174+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
175+ vm.connect(profile, onConnected)
176+ },
177+ ) {
171178 Row(
172179 modifier = Modifier.fillMaxWidth().padding(16.dp),
173180 verticalAlignment = Alignment.CenterVertically,
@@ -224,6 +231,7 @@ fun ServerListScreen(
224231 SwipeToDismissBox(
225232 state = swipeState,
226233 enableDismissFromStartToEnd = false,
234+ modifier = Modifier.animateItem(),
227235 backgroundContent = {
228236 Box(
229237 modifier = Modifier
@@ -290,6 +298,7 @@ fun ServerListScreen(
290298 }
291299 }
292300 }
301+ }
293302 }
294303 }
295304 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+9
@@ -74,6 +74,15 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
7474 }
7575 }
7676
77+ /** Retry connecting to the most recently used profile — the pull-to-refresh action on
78+ * the server list. Gives the user a familiar gesture to retry a failed auto-connect
79+ * instead of having to tap a server card. */
80+ fun refresh(onConnected: () -> Unit) {
81+ if (_connecting.value != null || container.reconnecting.value) return
82+ val target = profiles.value.firstOrNull() ?: return
83+ connect(target, onConnected)
84+ }
85+
7786 /**
7887 * Mark [profile] for deferred deletion and emit an Undo event. The actual
7988 * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+36−8
@@ -210,8 +210,18 @@ fun SessionListScreen(
210210 snackbarHost = { SnackbarHost(snackbar) },
211211 floatingActionButton = {
212212 ExtendedFloatingActionButton(
213− onClick = { vm.createSession(onCreated = onOpenSession) },
214− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
213+ onClick = { if (!creating) vm.createSession(onCreated = onOpenSession) },
214+ icon = {
215+ if (creating) {
216+ CircularProgressIndicator(
217+ Modifier.size(24.dp),
218+ strokeWidth = 2.dp,
219+ color = MaterialTheme.colorScheme.onPrimaryContainer,
220+ )
221+ } else {
222+ Icon(Icons.Filled.Add, contentDescription = null)
223+ }
224+ },
215225 text = { Text(stringResource(R.string.new_session)) },
216226 // Disable while a creation is in flight so a double-tap can't spawn two
217227 // sessions. The container guard in createSession is the real protection;
@@ -355,11 +365,28 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
355365 if (state.query.isBlank()) state.sessions else state.filtered
356366 }
357367 if (sessions.isEmpty()) {
358− Text(
359− stringResource(R.string.no_sessions_match, state.query),
360− modifier = Modifier.padding(24.dp),
361− color = MaterialTheme.colorScheme.onSurfaceVariant,
362− )
368+ Box(
369+ modifier = Modifier.fillMaxSize(),
370+ contentAlignment = Alignment.Center,
371+ ) {
372+ Column(
373+ horizontalAlignment = Alignment.CenterHorizontally,
374+ modifier = Modifier.padding(32.dp),
375+ ) {
376+ Icon(
377+ Icons.Filled.Search,
378+ contentDescription = null,
379+ modifier = Modifier.size(48.dp),
380+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
381+ )
382+ Spacer(Modifier.size(12.dp))
383+ Text(
384+ stringResource(R.string.no_sessions_match, state.query),
385+ style = MaterialTheme.typography.bodyMedium,
386+ color = MaterialTheme.colorScheme.onSurfaceVariant,
387+ )
388+ }
389+ }
363390 } else {
364391 PullToRefreshBox(
365392 isRefreshing = refreshing,
@@ -389,6 +416,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
389416 SwipeToDismissBox(
390417 state = swipeState,
391418 enableDismissFromStartToEnd = false,
419+ modifier = Modifier.animateItem(),
392420 backgroundContent = {
393421 Box(
394422 modifier = Modifier
@@ -469,7 +497,7 @@ private fun SessionCard(
469497 Card(
470498 modifier = modifier
471499 .fillMaxWidth()
472− .clickable(role = Role.Button) { onClick() },
500+ .clickable { onClick() },
473501 ) {
474502 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
475503 Row(
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1
@@ -154,6 +154,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
154154 SwipeToDismissBox(
155155 state = swipeState,
156156 enableDismissFromStartToEnd = false,
157+ modifier = Modifier.animateItem(),
157158 backgroundContent = {
158159 Box(
159160 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+71−45
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
2525import androidx.compose.material.icons.filled.BugReport
2626import androidx.compose.material.icons.filled.Dns
2727import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.CircularProgressIndicator
2829import androidx.compose.material3.HorizontalDivider
2930import androidx.compose.material3.Icon
3031import androidx.compose.material3.IconButton
@@ -52,6 +53,7 @@ import androidx.compose.ui.semantics.semantics
5253import androidx.compose.ui.text.style.TextAlign
5354import androidx.compose.ui.unit.dp
5455import androidx.lifecycle.compose.collectAsStateWithLifecycle
56+import kotlinx.coroutines.flow.combine
5557import kotlinx.coroutines.launch
5658import soy.iko.opencode.data.repo.CrashLogger
5759import soy.iko.opencode.data.repo.ThemeMode
@@ -65,9 +67,15 @@ import soy.iko.opencode.util.runCatchingCancellable
6567@Composable
6668fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
6769 val scope = rememberCoroutineScope()
68− val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
69− val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
70− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
70+ // Combine the three settings into a single nullable state so the appearance section
71+ // renders only after the persisted values have loaded, avoiding a brief flash of
72+ // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
73+ val settings by combine(
74+ container.settingsStore.themeMode,
75+ container.settingsStore.dynamicColor,
76+ container.settingsStore.sendOnEnter,
77+ ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
78+ .collectAsStateWithLifecycle(initialValue = null)
7179 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
7280 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
7381 val context = LocalContext.current
@@ -104,75 +112,85 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
104112 },
105113 ) { padding ->
106114 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
115+ val s = settings
107116 Text(
108117 stringResource(R.string.appearance),
109118 style = MaterialTheme.typography.titleSmall,
110119 color = MaterialTheme.colorScheme.primary,
111120 modifier = Modifier.semantics { heading() },
112121 )
113− ThemeMode.entries.forEach { mode ->
114− ThemeRow(
115− mode = mode,
116− selected = themeMode == mode,
117− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
118− )
119− }
122+ if (s != null) {
123+ ThemeMode.entries.forEach { mode ->
124+ ThemeRow(
125+ mode = mode,
126+ selected = s.themeMode == mode,
127+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
128+ )
129+ }
130+
131+ Spacer(Modifier.size(8.dp))
132+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
133+ // on older devices instead of offering a control that silently does nothing.
134+ if (dynamicColorAvailable) {
135+ Row(
136+ modifier = Modifier
137+ .fillMaxWidth()
138+ .toggleable(
139+ value = s.dynamicColor,
140+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
141+ role = Role.Switch,
142+ )
143+ .padding(vertical = 8.dp),
144+ verticalAlignment = Alignment.CenterVertically,
145+ ) {
146+ Column(modifier = Modifier.weight(1f)) {
147+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
148+ Text(
149+ stringResource(R.string.dynamic_color_desc),
150+ style = MaterialTheme.typography.bodySmall,
151+ color = MaterialTheme.colorScheme.onSurfaceVariant,
152+ )
153+ }
154+ Switch(
155+ checked = s.dynamicColor,
156+ onCheckedChange = null,
157+ )
158+ }
159+ }
120160
121− Spacer(Modifier.size(8.dp))
122− // Dynamic color (Material You) only works on Android 12+, so hide the toggle
123− // on older devices instead of offering a control that silently does nothing.
124− if (dynamicColorAvailable) {
161+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
162+ Spacer(Modifier.size(4.dp))
125163 Row(
126164 modifier = Modifier
127165 .fillMaxWidth()
128166 .toggleable(
129− value = dynamicColor,
130− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
167+ value = s.sendOnEnter,
168+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
131169 role = Role.Switch,
132170 )
133171 .padding(vertical = 8.dp),
134172 verticalAlignment = Alignment.CenterVertically,
135173 ) {
136174 Column(modifier = Modifier.weight(1f)) {
137− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
175+ Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
138176 Text(
139− stringResource(R.string.dynamic_color_desc),
177+ stringResource(R.string.send_on_enter_desc),
140178 style = MaterialTheme.typography.bodySmall,
141179 color = MaterialTheme.colorScheme.onSurfaceVariant,
142180 )
143181 }
144182 Switch(
145− checked = dynamicColor,
183+ checked = s.sendOnEnter,
146184 onCheckedChange = null,
147185 )
148186 }
149− }
150−
151− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
152− Spacer(Modifier.size(4.dp))
153− Row(
154− modifier = Modifier
155− .fillMaxWidth()
156− .toggleable(
157− value = sendOnEnter,
158− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
159− role = Role.Switch,
160− )
161− .padding(vertical = 8.dp),
162− verticalAlignment = Alignment.CenterVertically,
163− ) {
164− Column(modifier = Modifier.weight(1f)) {
165− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
166− Text(
167− stringResource(R.string.send_on_enter_desc),
168− style = MaterialTheme.typography.bodySmall,
169− color = MaterialTheme.colorScheme.onSurfaceVariant,
170− )
187+ } else {
188+ Box(
189+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
190+ contentAlignment = Alignment.Center,
191+ ) {
192+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
171193 }
172− Switch(
173− checked = sendOnEnter,
174− onCheckedChange = null,
175− )
176194 }
177195
178196 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -311,6 +329,14 @@ private val LightSwatches: List<Color> = LightPaletteSwatches
311329@Composable
312330private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
313331
332+/** Small wrapper so the three appearance settings load atomically and the UI can gate
333+ * on a single null check instead of flashing hardcoded defaults. */
334+private data class SettingsValues(
335+ val themeMode: ThemeMode,
336+ val dynamicColor: Boolean,
337+ val sendOnEnter: Boolean,
338+)
339+
314340/** Small circular count badge used to indicate pending crash reports. */
315341@Composable
316342private fun Badge(count: Int) {
app/src/main/res/values/strings.xml+2
@@ -241,6 +241,8 @@
241241 <string name="last_used">Last used %1$s</string>
242242 <string name="duplicate_server">Duplicate</string>
243243 <string name="find_in_file">Find in file</string>
244+ <string name="find_previous">Find previous</string>
245+ <string name="find_next">Find next</string>
244246 <string name="find_hint">Find…</string>
245247 <string name="no_matches_in_file">No matches</string>
246248 <string name="match_count">%1$d / %2$d</string>