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
27b5505iko
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−1
@@ -1,5 +1,10 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
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
3import androidx.compose.foundation.layout.BoxWithConstraints
8import androidx.compose.foundation.layout.BoxWithConstraints
4import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
5import androidx.compose.runtime.LaunchedEffect
10import androidx.compose.runtime.LaunchedEffect
@@ -60,7 +65,16 @@ fun OpencodeApp(container: AppContainer) {
60 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
65 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
61 }
66 }
62
67
68+ NavHost(
69+ navController = navController,
63− NavHost(navController = navController, startDestination = Routes.SERVERS) {
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+ ) {
64
78
65 composable(Routes.SERVERS) {
79 composable(Routes.SERVERS) {
66 ServerListScreen(
80 ServerListScreen(
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+12−9
@@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.ModalBottomSheet
20import androidx.compose.material3.ModalBottomSheet
21import androidx.compose.material3.OutlinedTextField
21import androidx.compose.material3.OutlinedTextField
22import androidx.compose.material3.Text
22import androidx.compose.material3.Text
23+import androidx.compose.material3.TextButton
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Composable
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.mutableStateOf
@@ -67,15 +68,17 @@ fun AgentPickerSheet(
67 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
68 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
68 }
69 }
69 } else if (error && agents.isEmpty()) {
70 } else if (error && agents.isEmpty()) {
70− Text(
71+ Column(
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),
72+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
73+ horizontalAlignment = Alignment.CenterHorizontally,
78− )
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+ }
79 } else if (agents.isEmpty()) {
82 } else if (agents.isEmpty()) {
80 Text(
83 Text(
81 stringResource(R.string.no_agents),
84 stringResource(R.string.no_agents),
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+50−9
@@ -202,15 +202,32 @@ fun ChatScreen(
202 topBar = {
202 topBar = {
203 TopAppBar(
203 TopAppBar(
204 title = {
204 title = {
205+ val changeLabel = stringResource(R.string.change_model_agent)
206+ Row(
205− Column(modifier = Modifier
207+ modifier = Modifier
206− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
208+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
209+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
210+ verticalAlignment = Alignment.CenterVertically,
207 ) {
211 ) {
212+ Column {
208− Text(sessionTitle ?: sessionLabel)
213+ Text(sessionTitle ?: sessionLabel)
209− Text(
214+ Text(
210− subtitle,
215+ subtitle,
211− style = MaterialTheme.typography.labelSmall,
216+ style = MaterialTheme.typography.labelSmall,
212− color = MaterialTheme.colorScheme.onSurfaceVariant,
217+ color = MaterialTheme.colorScheme.onSurfaceVariant,
213− )
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+ }
214 }
231 }
215 },
232 },
216 navigationIcon = {
233 navigationIcon = {
@@ -221,6 +238,17 @@ fun ChatScreen(
221 }
238 }
222 },
239 },
223 actions = {
240 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+ }
224 IconButton(
252 IconButton(
225 onClick = {
253 onClick = {
226 scope.launch {
254 scope.launch {
@@ -335,7 +363,20 @@ fun ChatScreen(
335 .align(Alignment.Center)
363 .align(Alignment.Center)
336 .semantics { contentDescription = loadingLabel },
364 .semantics { contentDescription = loadingLabel },
337 )
365 )
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()) {
339 EmptyConversation(
380 EmptyConversation(
340 modifier = Modifier.align(Alignment.Center),
381 modifier = Modifier.align(Alignment.Center),
341 )
382 )
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+12−9
@@ -15,6 +15,7 @@ import androidx.compose.material3.Icon
15import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.ModalBottomSheet
16import androidx.compose.material3.ModalBottomSheet
17import androidx.compose.material3.Text
17import androidx.compose.material3.Text
18+import androidx.compose.material3.TextButton
18import androidx.compose.runtime.Composable
19import androidx.compose.runtime.Composable
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.Modifier
@@ -55,15 +56,17 @@ fun CommandPickerSheet(
55 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
56 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
56 }
57 }
57 } else if (error && commands.isEmpty()) {
58 } else if (error && commands.isEmpty()) {
58− Text(
59+ Column(
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),
60+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
61+ horizontalAlignment = Alignment.CenterHorizontally,
66− )
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+ }
67 } else if (commands.isEmpty()) {
70 } else if (commands.isEmpty()) {
68 Text(
71 Text(
69 stringResource(R.string.no_commands),
72 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
123 if (textToCopy != null) {
123 if (textToCopy != null) {
124 IconButton(
124 IconButton(
125 onClick = { copyToClipboard(context, "message", textToCopy) },
125 onClick = { copyToClipboard(context, "message", textToCopy) },
126− modifier = Modifier.size(28.dp),
127 ) {
126 ) {
128 Icon(
127 Icon(
129 Icons.Filled.ContentCopy,
128 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
20import androidx.compose.material3.ModalBottomSheet
20import androidx.compose.material3.ModalBottomSheet
21import androidx.compose.material3.OutlinedTextField
21import androidx.compose.material3.OutlinedTextField
22import androidx.compose.material3.Text
22import androidx.compose.material3.Text
23+import androidx.compose.material3.TextButton
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Composable
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.mutableStateOf
@@ -67,15 +68,17 @@ fun ModelPickerSheet(
67 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
68 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
68 }
69 }
69 } else if (error && options.isEmpty()) {
70 } else if (error && options.isEmpty()) {
70− Text(
71+ Column(
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),
72+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
73+ horizontalAlignment = Alignment.CenterHorizontally,
78− )
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+ }
79 } else if (options.isEmpty()) {
82 } else if (options.isEmpty()) {
80 Text(
83 Text(
81 stringResource(R.string.no_models),
84 stringResource(R.string.no_models),
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+16−6
@@ -37,6 +37,14 @@ fun ConnectionBanner(
37 EventStreamClient.ConnectionState.Connected -> null
37 EventStreamClient.ConnectionState.Connected -> null
38 }
38 }
39 if (text != null) {
39 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
40 Surface(
48 Surface(
41 modifier = modifier
49 modifier = modifier
42 .fillMaxWidth()
50 .fillMaxWidth()
@@ -46,21 +54,23 @@ fun ConnectionBanner(
46 liveRegion = LiveRegionMode.Polite
54 liveRegion = LiveRegionMode.Polite
47 contentDescription = text
55 contentDescription = text
48 },
56 },
49− color = MaterialTheme.colorScheme.tertiaryContainer,
57+ color = container,
50 ) {
58 ) {
51 Row(
59 Row(
52 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
60 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
53 horizontalArrangement = Arrangement.Center,
61 horizontalArrangement = Arrangement.Center,
54 verticalAlignment = Alignment.CenterVertically,
62 verticalAlignment = Alignment.CenterVertically,
55 ) {
63 ) {
64+ if (!isFailed) {
56− CircularProgressIndicator(
65+ CircularProgressIndicator(
57− Modifier.size(12.dp),
66+ Modifier.size(12.dp),
58− strokeWidth = 2.dp,
67+ strokeWidth = 2.dp,
59− )
68+ )
69+ }
60 Text(
70 Text(
61 text,
71 text,
62 style = MaterialTheme.typography.labelMedium,
72 style = MaterialTheme.typography.labelMedium,
63− color = MaterialTheme.colorScheme.onTertiaryContainer,
73+ color = onContainer,
64 modifier = Modifier.padding(start = 6.dp),
74 modifier = Modifier.padding(start = 6.dp),
65 )
75 )
66 }
76 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1
@@ -193,7 +193,7 @@ private fun DirectoryListing(
193 onUp: () -> Unit,
193 onUp: () -> Unit,
194 onOpenFile: (String) -> Unit,
194 onOpenFile: (String) -> Unit,
195) {
195) {
196− if (state.entries.isEmpty() && state.path.isBlank()) {
196+ if (state.entries.isEmpty()) {
197 Text(
197 Text(
198 stringResource(R.string.empty_folder),
198 stringResource(R.string.empty_folder),
199 modifier = Modifier.fillMaxWidth().padding(24.dp),
199 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(
93 )
93 )
94 OutlinedTextField(
94 OutlinedTextField(
95 value = state.baseUrl,
95 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) } },
97 label = { Text(stringResource(R.string.base_url)) },
97 label = { Text(stringResource(R.string.base_url)) },
98 placeholder = { Text(stringResource(R.string.base_url_hint)) },
98 placeholder = { Text(stringResource(R.string.base_url_hint)) },
99 singleLine = true,
99 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+ },
100 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
106 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
101 modifier = Modifier.fillMaxWidth().testTag("server_url"),
107 modifier = Modifier.fillMaxWidth().testTag("server_url"),
102 )
108 )
@@ -118,6 +124,17 @@ fun ServerEditScreen(
118 Text(stringResource(R.string.check_connectivity))
124 Text(stringResource(R.string.check_connectivity))
119 }
125 }
120 }
126 }
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+ }
121 AnimatedVisibility(visible = state.authFieldsVisible) {
138 AnimatedVisibility(visible = state.authFieldsVisible) {
122 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
139 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
123 Text(
140 Text(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+1−1
@@ -37,7 +37,7 @@ data class ServerEditState(
37 val isNew: Boolean get() = id == null
37 val isNew: Boolean get() = id == null
38}
38}
39
39
40−private fun isValidUrl(url: String): Boolean = try {
40+fun isValidUrl(url: String): Boolean = try {
41 val u = java.net.URI(url.trim())
41 val u = java.net.URI(url.trim())
42 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
42 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
43} catch (_: Exception) {
43} 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
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
17+import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.text.KeyboardOptions
19−import androidx.compose.foundation.lazy.items
20import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.Logout
21import androidx.compose.material.icons.automirrored.filled.Logout
22import androidx.compose.material.icons.filled.Add
22import androidx.compose.material.icons.filled.Add
@@ -43,10 +43,13 @@ import androidx.compose.material3.OutlinedTextField
43import androidx.compose.material3.Scaffold
43import androidx.compose.material3.Scaffold
44import androidx.compose.material3.SnackbarHost
44import androidx.compose.material3.SnackbarHost
45import androidx.compose.material3.SnackbarHostState
45import androidx.compose.material3.SnackbarHostState
46+import androidx.compose.material3.SwipeToDismissBox
47+import androidx.compose.material3.SwipeToDismissBoxValue
46import androidx.compose.material3.Text
48import androidx.compose.material3.Text
47import androidx.compose.material3.TextButton
49import androidx.compose.material3.TextButton
48import androidx.compose.material3.TopAppBar
50import androidx.compose.material3.TopAppBar
49import androidx.compose.material3.pulltorefresh.PullToRefreshBox
51import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52+import androidx.compose.material3.rememberSwipeToDismissBoxState
50import androidx.compose.runtime.Composable
53import androidx.compose.runtime.Composable
51import androidx.compose.runtime.CompositionLocalProvider
54import androidx.compose.runtime.CompositionLocalProvider
52import androidx.compose.runtime.LaunchedEffect
55import androidx.compose.runtime.LaunchedEffect
@@ -213,11 +216,19 @@ fun SessionListScreen(
213 .semantics { contentDescription = loadingLabel },
216 .semantics { contentDescription = loadingLabel },
214 )
217 )
215 }
218 }
216− state.sessions.isEmpty() && state.error != null -> Text(
219+ state.sessions.isEmpty() && state.error != null -> Column(
217− state.error ?: "",
218 modifier = Modifier.align(Alignment.Center).padding(24.dp),
220 modifier = Modifier.align(Alignment.Center).padding(24.dp),
221+ horizontalAlignment = Alignment.CenterHorizontally,
222+ ) {
223+ Text(
224+ state.error ?: "",
219− color = MaterialTheme.colorScheme.error,
225+ color = MaterialTheme.colorScheme.error,
220− )
226+ )
227+ Spacer(Modifier.size(12.dp))
228+ TextButton(onClick = { vm.refresh() }) {
229+ Text(stringResource(R.string.retry))
230+ }
231+ }
221 state.sessions.isEmpty() -> EmptySessions(
232 state.sessions.isEmpty() -> EmptySessions(
222 onCreate = { vm.createSession(onCreated = onOpenSession) },
233 onCreate = { vm.createSession(onCreated = onOpenSession) },
223 modifier = Modifier.align(Alignment.Center),
234 modifier = Modifier.align(Alignment.Center),
@@ -258,15 +269,46 @@ fun SessionListScreen(
258 verticalArrangement = Arrangement.spacedBy(12.dp),
269 verticalArrangement = Arrangement.spacedBy(12.dp),
259 ) {
270 ) {
260 items(sessions, key = { it.id }) { session ->
271 items(sessions, key = { it.id }) { session ->
261− SessionCard(
272+ // Swipe end-to-start reveals a delete affordance and opens the
262− session = session,
273+ // same confirmation dialog as the trash icon. We never commit
263− preview = state.previews[session.id],
274+ // the dismissal (always reset to Settled) so the card snaps back
264− unread = unread.contains(session.id),
275+ // and the dialog guards against accidental data loss.
265− onClick = { onOpenSession(session.id) },
276+ val swipeState = rememberSwipeToDismissBoxState(
266− onRename = { pendingRenameId = session.id },
277+ confirmValueChange = {
267− onDelete = { pendingDeleteId = session.id },
278+ pendingDeleteId = session.id
268− modifier = Modifier.testTag("session_card"),
279+ false
280+ },
269 )
281 )
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+ }
270 }
312 }
271 }
313 }
272 }
314 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+34−6
@@ -10,10 +10,13 @@ import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.heightIn
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
18+import androidx.compose.foundation.rememberScrollState
19+import androidx.compose.foundation.verticalScroll
17import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
19import androidx.compose.material.icons.filled.BugReport
22import androidx.compose.material.icons.filled.BugReport
@@ -67,6 +70,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
67 val logger = remember { CrashLogger.get(context) }
70 val logger = remember { CrashLogger.get(context) }
68 val reports by logger.reports.collectAsStateWithLifecycle()
71 val reports by logger.reports.collectAsStateWithLifecycle()
69 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
72 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
73+ var showClearAll by rememberSaveable { mutableStateOf(false) }
70 val shareScope = rememberCoroutineScope()
74 val shareScope = rememberCoroutineScope()
71 val shareLabel = stringResource(R.string.share)
75 val shareLabel = stringResource(R.string.share)
72 val timeTick = rememberRelativeTimeTick()
76 val timeTick = rememberRelativeTimeTick()
@@ -83,7 +87,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
83 actions = {
87 actions = {
84 if (reports.isNotEmpty()) {
88 if (reports.isNotEmpty()) {
85 IconButton(onClick = {
89 IconButton(onClick = {
86− logger.clearAll()
90+ showClearAll = true
87 }) {
91 }) {
88 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
92 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
89 }
93 }
@@ -200,11 +204,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
200 title = { Text(stringResource(R.string.crash_report)) },
204 title = { Text(stringResource(R.string.crash_report)) },
201 text = {
205 text = {
202 if (content != null) {
206 if (content != null) {
207+ Box(
208+ modifier = Modifier
209+ .fillMaxWidth()
210+ .heightIn(max = 400.dp)
211+ .verticalScroll(rememberScrollState()),
212+ ) {
203− Text(
213+ Text(
204− content,
214+ content,
205− style = MaterialTheme.typography.bodySmall,
215+ style = MaterialTheme.typography.bodySmall,
206− modifier = Modifier.fillMaxWidth(),
216+ modifier = Modifier.fillMaxWidth(),
207− )
217+ )
218+ }
208 } else {
219 } else {
209 val loadingLabel = stringResource(R.string.loading)
220 val loadingLabel = stringResource(R.string.loading)
210 Box(
221 Box(
@@ -217,4 +228,21 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
217 },
228 },
218 )
229 )
219 }
230 }
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+ }
220}
248}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−1
@@ -12,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.rememberScrollState
15import androidx.compose.foundation.selection.selectable
16import androidx.compose.foundation.selection.selectable
16import androidx.compose.foundation.selection.toggleable
17import androidx.compose.foundation.selection.toggleable
18+import androidx.compose.foundation.verticalScroll
17import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
19import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
21import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -85,7 +87,7 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
85 )
87 )
86 },
88 },
87 ) { padding ->
89 ) { 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)) {
89 Text(
91 Text(
90 stringResource(R.string.appearance),
92 stringResource(R.string.appearance),
91 style = MaterialTheme.typography.titleSmall,
93 style = MaterialTheme.typography.titleSmall,
app/src/main/res/values/strings.xml+3
@@ -54,6 +54,7 @@
54 <string name="label_optional">Label (optional)</string>
54 <string name="label_optional">Label (optional)</string>
55 <string name="base_url">Base URL</string>
55 <string name="base_url">Base URL</string>
56 <string name="base_url_hint">http://192.168.1.10:4096</string>
56 <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>
57 <string name="check_connectivity">Check connectivity</string>
58 <string name="check_connectivity">Check connectivity</string>
58 <string name="checking">Checking…</string>
59 <string name="checking">Checking…</string>
59 <string name="auth_required">This server requires authentication.</string>
60 <string name="auth_required">This server requires authentication.</string>
@@ -90,6 +91,7 @@
90 <string name="commands">Commands</string>
91 <string name="commands">Commands</string>
91 <string name="choose_agent">Choose agent</string>
92 <string name="choose_agent">Choose agent</string>
92 <string name="choose_model">Choose model</string>
93 <string name="choose_model">Choose model</string>
94+ <string name="change_model_agent">Change model and agent</string>
93 <string name="share_conversation">Share conversation</string>
95 <string name="share_conversation">Share conversation</string>
94
96
95 <!-- Errors (view models) -->
97 <!-- Errors (view models) -->
@@ -171,6 +173,7 @@
171 <string name="crash_reports">Crash reports</string>
173 <string name="crash_reports">Crash reports</string>
172 <string name="no_crash_reports">No crashes recorded. \ud83d\udc4d</string>
174 <string name="no_crash_reports">No crashes recorded. \ud83d\udc4d</string>
173 <string name="clear_all">Clear all</string>
175 <string name="clear_all">Clear all</string>
176+ <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
174 <string name="crash_report">Crash report</string>
177 <string name="crash_report">Crash report</string>
175
178
176 <!-- Notifications -->
179 <!-- Notifications -->