git.iko.soy/opencode-android
on master help sign in
Improve UX across screens with retry buttons, inline URL validation, swipe-to-delete, scrollable settings, navigation transitions, and clearer status affordances
commit27b550589bfc90c849cf3aa1561beaf810e5cdde authoriko <mail@iko.soy> dateWed 1 Jul 2026, 08:52 (3 months ago) parenta40d3a2
14 files changed+232−67
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−1 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12−9 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+50−9 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12−9 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt−1 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+12−9 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+16−6 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+18−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+1−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+55−13 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+34−6 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−1 app/src/main/res/values/strings.xml+3
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−1
@@ -1,5 +1,10 @@
11package soy.iko.opencode.ui
22
3+import androidx.compose.animation.core.tween
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.animation.slideInHorizontally
7+import androidx.compose.animation.slideOutHorizontally
38import androidx.compose.foundation.layout.BoxWithConstraints
49import androidx.compose.runtime.Composable
510import androidx.compose.runtime.LaunchedEffect
@@ -60,7 +65,16 @@ fun OpencodeApp(container: AppContainer) {
6065 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
6166 }
6267
63− NavHost(navController = navController, startDestination = Routes.SERVERS) {
68+ NavHost(
69+ navController = navController,
70+ startDestination = Routes.SERVERS,
71+ // Slide horizontally for forward/back navigation to feel native; fade for
72+ // the root so the first frame doesn't slide in from off-screen.
73+ enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
74+ exitTransition = { fadeOut(tween(180)) },
75+ popEnterTransition = { fadeIn(tween(180)) },
76+ popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
77+ ) {
6478
6579 composable(Routes.SERVERS) {
6680 ServerListScreen(
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12−9
@@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme
2020import androidx.compose.material3.ModalBottomSheet
2121import androidx.compose.material3.OutlinedTextField
2222import androidx.compose.material3.Text
23+import androidx.compose.material3.TextButton
2324import androidx.compose.runtime.Composable
2425import androidx.compose.runtime.getValue
2526import androidx.compose.runtime.mutableStateOf
@@ -67,15 +68,17 @@ fun AgentPickerSheet(
6768 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
6869 }
6970 } else if (error && agents.isEmpty()) {
70− Text(
71− stringResource(R.string.load_failed),
72− style = MaterialTheme.typography.bodyMedium,
73− color = MaterialTheme.colorScheme.error,
74− modifier = Modifier
75− .fillMaxWidth()
76− .clickable(role = Role.Button) { onRetry() }
77− .padding(horizontal = 24.dp, vertical = 16.dp),
78− )
71+ Column(
72+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
73+ horizontalAlignment = Alignment.CenterHorizontally,
74+ ) {
75+ Text(
76+ stringResource(R.string.load_failed),
77+ style = MaterialTheme.typography.bodyMedium,
78+ color = MaterialTheme.colorScheme.error,
79+ )
80+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
81+ }
7982 } else if (agents.isEmpty()) {
8083 Text(
8184 stringResource(R.string.no_agents),
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+50−9
@@ -202,15 +202,32 @@ fun ChatScreen(
202202 topBar = {
203203 TopAppBar(
204204 title = {
205− Column(modifier = Modifier
206− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
205+ val changeLabel = stringResource(R.string.change_model_agent)
206+ Row(
207+ modifier = Modifier
208+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
209+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
210+ verticalAlignment = Alignment.CenterVertically,
207211 ) {
208− Text(sessionTitle ?: sessionLabel)
209− Text(
210− subtitle,
211− style = MaterialTheme.typography.labelSmall,
212− color = MaterialTheme.colorScheme.onSurfaceVariant,
213− )
212+ Column {
213+ Text(sessionTitle ?: sessionLabel)
214+ Text(
215+ subtitle,
216+ style = MaterialTheme.typography.labelSmall,
217+ color = MaterialTheme.colorScheme.onSurfaceVariant,
218+ )
219+ }
220+ // Visual affordance that the title opens the model picker;
221+ // without it the tappable title is indistinguishable from plain
222+ // text and the picker is effectively undiscoverable.
223+ if (models.isNotEmpty()) {
224+ Icon(
225+ Icons.Filled.KeyboardArrowDown,
226+ contentDescription = null,
227+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
228+ modifier = Modifier.size(20.dp),
229+ )
230+ }
214231 }
215232 },
216233 navigationIcon = {
@@ -221,6 +238,17 @@ fun ChatScreen(
221238 }
222239 },
223240 actions = {
241+ // At-a-glance run status in the top bar. When the user is scrolled
242+ // up (jump-to-latest FAB visible), the input-bar Stop button and the
243+ // trailing "working…" row are off-screen, so a top-bar spinner is the
244+ // only signal the agent is still working.
245+ if (running) {
246+ val workingLabel = stringResource(R.string.working)
247+ CircularProgressIndicator(
248+ Modifier.size(18.dp).semantics { contentDescription = workingLabel },
249+ strokeWidth = 2.dp,
250+ )
251+ }
224252 IconButton(
225253 onClick = {
226254 scope.launch {
@@ -335,7 +363,20 @@ fun ChatScreen(
335363 .align(Alignment.Center)
336364 .semantics { contentDescription = loadingLabel },
337365 )
338− } else if (messages.isEmpty() && !running) {
366+ } else if (messages.isEmpty() && running) {
367+ // A run just started but no parts have arrived yet — the empty
368+ // list with only the trailing "working…" row looks broken, so
369+ // surface a clear starting state until the first part streams in.
370+ val workingText = stringResource(R.string.working)
371+ Column(
372+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
373+ horizontalAlignment = Alignment.CenterHorizontally,
374+ ) {
375+ CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
376+ Spacer(Modifier.size(12.dp))
377+ Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
378+ }
379+ } else if (messages.isEmpty()) {
339380 EmptyConversation(
340381 modifier = Modifier.align(Alignment.Center),
341382 )
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12−9
@@ -15,6 +15,7 @@ import androidx.compose.material3.Icon
1515import androidx.compose.material3.MaterialTheme
1616import androidx.compose.material3.ModalBottomSheet
1717import androidx.compose.material3.Text
18+import androidx.compose.material3.TextButton
1819import androidx.compose.runtime.Composable
1920import androidx.compose.ui.Alignment
2021import androidx.compose.ui.Modifier
@@ -55,15 +56,17 @@ fun CommandPickerSheet(
5556 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
5657 }
5758 } else if (error && commands.isEmpty()) {
58− Text(
59− stringResource(R.string.load_failed),
60− style = MaterialTheme.typography.bodyMedium,
61− color = MaterialTheme.colorScheme.error,
62− modifier = Modifier
63− .fillMaxWidth()
64− .clickable(role = Role.Button) { onRetry() }
65− .padding(horizontal = 24.dp, vertical = 16.dp),
66− )
59+ Column(
60+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
61+ horizontalAlignment = Alignment.CenterHorizontally,
62+ ) {
63+ Text(
64+ stringResource(R.string.load_failed),
65+ style = MaterialTheme.typography.bodyMedium,
66+ color = MaterialTheme.colorScheme.error,
67+ )
68+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
69+ }
6770 } else if (commands.isEmpty()) {
6871 Text(
6972 stringResource(R.string.no_commands),
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt−1
@@ -123,7 +123,6 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
123123 if (textToCopy != null) {
124124 IconButton(
125125 onClick = { copyToClipboard(context, "message", textToCopy) },
126− modifier = Modifier.size(28.dp),
127126 ) {
128127 Icon(
129128 Icons.Filled.ContentCopy,
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+12−9
@@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme
2020import androidx.compose.material3.ModalBottomSheet
2121import androidx.compose.material3.OutlinedTextField
2222import androidx.compose.material3.Text
23+import androidx.compose.material3.TextButton
2324import androidx.compose.runtime.Composable
2425import androidx.compose.runtime.getValue
2526import androidx.compose.runtime.mutableStateOf
@@ -67,15 +68,17 @@ fun ModelPickerSheet(
6768 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
6869 }
6970 } else if (error && options.isEmpty()) {
70− Text(
71− stringResource(R.string.load_failed),
72− style = MaterialTheme.typography.bodyMedium,
73− color = MaterialTheme.colorScheme.error,
74− modifier = Modifier
75− .fillMaxWidth()
76− .clickable(role = Role.Button) { onRetry() }
77− .padding(horizontal = 24.dp, vertical = 16.dp),
78− )
71+ Column(
72+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
73+ horizontalAlignment = Alignment.CenterHorizontally,
74+ ) {
75+ Text(
76+ stringResource(R.string.load_failed),
77+ style = MaterialTheme.typography.bodyMedium,
78+ color = MaterialTheme.colorScheme.error,
79+ )
80+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
81+ }
7982 } else if (options.isEmpty()) {
8083 Text(
8184 stringResource(R.string.no_models),
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+16−6
@@ -37,6 +37,14 @@ fun ConnectionBanner(
3737 EventStreamClient.ConnectionState.Connected -> null
3838 }
3939 if (text != null) {
40+ // Distinguish a hard failure (e.g. bad credentials) from transient
41+ // connecting/reconnecting states by switching to the error palette, so
42+ // the banner conveys urgency without relying on text alone.
43+ val isFailed = state == EventStreamClient.ConnectionState.Failed
44+ val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
45+ else MaterialTheme.colorScheme.tertiaryContainer
46+ val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
47+ else MaterialTheme.colorScheme.onTertiaryContainer
4048 Surface(
4149 modifier = modifier
4250 .fillMaxWidth()
@@ -46,21 +54,23 @@ fun ConnectionBanner(
4654 liveRegion = LiveRegionMode.Polite
4755 contentDescription = text
4856 },
49− color = MaterialTheme.colorScheme.tertiaryContainer,
57+ color = container,
5058 ) {
5159 Row(
5260 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
5361 horizontalArrangement = Arrangement.Center,
5462 verticalAlignment = Alignment.CenterVertically,
5563 ) {
56− CircularProgressIndicator(
57− Modifier.size(12.dp),
58− strokeWidth = 2.dp,
59− )
64+ if (!isFailed) {
65+ CircularProgressIndicator(
66+ Modifier.size(12.dp),
67+ strokeWidth = 2.dp,
68+ )
69+ }
6070 Text(
6171 text,
6272 style = MaterialTheme.typography.labelMedium,
63− color = MaterialTheme.colorScheme.onTertiaryContainer,
73+ color = onContainer,
6474 modifier = Modifier.padding(start = 6.dp),
6575 )
6676 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1
@@ -193,7 +193,7 @@ private fun DirectoryListing(
193193 onUp: () -> Unit,
194194 onOpenFile: (String) -> Unit,
195195) {
196− if (state.entries.isEmpty() && state.path.isBlank()) {
196+ if (state.entries.isEmpty()) {
197197 Text(
198198 stringResource(R.string.empty_folder),
199199 modifier = Modifier.fillMaxWidth().padding(24.dp),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+18−1
@@ -93,10 +93,16 @@ fun ServerEditScreen(
9393 )
9494 OutlinedTextField(
9595 value = state.baseUrl,
96− onValueChange = { v -> vm.update { it.copy(baseUrl = v, authFieldsVisible = false, error = null) } },
96+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
9797 label = { Text(stringResource(R.string.base_url)) },
9898 placeholder = { Text(stringResource(R.string.base_url_hint)) },
9999 singleLine = true,
100+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
101+ supportingText = {
102+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
103+ Text(stringResource(R.string.invalid_url))
104+ }
105+ },
100106 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
101107 modifier = Modifier.fillMaxWidth().testTag("server_url"),
102108 )
@@ -118,6 +124,17 @@ fun ServerEditScreen(
118124 Text(stringResource(R.string.check_connectivity))
119125 }
120126 }
127+ // Guide users through the probe-for-auth flow: auth fields only appear
128+ // after a probe detects they're required, so a user who doesn't know to
129+ // tap "Check connectivity" would otherwise be confused.
130+ if (!state.authFieldsVisible) {
131+ Text(
132+ stringResource(R.string.auth_help),
133+ style = MaterialTheme.typography.bodySmall,
134+ color = MaterialTheme.colorScheme.onSurfaceVariant,
135+ modifier = Modifier.fillMaxWidth(),
136+ )
137+ }
121138 AnimatedVisibility(visible = state.authFieldsVisible) {
122139 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
123140 Text(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+1−1
@@ -37,7 +37,7 @@ data class ServerEditState(
3737 val isNew: Boolean get() = id == null
3838}
3939
40−private fun isValidUrl(url: String): Boolean = try {
40+fun isValidUrl(url: String): Boolean = try {
4141 val u = java.net.URI(url.trim())
4242 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
4343} catch (_: Exception) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+55−13
@@ -14,9 +14,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
1414import androidx.compose.foundation.layout.padding
1515import androidx.compose.foundation.layout.size
1616import androidx.compose.foundation.lazy.LazyColumn
17+import androidx.compose.foundation.lazy.items
1718import androidx.compose.foundation.text.KeyboardActions
1819import androidx.compose.foundation.text.KeyboardOptions
19−import androidx.compose.foundation.lazy.items
2020import androidx.compose.material.icons.Icons
2121import androidx.compose.material.icons.automirrored.filled.Logout
2222import androidx.compose.material.icons.filled.Add
@@ -43,10 +43,13 @@ import androidx.compose.material3.OutlinedTextField
4343import androidx.compose.material3.Scaffold
4444import androidx.compose.material3.SnackbarHost
4545import androidx.compose.material3.SnackbarHostState
46+import androidx.compose.material3.SwipeToDismissBox
47+import androidx.compose.material3.SwipeToDismissBoxValue
4648import androidx.compose.material3.Text
4749import androidx.compose.material3.TextButton
4850import androidx.compose.material3.TopAppBar
4951import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52+import androidx.compose.material3.rememberSwipeToDismissBoxState
5053import androidx.compose.runtime.Composable
5154import androidx.compose.runtime.CompositionLocalProvider
5255import androidx.compose.runtime.LaunchedEffect
@@ -213,11 +216,19 @@ fun SessionListScreen(
213216 .semantics { contentDescription = loadingLabel },
214217 )
215218 }
216− state.sessions.isEmpty() && state.error != null -> Text(
217− state.error ?: "",
219+ state.sessions.isEmpty() && state.error != null -> Column(
218220 modifier = Modifier.align(Alignment.Center).padding(24.dp),
219− color = MaterialTheme.colorScheme.error,
220− )
221+ horizontalAlignment = Alignment.CenterHorizontally,
222+ ) {
223+ Text(
224+ state.error ?: "",
225+ color = MaterialTheme.colorScheme.error,
226+ )
227+ Spacer(Modifier.size(12.dp))
228+ TextButton(onClick = { vm.refresh() }) {
229+ Text(stringResource(R.string.retry))
230+ }
231+ }
221232 state.sessions.isEmpty() -> EmptySessions(
222233 onCreate = { vm.createSession(onCreated = onOpenSession) },
223234 modifier = Modifier.align(Alignment.Center),
@@ -258,15 +269,46 @@ fun SessionListScreen(
258269 verticalArrangement = Arrangement.spacedBy(12.dp),
259270 ) {
260271 items(sessions, key = { it.id }) { session ->
261− SessionCard(
262− session = session,
263− preview = state.previews[session.id],
264− unread = unread.contains(session.id),
265− onClick = { onOpenSession(session.id) },
266− onRename = { pendingRenameId = session.id },
267− onDelete = { pendingDeleteId = session.id },
268− modifier = Modifier.testTag("session_card"),
272+ // Swipe end-to-start reveals a delete affordance and opens the
273+ // same confirmation dialog as the trash icon. We never commit
274+ // the dismissal (always reset to Settled) so the card snaps back
275+ // and the dialog guards against accidental data loss.
276+ val swipeState = rememberSwipeToDismissBoxState(
277+ confirmValueChange = {
278+ pendingDeleteId = session.id
279+ false
280+ },
269281 )
282+ SwipeToDismissBox(
283+ state = swipeState,
284+ enableDismissFromStartToEnd = false,
285+ backgroundContent = {
286+ Box(
287+ modifier = Modifier
288+ .fillMaxSize()
289+ .clip(MaterialTheme.shapes.medium)
290+ .background(MaterialTheme.colorScheme.errorContainer)
291+ .padding(horizontal = 20.dp),
292+ contentAlignment = Alignment.CenterEnd,
293+ ) {
294+ Icon(
295+ Icons.Filled.Delete,
296+ contentDescription = stringResource(R.string.delete),
297+ tint = MaterialTheme.colorScheme.onErrorContainer,
298+ )
299+ }
300+ },
301+ ) {
302+ SessionCard(
303+ session = session,
304+ preview = state.previews[session.id],
305+ unread = unread.contains(session.id),
306+ onClick = { onOpenSession(session.id) },
307+ onRename = { pendingRenameId = session.id },
308+ onDelete = { pendingDeleteId = session.id },
309+ modifier = Modifier.testTag("session_card"),
310+ )
311+ }
270312 }
271313 }
272314 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+34−6
@@ -10,10 +10,13 @@ import androidx.compose.foundation.layout.Row
1010import androidx.compose.foundation.layout.Spacer
1111import androidx.compose.foundation.layout.fillMaxSize
1212import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.heightIn
1314import androidx.compose.foundation.layout.padding
1415import androidx.compose.foundation.layout.size
1516import androidx.compose.foundation.lazy.LazyColumn
1617import androidx.compose.foundation.lazy.items
18+import androidx.compose.foundation.rememberScrollState
19+import androidx.compose.foundation.verticalScroll
1720import androidx.compose.material.icons.Icons
1821import androidx.compose.material.icons.automirrored.filled.ArrowBack
1922import androidx.compose.material.icons.filled.BugReport
@@ -67,6 +70,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
6770 val logger = remember { CrashLogger.get(context) }
6871 val reports by logger.reports.collectAsStateWithLifecycle()
6972 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
73+ var showClearAll by rememberSaveable { mutableStateOf(false) }
7074 val shareScope = rememberCoroutineScope()
7175 val shareLabel = stringResource(R.string.share)
7276 val timeTick = rememberRelativeTimeTick()
@@ -83,7 +87,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
8387 actions = {
8488 if (reports.isNotEmpty()) {
8589 IconButton(onClick = {
86− logger.clearAll()
90+ showClearAll = true
8791 }) {
8892 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
8993 }
@@ -200,11 +204,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
200204 title = { Text(stringResource(R.string.crash_report)) },
201205 text = {
202206 if (content != null) {
203− Text(
204− content,
205− style = MaterialTheme.typography.bodySmall,
206− modifier = Modifier.fillMaxWidth(),
207− )
207+ Box(
208+ modifier = Modifier
209+ .fillMaxWidth()
210+ .heightIn(max = 400.dp)
211+ .verticalScroll(rememberScrollState()),
212+ ) {
213+ Text(
214+ content,
215+ style = MaterialTheme.typography.bodySmall,
216+ modifier = Modifier.fillMaxWidth(),
217+ )
218+ }
208219 } else {
209220 val loadingLabel = stringResource(R.string.loading)
210221 Box(
@@ -217,4 +228,21 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
217228 },
218229 )
219230 }
231+
232+ if (showClearAll) {
233+ AlertDialog(
234+ onDismissRequest = { showClearAll = false },
235+ title = { Text(stringResource(R.string.clear_all)) },
236+ text = { Text(stringResource(R.string.clear_all_confirm)) },
237+ confirmButton = {
238+ TextButton(onClick = {
239+ showClearAll = false
240+ logger.clearAll()
241+ }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
242+ },
243+ dismissButton = {
244+ TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
245+ },
246+ )
247+ }
220248}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−1
@@ -12,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
1212import androidx.compose.foundation.layout.padding
1313import androidx.compose.foundation.layout.size
1414import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.rememberScrollState
1516import androidx.compose.foundation.selection.selectable
1617import androidx.compose.foundation.selection.toggleable
18+import androidx.compose.foundation.verticalScroll
1719import androidx.compose.material.icons.Icons
1820import androidx.compose.material.icons.automirrored.filled.ArrowBack
1921import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -85,7 +87,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
8587 )
8688 },
8789 ) { padding ->
88− Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).padding(16.dp)) {
90+ Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
8991 Text(
9092 stringResource(R.string.appearance),
9193 style = MaterialTheme.typography.titleSmall,
app/src/main/res/values/strings.xml+3
@@ -54,6 +54,7 @@
5454 <string name="label_optional">Label (optional)</string>
5555 <string name="base_url">Base URL</string>
5656 <string name="base_url_hint">http://192.168.1.10:4096</string>
57+ <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
5758 <string name="check_connectivity">Check connectivity</string>
5859 <string name="checking">Checking…</string>
5960 <string name="auth_required">This server requires authentication.</string>
@@ -90,6 +91,7 @@
9091 <string name="commands">Commands</string>
9192 <string name="choose_agent">Choose agent</string>
9293 <string name="choose_model">Choose model</string>
94+ <string name="change_model_agent">Change model and agent</string>
9395 <string name="share_conversation">Share conversation</string>
9496
9597 <!-- Errors (view models) -->
@@ -171,6 +173,7 @@
171173 <string name="crash_reports">Crash reports</string>
172174 <string name="no_crash_reports">No crashes recorded. \ud83d\udc4d</string>
173175 <string name="clear_all">Clear all</string>
176+ <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
174177 <string name="crash_report">Crash report</string>
175178
176179 <!-- Notifications -->