git.iko.soy/opencode-android
on master help sign in
Fix 35 UI/UX issues: permissions, file viewer, chat semantics, touch targets, search caching, accessibility
066cdediko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4
@@ -200,6 +200,10 @@ object NetworkConfig {
200 const val twoPaneWidthThresholdDp = 840
200 const val twoPaneWidthThresholdDp = 840
201 /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
201 /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
202 const val userBubbleWidthFraction = 0.85f
202 const val userBubbleWidthFraction = 0.85f
203+ /** Minimum number of options in a picker sheet (model/agent/command) before its search
204+ * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
205+ * eye, so popping the keyboard for it is just friction. */
206+ const val pickerSearchAutofocusThreshold = 8
203 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
207 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
204 * jump-to-latest FAB so it never floats over the last message. */
208 * jump-to-latest FAB so it never floats over the last message. */
205 const val chatListFabInsetDp = 80
209 const val chatListFabInsetDp = 80
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+17−2
@@ -7,12 +7,14 @@ import androidx.biometric.BiometricManager
7import androidx.biometric.BiometricPrompt
7import androidx.biometric.BiometricPrompt
8import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
10+import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.filled.Lock
15import androidx.compose.material.icons.filled.Lock
15import androidx.compose.material3.Button
16import androidx.compose.material3.Button
17+import androidx.compose.material3.CircularProgressIndicator
16import androidx.compose.material3.Icon
18import androidx.compose.material3.Icon
17import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.Surface
20import androidx.compose.material3.Surface
@@ -28,6 +30,9 @@ import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
29import androidx.compose.ui.platform.LocalContext
31import androidx.compose.ui.platform.LocalContext
30import androidx.compose.ui.res.stringResource
32import androidx.compose.ui.res.stringResource
33+import androidx.compose.ui.semantics.LiveRegionMode
34+import androidx.compose.ui.semantics.liveRegion
35+import androidx.compose.ui.semantics.semantics
31import androidx.compose.ui.unit.dp
36import androidx.compose.ui.unit.dp
32import androidx.core.content.ContextCompat
37import androidx.core.content.ContextCompat
33import androidx.fragment.app.FragmentActivity
38import androidx.fragment.app.FragmentActivity
@@ -206,20 +211,30 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
206 ) {
211 ) {
207 Icon(
212 Icon(
208 Icons.Filled.Lock,
213 Icons.Filled.Lock,
209− contentDescription = null,
214+ contentDescription = stringResource(R.string.app_locked),
210 modifier = Modifier.size(48.dp),
215 modifier = Modifier.size(48.dp),
211 tint = MaterialTheme.colorScheme.onSurfaceVariant,
216 tint = MaterialTheme.colorScheme.onSurfaceVariant,
212 )
217 )
213 Text(
218 Text(
214 stringResource(R.string.app_locked),
219 stringResource(R.string.app_locked),
215 style = MaterialTheme.typography.titleMedium,
220 style = MaterialTheme.typography.titleMedium,
216− modifier = Modifier.padding(top = 16.dp),
221+ modifier = Modifier
222+ .padding(top = 16.dp)
223+ .semantics { liveRegion = LiveRegionMode.Polite },
217 )
224 )
218 Button(
225 Button(
219 onClick = { authenticate() },
226 onClick = { authenticate() },
220 enabled = !authInFlight,
227 enabled = !authInFlight,
221 modifier = Modifier.padding(top = 24.dp),
228 modifier = Modifier.padding(top = 24.dp),
222 ) {
229 ) {
230+ if (authInFlight) {
231+ CircularProgressIndicator(
232+ modifier = Modifier.size(18.dp),
233+ strokeWidth = 2.dp,
234+ color = MaterialTheme.colorScheme.primary,
235+ )
236+ Spacer(Modifier.size(8.dp))
237+ }
223 Text(stringResource(R.string.app_unlock))
238 Text(stringResource(R.string.app_unlock))
224 }
239 }
225 }
240 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+5−1
@@ -332,7 +332,11 @@ fun OpencodeApp(container: AppContainer) {
332 }
332 }
333
333
334 composable(Routes.USAGE) {
334 composable(Routes.USAGE) {
335+ UsageScreen(
336+ container = container,
335− UsageScreen(container = container, onBack = { navController.popBackStack() })
337+ onBack = { navController.popBackStack() },
338+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
339+ )
336 }
340 }
337
341
338 composable(Routes.MCP) {
342 composable(Routes.MCP) {
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+8−2
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
49import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
50import androidx.compose.foundation.layout.Box
51import soy.iko.opencode.data.model.Agent
51import soy.iko.opencode.data.model.Agent
52+import soy.iko.opencode.data.network.NetworkConfig
52import soy.iko.opencode.R
53import soy.iko.opencode.R
53
54
54/**
55/**
@@ -122,9 +123,14 @@ fun AgentPickerSheet(
122 var query by rememberSaveable { mutableStateOf("") }
123 var query by rememberSaveable { mutableStateOf("") }
123 val keyboardController = LocalSoftwareKeyboardController.current
124 val keyboardController = LocalSoftwareKeyboardController.current
124 val haptics = LocalHapticFeedback.current
125 val haptics = LocalHapticFeedback.current
125− // Auto-focus the search field (and raise the keyboard) when the sheet opens.
126+ // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
127+ // it for a short catalog that's faster to scan by eye than to filter.
126 val searchFocus = remember { FocusRequester() }
128 val searchFocus = remember { FocusRequester() }
129+ LaunchedEffect(Unit) {
130+ if (agents.size > NetworkConfig.pickerSearchAutofocusThreshold) {
127− LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
131+ runCatching { searchFocus.requestFocus() }
132+ }
133+ }
128 OutlinedTextField(
134 OutlinedTextField(
129 value = query,
135 value = query,
130 onValueChange = { query = it },
136 onValueChange = { query = it },
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+73−22
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
44import androidx.compose.material.icons.filled.PhotoLibrary
44import androidx.compose.material.icons.filled.PhotoLibrary
45import androidx.compose.material.icons.filled.Refresh
45import androidx.compose.material.icons.filled.Refresh
46import androidx.compose.material.icons.filled.Restore
46import androidx.compose.material.icons.filled.Restore
47+import androidx.compose.material.icons.filled.Schedule
47import androidx.compose.material.icons.filled.Stop
48import androidx.compose.material.icons.filled.Stop
48import androidx.compose.material3.AlertDialog
49import androidx.compose.material3.AlertDialog
49import androidx.compose.material3.Badge
50import androidx.compose.material3.Badge
@@ -154,6 +155,7 @@ fun ChatScreen(
154 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
155 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
155 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
156 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
156 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
157 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
158+ val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
157 val agents by vm.agents.collectAsStateWithLifecycle()
159 val agents by vm.agents.collectAsStateWithLifecycle()
158 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
160 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
159 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
161 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
@@ -226,6 +228,10 @@ fun ChatScreen(
226 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
228 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
227 var showShellDialog by rememberSaveable { mutableStateOf(false) }
229 var showShellDialog by rememberSaveable { mutableStateOf(false) }
228 var showPalette by rememberSaveable { mutableStateOf(false) }
230 var showPalette by rememberSaveable { mutableStateOf(false) }
231+ // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
232+ // a confirmation dialog (from both the overflow menu and the command palette).
233+ var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
234+ var showInitConfirm by rememberSaveable { mutableStateOf(false) }
229
235
230 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
236 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
231 // action list doesn't need a Composable context. Actions map to the same handlers the
237 // action list doesn't need a Composable context. Actions map to the same handlers the
@@ -244,7 +250,7 @@ fun ChatScreen(
244 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
250 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
245 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
251 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
246 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
252 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
247− if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { vm.summarize() })
253+ if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
248 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
254 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
249 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
255 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
250 }
256 }
@@ -371,7 +377,7 @@ fun ChatScreen(
371 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
377 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
372 LaunchedEffect(Unit) {
378 LaunchedEffect(Unit) {
373 vm.shareLinkEvents.collect { url ->
379 vm.shareLinkEvents.collect { url ->
374− copyToClipboard(shareContext, "share", url)
380+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
375 snackbar.showSnackbar(linkCopiedMsg)
381 snackbar.showSnackbar(linkCopiedMsg)
376 }
382 }
377 }
383 }
@@ -436,9 +442,17 @@ fun ChatScreen(
436 // the overflow menu despite the title implying otherwise.
442 // the overflow menu despite the title implying otherwise.
437 Box {
443 Box {
438 Row(
444 Row(
445+ // mergeDescendants so TalkBack reads the title/subtitle Texts as one
446+ // node; the click label rides on `clickable` (onClickLabel) instead of
447+ // an explicit contentDescription, which would have SUPPRESSED the child
448+ // Texts and announced only "Change model and agent".
439 modifier = Modifier
449 modifier = Modifier
450+ .clickable(
440− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showTitleMenu = true }
451+ enabled = models.isNotEmpty(),
452+ role = Role.Button,
453+ onClickLabel = changeLabel,
454+ ) { showTitleMenu = true }
441− .semantics(mergeDescendants = true) { contentDescription = changeLabel },
455+ .semantics(mergeDescendants = true) {},
442 verticalAlignment = Alignment.CenterVertically,
456 verticalAlignment = Alignment.CenterVertically,
443 ) {
457 ) {
444 Column(modifier = Modifier.weight(1f, fill = false)) {
458 Column(modifier = Modifier.weight(1f, fill = false)) {
@@ -532,8 +546,6 @@ fun ChatScreen(
532 val loadingLabel = stringResource(R.string.loading)
546 val loadingLabel = stringResource(R.string.loading)
533 val shareLabel = stringResource(R.string.share_conversation)
547 val shareLabel = stringResource(R.string.share_conversation)
534 val commandsLabel = stringResource(R.string.commands)
548 val commandsLabel = stringResource(R.string.commands)
535− val agentLabel = stringResource(R.string.choose_agent)
536− val modelLabel = stringResource(R.string.choose_model)
537 val renameLabel = stringResource(R.string.rename_session_chat)
549 val renameLabel = stringResource(R.string.rename_session_chat)
538 val deleteLabel = stringResource(R.string.delete_session_chat)
550 val deleteLabel = stringResource(R.string.delete_session_chat)
539 val createShareLabel = stringResource(R.string.create_share_link)
551 val createShareLabel = stringResource(R.string.create_share_link)
@@ -574,16 +586,6 @@ fun ChatScreen(
574 enabled = !commandsLoading || commands.isNotEmpty(),
586 enabled = !commandsLoading || commands.isNotEmpty(),
575 onClick = { showOverflowMenu = false; showCommandPicker = true },
587 onClick = { showOverflowMenu = false; showCommandPicker = true },
576 )
588 )
577− DropdownMenuItem(
578− text = { Text(agentLabel) },
579− enabled = !agentsLoading || agents.isNotEmpty(),
580− onClick = { showOverflowMenu = false; showAgentPicker = true },
581− )
582− DropdownMenuItem(
583− text = { Text(modelLabel) },
584− enabled = !modelsLoading || models.isNotEmpty(),
585− onClick = { showOverflowMenu = false; showModelPicker = true },
586− )
587 androidx.compose.material3.HorizontalDivider()
589 androidx.compose.material3.HorizontalDivider()
588 // Session sharing: create a public link, copy an existing one, or revoke it.
590 // Session sharing: create a public link, copy an existing one, or revoke it.
589 if (shareUrl == null) {
591 if (shareUrl == null) {
@@ -598,7 +600,7 @@ fun ChatScreen(
598 onClick = {
600 onClick = {
599 showOverflowMenu = false
601 showOverflowMenu = false
600 shareUrl?.let {
602 shareUrl?.let {
601− copyToClipboard(shareContext, "share", it)
603+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
602 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
604 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
603 }
605 }
604 },
606 },
@@ -611,12 +613,12 @@ fun ChatScreen(
611 DropdownMenuItem(
613 DropdownMenuItem(
612 text = { Text(summarizeLabel) },
614 text = { Text(summarizeLabel) },
613 enabled = hasMessages && !running,
615 enabled = hasMessages && !running,
614− onClick = { showOverflowMenu = false; vm.summarize() },
616+ onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
615 )
617 )
616 DropdownMenuItem(
618 DropdownMenuItem(
617 text = { Text(initLabel) },
619 text = { Text(initLabel) },
618 enabled = !running,
620 enabled = !running,
619− onClick = { showOverflowMenu = false; vm.initProject() },
621+ onClick = { showOverflowMenu = false; showInitConfirm = true },
620 )
622 )
621 DropdownMenuItem(
623 DropdownMenuItem(
622 text = { Text(shellLabel) },
624 text = { Text(shellLabel) },
@@ -936,6 +938,10 @@ fun ChatScreen(
936 key = { it.key },
938 key = { it.key },
937 contentType = { it.contentType },
939 contentType = { it.contentType },
938 ) { item ->
940 ) { item ->
941+ // Default placement animation so inserted/moved rows glide in. Kept to the
942+ // default (no heavy fade spec) so a streaming message growing in place
943+ // doesn't animate on every token.
944+ Box(Modifier.animateItem()) {
939 when (item) {
945 when (item) {
940 is MessageListItem.Separator -> DateSeparator(item.label)
946 is MessageListItem.Separator -> DateSeparator(item.label)
941 is MessageListItem.Message -> {
947 is MessageListItem.Message -> {
@@ -978,6 +984,7 @@ fun ChatScreen(
978 )
984 )
979 }
985 }
980 }
986 }
987+ }
981 }
988 }
982 if (running) {
989 if (running) {
983 item(key = "__typing") {
990 item(key = "__typing") {
@@ -1021,7 +1028,9 @@ fun ChatScreen(
1021 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1028 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1022 }
1029 }
1023 },
1030 },
1031+ // contentDescription null: the visible "Latest" text already labels
1032+ // the FAB, so a description here makes TalkBack announce "Latest" twice.
1024− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
1033+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1025 text = { Text(stringResource(R.string.latest)) },
1034 text = { Text(stringResource(R.string.latest)) },
1026 )
1035 )
1027 }
1036 }
@@ -1069,6 +1078,40 @@ fun ChatScreen(
1069 )
1078 )
1070 }
1079 }
1071
1080
1081+ if (showSummarizeConfirm) {
1082+ AlertDialog(
1083+ onDismissRequest = { showSummarizeConfirm = false },
1084+ title = { Text(stringResource(R.string.summarize_confirm_title)) },
1085+ text = { Text(stringResource(R.string.summarize_confirm_text)) },
1086+ confirmButton = {
1087+ TextButton(onClick = {
1088+ showSummarizeConfirm = false
1089+ vm.summarize()
1090+ }) { Text(stringResource(R.string.action_continue)) }
1091+ },
1092+ dismissButton = {
1093+ TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1094+ },
1095+ )
1096+ }
1097+
1098+ if (showInitConfirm) {
1099+ AlertDialog(
1100+ onDismissRequest = { showInitConfirm = false },
1101+ title = { Text(stringResource(R.string.init_confirm_title)) },
1102+ text = { Text(stringResource(R.string.init_confirm_text)) },
1103+ confirmButton = {
1104+ TextButton(onClick = {
1105+ showInitConfirm = false
1106+ vm.initProject()
1107+ }) { Text(stringResource(R.string.action_continue)) }
1108+ },
1109+ dismissButton = {
1110+ TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
1111+ },
1112+ )
1113+ }
1114+
1072 if (showRenameDialog) {
1115 if (showRenameDialog) {
1073 RenameSessionChatDialog(
1116 RenameSessionChatDialog(
1074 initialTitle = sessionTitle ?: "",
1117 initialTitle = sessionTitle ?: "",
@@ -1146,6 +1189,8 @@ fun ChatScreen(
1146 pendingPermission?.let { permission ->
1189 pendingPermission?.let { permission ->
1147 PermissionDialog(
1190 PermissionDialog(
1148 permission = permission,
1191 permission = permission,
1192+ position = permissionProgress.position,
1193+ total = permissionProgress.total,
1149 onRespond = { response -> vm.respondPermission(permission, response) },
1194 onRespond = { response -> vm.respondPermission(permission, response) },
1150 )
1195 )
1151 }
1196 }
@@ -1272,7 +1317,11 @@ private fun ChatInputBar(
1272 false
1317 false
1273 }
1318 }
1274 },
1319 },
1320+ // While a run is active a typed message only queues (sends after the run), so
1321+ // the placeholder says so instead of the plain "Message…".
1322+ placeholder = {
1275− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1323+ Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
1324+ },
1276 enabled = enabled,
1325 enabled = enabled,
1277 maxLines = 6,
1326 maxLines = 6,
1278 // Show a "N / max" countdown once the draft crosses a high fraction of
1327 // Show a "N / max" countdown once the draft crosses a high fraction of
@@ -1373,8 +1422,10 @@ private fun ComposerTrailingButton(
1373 onClick = onQueue,
1422 onClick = onQueue,
1374 modifier = Modifier.testTag("queue_button"),
1423 modifier = Modifier.testTag("queue_button"),
1375 ) {
1424 ) {
1425+ // A clock/schedule icon (not Send) so it reads as "queue for later" and
1426+ // isn't confused with the send action sitting right beside the Stop button.
1376 Icon(
1427 Icon(
1377− Icons.AutoMirrored.Filled.Send,
1428+ Icons.Filled.Schedule,
1378 contentDescription = stringResource(R.string.queue_followup),
1429 contentDescription = stringResource(R.string.queue_followup),
1379 )
1430 )
1380 }
1431 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+36
@@ -69,6 +69,14 @@ import kotlinx.coroutines.withContext
69 */
69 */
70data class ChatError(val message: String, val retryable: Boolean = false)
70data class ChatError(val message: String, val retryable: Boolean = false)
71
71
72+/**
73+ * Progress of the current permission within a burst of requests. [total] is the max number
74+ * simultaneously pending since the queue was last empty; [position] advances as the user clears
75+ * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
76+ */
77+@androidx.compose.runtime.Immutable
78+data class PermissionProgress(val position: Int, val total: Int)
79+
72/**
80/**
73 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
81 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
74 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
82 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
@@ -298,6 +306,14 @@ class ChatViewModel(
298 private val _pendingPermission = MutableStateFlow<Permission?>(null)
306 private val _pendingPermission = MutableStateFlow<Permission?>(null)
299 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
307 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
300
308
309+ /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
310+ private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
311+ val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
312+
313+ /** Max number of permissions simultaneously pending since the queue was last empty. Reset
314+ * to 0 when the queue drains, so a fresh burst starts its count over. */
315+ private var maxPendingSeen = 0
316+
301 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
317 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
302 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
318 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
303 // request that arrived while another was in flight isn't silently dropped (its tool run would
319 // request that arrived while another was in flight isn't silently dropped (its tool run would
@@ -308,16 +324,36 @@ class ChatViewModel(
308 private fun enqueuePermission(permission: Permission) {
324 private fun enqueuePermission(permission: Permission) {
309 pendingPermissions[permission.id] = permission
325 pendingPermissions[permission.id] = permission
310 _pendingPermission.value = pendingPermissions.values.lastOrNull()
326 _pendingPermission.value = pendingPermissions.values.lastOrNull()
327+ updatePermissionProgress()
311 }
328 }
312
329
313 private fun resolvePermission(id: String) {
330 private fun resolvePermission(id: String) {
314 pendingPermissions.remove(id)
331 pendingPermissions.remove(id)
315 _pendingPermission.value = pendingPermissions.values.lastOrNull()
332 _pendingPermission.value = pendingPermissions.values.lastOrNull()
333+ updatePermissionProgress()
316 }
334 }
317
335
318 private fun clearPermissions() {
336 private fun clearPermissions() {
319 pendingPermissions.clear()
337 pendingPermissions.clear()
320 _pendingPermission.value = null
338 _pendingPermission.value = null
339+ updatePermissionProgress()
340+ }
341+
342+ /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
343+ * resets only when it drains, so position = total - remaining + 1 advances as requests are
344+ * answered. */
345+ private fun updatePermissionProgress() {
346+ val remaining = pendingPermissions.size
347+ if (remaining == 0) {
348+ maxPendingSeen = 0
349+ _permissionProgress.value = PermissionProgress(0, 0)
350+ return
351+ }
352+ if (remaining > maxPendingSeen) maxPendingSeen = remaining
353+ _permissionProgress.value = PermissionProgress(
354+ position = maxPendingSeen - remaining + 1,
355+ total = maxPendingSeen,
356+ )
321 }
357 }
322
358
323 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
359 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+8−2
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
47import androidx.compose.foundation.layout.Box
47import androidx.compose.foundation.layout.Box
48import soy.iko.opencode.data.model.Command
48import soy.iko.opencode.data.model.Command
49+import soy.iko.opencode.data.network.NetworkConfig
49import soy.iko.opencode.R
50import soy.iko.opencode.R
50
51
51/** Bottom sheet that lists slash-commands; selecting one invokes it. */
52/** Bottom sheet that lists slash-commands; selecting one invokes it. */
@@ -102,9 +103,14 @@ fun CommandPickerSheet(
102 var query by rememberSaveable { mutableStateOf("") }
103 var query by rememberSaveable { mutableStateOf("") }
103 val keyboardController = LocalSoftwareKeyboardController.current
104 val keyboardController = LocalSoftwareKeyboardController.current
104 val haptics = LocalHapticFeedback.current
105 val haptics = LocalHapticFeedback.current
105− // Auto-focus the search field (and raise the keyboard) when the sheet opens.
106+ // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
107+ // it for a short list that's faster to scan by eye than to filter.
106 val searchFocus = remember { FocusRequester() }
108 val searchFocus = remember { FocusRequester() }
109+ LaunchedEffect(Unit) {
110+ if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
107− LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
111+ runCatching { searchFocus.requestFocus() }
112+ }
113+ }
108 OutlinedTextField(
114 OutlinedTextField(
109 value = query,
115 value = query,
110 onValueChange = { query = it },
116 onValueChange = { query = it },
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+9−4
@@ -2,11 +2,13 @@ package soy.iko.opencode.ui.chat
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.BoxWithConstraints
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.layout.widthIn
10import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
13import androidx.compose.material.icons.automirrored.filled.HelpOutline
12import androidx.compose.material.icons.automirrored.filled.VolumeUp
14import androidx.compose.material.icons.automirrored.filled.VolumeUp
@@ -222,10 +224,13 @@ private fun UserBubble(
222 .joinToString("\n\n") { it.text }
224 .joinToString("\n\n") { it.text }
223 .takeIf { it.isNotBlank() }
225 .takeIf { it.isNotBlank() }
224 }
226 }
225− Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
227+ // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
228+ // snug instead of a wide empty box while long prompts still cap at the readable fraction.
229+ BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
226 Column(
230 Column(
227 modifier = Modifier
231 modifier = Modifier
232+ .align(Alignment.TopEnd)
228− .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
233+ .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
229 .clip(MaterialTheme.shapes.medium)
234 .clip(MaterialTheme.shapes.medium)
230 .background(MaterialTheme.colorScheme.primaryContainer)
235 .background(MaterialTheme.colorScheme.primaryContainer)
231 .padding(12.dp),
236 .padding(12.dp),
@@ -271,7 +276,7 @@ private fun UserBubble(
271 IconButton(
276 IconButton(
272 onClick = {
277 onClick = {
273 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
278 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
274− copyToClipboard(context, "message", textToCopy)
279+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
275 },
280 },
276 ) {
281 ) {
277 Icon(
282 Icon(
@@ -424,7 +429,7 @@ private fun AssistantActions(
424 if (textToCopy != null) {
429 if (textToCopy != null) {
425 IconButton(onClick = {
430 IconButton(onClick = {
426 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
431 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
427− copyToClipboard(context, "message", textToCopy)
432+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
428 }) {
433 }) {
429 Icon(
434 Icon(
430 Icons.Filled.ContentCopy,
435 Icons.Filled.ContentCopy,
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+8−2
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
49import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
50import androidx.compose.foundation.layout.Box
51import soy.iko.opencode.data.model.ModelOption
51import soy.iko.opencode.data.model.ModelOption
52+import soy.iko.opencode.data.network.NetworkConfig
52import soy.iko.opencode.R
53import soy.iko.opencode.R
53
54
54/**
55/**
@@ -124,9 +125,14 @@ fun ModelPickerSheet(
124 val keyboardController = LocalSoftwareKeyboardController.current
125 val keyboardController = LocalSoftwareKeyboardController.current
125 val haptics = LocalHapticFeedback.current
126 val haptics = LocalHapticFeedback.current
126 // Auto-focus the search field (and raise the keyboard) when the sheet opens so the
127 // Auto-focus the search field (and raise the keyboard) when the sheet opens so the
127− // user can start typing to filter a long catalog without an extra tap.
128+ // user can start typing to filter a long catalog without an extra tap. Skip it for a
129+ // short catalog that's faster to scan by eye than to filter.
128 val searchFocus = remember { FocusRequester() }
130 val searchFocus = remember { FocusRequester() }
131+ LaunchedEffect(Unit) {
132+ if (options.size > NetworkConfig.pickerSearchAutofocusThreshold) {
129− LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
133+ runCatching { searchFocus.requestFocus() }
134+ }
135+ }
130 OutlinedTextField(
136 OutlinedTextField(
131 value = query,
137 value = query,
132 onValueChange = { query = it },
138 onValueChange = { query = it },
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+25−9
@@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.Text
30import androidx.compose.material3.Text
31import androidx.compose.material3.TextButton
31import androidx.compose.material3.TextButton
32import androidx.compose.runtime.Composable
32import androidx.compose.runtime.Composable
33+import androidx.compose.runtime.LaunchedEffect
33import androidx.compose.runtime.getValue
34import androidx.compose.runtime.getValue
34import androidx.compose.runtime.mutableStateOf
35import androidx.compose.runtime.mutableStateOf
35import androidx.compose.runtime.remember
36import androidx.compose.runtime.remember
@@ -46,6 +47,7 @@ import androidx.compose.ui.semantics.contentDescription
46import androidx.compose.ui.semantics.semantics
47import androidx.compose.ui.semantics.semantics
47import androidx.compose.ui.semantics.stateDescription
48import androidx.compose.ui.semantics.stateDescription
48import androidx.compose.ui.text.font.FontFamily
49import androidx.compose.ui.text.font.FontFamily
50+import androidx.compose.ui.res.pluralStringResource
49import androidx.compose.ui.res.stringResource
51import androidx.compose.ui.res.stringResource
50import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
51import kotlinx.serialization.json.Json
53import kotlinx.serialization.json.Json
@@ -164,6 +166,15 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
164 // visible; once complete it collapses. A non-null override records an explicit user
166 // visible; once complete it collapses. A non-null override records an explicit user
165 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
167 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
166 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
168 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
169+ // Sticky expand: once a block has streamed this session, pin it open after streaming ends
170+ // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
171+ // stream finishes. A historical block that never streamed this session (streaming is false
172+ // from the first composition) never sets streamedThisSession, so it stays collapsed.
173+ var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
174+ LaunchedEffect(streaming) {
175+ if (streaming) streamedThisSession = true
176+ else if (streamedThisSession && userOverride == null) userOverride = true
177+ }
167 val expanded = userOverride ?: streaming
178 val expanded = userOverride ?: streaming
168 val expandedState = stringResource(R.string.state_expanded)
179 val expandedState = stringResource(R.string.state_expanded)
169 val collapsedState = stringResource(R.string.state_collapsed)
180 val collapsedState = stringResource(R.string.state_collapsed)
@@ -217,7 +228,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
217 TextButton(
228 TextButton(
218 onClick = {
229 onClick = {
219 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
230 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
220− copyToClipboard(context, "reasoning", text)
231+ copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
221 },
232 },
222 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
233 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
223 modifier = Modifier.semantics(mergeDescendants = true) {},
234 modifier = Modifier.semantics(mergeDescendants = true) {},
@@ -296,14 +307,19 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
296 else -> null
307 else -> null
297 }
308 }
298 if (detail != null) {
309 if (detail != null) {
310+ // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
311+ // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
312+ val isDiff = remember(detail) { looksLikeDiff(detail) }
299− val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
313+ val collapsed = remember(detail, isDiff) {
314+ val head = detail.take(COLLAPSED_LIMIT)
315+ // Truncating a diff mid-line makes DiffView render a malformed final line, so
316+ // trim back to the last complete line when the content is a diff.
317+ if (isDiff) head.substringBeforeLast('\n') else head
318+ }
300 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
319 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
301 val expandedState = stringResource(R.string.state_expanded)
320 val expandedState = stringResource(R.string.state_expanded)
302 val collapsedState = stringResource(R.string.state_collapsed)
321 val collapsedState = stringResource(R.string.state_collapsed)
303 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
322 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
304− // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
305− // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
306− val isDiff = remember(display) { looksLikeDiff(display) }
307 // How many lines are hidden while collapsed. detail.lines() splits the whole
323 // How many lines are hidden while collapsed. detail.lines() splits the whole
308 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
324 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
309 // every recomposition, including each streaming update of a running tool.
325 // every recomposition, including each streaming update of a running tool.
@@ -326,7 +342,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
326 collapsedState = collapsedState,
342 collapsedState = collapsedState,
327 onCopy = {
343 onCopy = {
328 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
344 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
329− copyToClipboard(context, "output", detail)
345+ copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
330 },
346 },
331 diffSaveKey = part.id,
347 diffSaveKey = part.id,
332 )
348 )
@@ -411,7 +427,7 @@ private fun CollapsibleDetail(
411 Text(
427 Text(
412 when {
428 when {
413 expanded -> stringResource(R.string.show_less)
429 expanded -> stringResource(R.string.show_less)
414− moreLines > 0 -> stringResource(R.string.show_more_lines, moreLines)
430+ moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
415 else -> stringResource(R.string.show_more)
431 else -> stringResource(R.string.show_more)
416 },
432 },
417 style = MaterialTheme.typography.labelSmall,
433 style = MaterialTheme.typography.labelSmall,
@@ -495,14 +511,14 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
495 opener?.invoke(toOpen)
511 opener?.invoke(toOpen)
496 } else if (!path.isNullOrBlank()) {
512 } else if (!path.isNullOrBlank()) {
497 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
513 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
498− copyToClipboard(context, "path", path)
514+ copyToClipboard(context, context.getString(R.string.clip_label_path), path)
499 showCopyToast(context, context.getString(R.string.path_copied))
515 showCopyToast(context, context.getString(R.string.path_copied))
500 }
516 }
501 },
517 },
502 onLongClick = {
518 onLongClick = {
503 if (!path.isNullOrBlank()) {
519 if (!path.isNullOrBlank()) {
504 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
520 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
505− copyToClipboard(context, "path", path)
521+ copyToClipboard(context, context.getString(R.string.clip_label_path), path)
506 showCopyToast(context, context.getString(R.string.path_copied))
522 showCopyToast(context, context.getString(R.string.path_copied))
507 }
523 }
508 },
524 },
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+41−10
@@ -4,7 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.rememberScrollState
10+import androidx.compose.foundation.text.selection.SelectionContainer
11+import androidx.compose.foundation.verticalScroll
8import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.filled.Shield
13import androidx.compose.material.icons.filled.Shield
10import androidx.compose.material3.AlertDialog
14import androidx.compose.material3.AlertDialog
@@ -37,6 +41,8 @@ import soy.iko.opencode.R
37fun PermissionDialog(
41fun PermissionDialog(
38 permission: Permission,
42 permission: Permission,
39 onRespond: (PermissionResponse) -> Unit,
43 onRespond: (PermissionResponse) -> Unit,
44+ position: Int = 0,
45+ total: Int = 0,
40) {
46) {
41 val haptics = LocalHapticFeedback.current
47 val haptics = LocalHapticFeedback.current
42 // Guard against double-respond: back press + button tap, or rapid double-tap,
48 // Guard against double-respond: back press + button tap, or rapid double-tap,
@@ -66,20 +72,45 @@ fun PermissionDialog(
66 onDismissRequest = { respond(PermissionResponse.REJECT) },
72 onDismissRequest = { respond(PermissionResponse.REJECT) },
67 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
73 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
68 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
74 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
69− title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
75+ title = {
70− text = {
71− Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
76+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
72− permission.type?.let {
77+ // A backlog indicator ("N of M") when more than one request is queued, so the
73− Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
78+ // user knows the newest dialog isn't the only pending decision.
74− }
79+ if (total > 1) {
75− permission.patternText?.let {
76 Text(
80 Text(
77− it,
81+ stringResource(R.string.permission_progress, position, total),
78− style = MaterialTheme.typography.bodyMedium,
82+ style = MaterialTheme.typography.labelMedium,
79− fontFamily = FontFamily.Monospace,
80 color = MaterialTheme.colorScheme.onSurfaceVariant,
83 color = MaterialTheme.colorScheme.onSurfaceVariant,
81 )
84 )
82 }
85 }
86+ Text(permission.title ?: stringResource(R.string.permission_title))
87+ }
88+ },
89+ text = {
90+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
91+ // The command / pattern can be long or multi-line; make it selectable and cap
92+ // its height with an inner scroll so it can't push the Allow/Reject buttons off
93+ // screen. Keeps the monospace styling.
94+ SelectionContainer {
95+ Column(
96+ modifier = Modifier
97+ .heightIn(max = 200.dp)
98+ .verticalScroll(rememberScrollState()),
99+ verticalArrangement = Arrangement.spacedBy(8.dp),
100+ ) {
101+ permission.type?.let {
102+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
103+ }
104+ permission.patternText?.let {
105+ Text(
106+ it,
107+ style = MaterialTheme.typography.bodyMedium,
108+ fontFamily = FontFamily.Monospace,
109+ color = MaterialTheme.colorScheme.onSurfaceVariant,
110+ )
111+ }
112+ }
113+ }
83 Text(
114 Text(
84 stringResource(R.string.permission_text),
115 stringResource(R.string.permission_text),
85 style = MaterialTheme.typography.bodySmall,
116 style = MaterialTheme.typography.bodySmall,
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+20−1
@@ -7,6 +7,13 @@ import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
9import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardActions
11+import androidx.compose.foundation.text.KeyboardOptions
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Close
14+import androidx.compose.material.icons.filled.Search
15+import androidx.compose.material3.Icon
16+import androidx.compose.material3.IconButton
10import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.OutlinedTextField
18import androidx.compose.material3.OutlinedTextField
12import androidx.compose.material3.Surface
19import androidx.compose.material3.Surface
@@ -25,6 +32,8 @@ import androidx.compose.ui.input.key.key
25import androidx.compose.ui.input.key.onPreviewKeyEvent
32import androidx.compose.ui.input.key.onPreviewKeyEvent
26import androidx.compose.ui.input.key.type
33import androidx.compose.ui.input.key.type
27import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.res.stringResource
35+import androidx.compose.ui.semantics.Role
36+import androidx.compose.ui.text.input.ImeAction
28import androidx.compose.ui.unit.dp
37import androidx.compose.ui.unit.dp
29import androidx.compose.ui.window.Dialog
38import androidx.compose.ui.window.Dialog
30import soy.iko.opencode.R
39import soy.iko.opencode.R
@@ -63,6 +72,16 @@ fun CommandPalette(
63 onValueChange = { query = it },
72 onValueChange = { query = it },
64 placeholder = { Text(stringResource(R.string.palette_hint)) },
73 placeholder = { Text(stringResource(R.string.palette_hint)) },
65 singleLine = true,
74 singleLine = true,
75+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
76+ trailingIcon = {
77+ if (query.isNotEmpty()) {
78+ IconButton(onClick = { query = "" }) {
79+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
80+ }
81+ }
82+ },
83+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
84+ keyboardActions = KeyboardActions(onGo = { filtered.firstOrNull()?.let { run(it) } }),
66 modifier = Modifier
85 modifier = Modifier
67 .fillMaxWidth()
86 .fillMaxWidth()
68 .focusRequester(focus)
87 .focusRequester(focus)
@@ -91,7 +110,7 @@ fun CommandPalette(
91 style = MaterialTheme.typography.bodyLarge,
110 style = MaterialTheme.typography.bodyLarge,
92 modifier = Modifier
111 modifier = Modifier
93 .fillMaxWidth()
112 .fillMaxWidth()
94− .clickable { run(action) }
113+ .clickable(role = Role.Button) { run(action) }
95 .padding(horizontal = 12.dp, vertical = 14.dp),
114 .padding(horizontal = 12.dp, vertical = 14.dp),
96 )
115 )
97 }
116 }
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+4−1
@@ -79,7 +79,10 @@ fun ConnectionBanner(
79 Surface(
79 Surface(
80 modifier = modifier
80 modifier = modifier
81 .fillMaxWidth()
81 .fillMaxWidth()
82+ // mergeDescendants so the spinner + label read as one TalkBack stop instead of
83+ // several; the Retry TextButton is its own merged node, so it stays separately
84+ // actionable.
82− .semantics {
85+ .semantics(mergeDescendants = true) {
83 // Announce connection state changes to TalkBack users so they're
86 // Announce connection state changes to TalkBack users so they're
84 // aware the stream dropped/reconnecting without visual cues.
87 // aware the stream dropped/reconnecting without visual cues.
85 liveRegion = LiveRegionMode.Polite
88 liveRegion = LiveRegionMode.Polite
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+10−5
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.Color
31import androidx.compose.ui.platform.LocalContext
31import androidx.compose.ui.platform.LocalContext
32import androidx.compose.ui.text.font.FontFamily
32import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.unit.dp
33import androidx.compose.ui.unit.dp
34+import androidx.compose.ui.res.pluralStringResource
34import androidx.compose.ui.res.stringResource
35import androidx.compose.ui.res.stringResource
35import soy.iko.opencode.R
36import soy.iko.opencode.R
36
37
@@ -150,7 +151,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
150 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
151 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
151 horizontalArrangement = Arrangement.End,
152 horizontalArrangement = Arrangement.End,
152 ) {
153 ) {
153− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
154+ IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
154 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
155 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
155 }
156 }
156 }
157 }
@@ -181,7 +182,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
181 line.text,
182 line.text,
182 style = MaterialTheme.typography.bodySmall,
183 style = MaterialTheme.typography.bodySmall,
183 fontFamily = FontFamily.Monospace,
184 fontFamily = FontFamily.Monospace,
184− color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
185+ color = MaterialTheme.colorScheme.onSurfaceVariant,
185 modifier = Modifier.padding(vertical = 1.dp),
186 modifier = Modifier.padding(vertical = 1.dp),
186 )
187 )
187 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
188 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
@@ -191,12 +192,16 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
191 }
192 }
192 }
193 }
193 }
194 }
194− if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
195+ if (lines.size > COLLAPSED_DIFF_LINES) {
196+ val hidden = lines.size - COLLAPSED_DIFF_LINES
195 TextButton(
197 TextButton(
196− onClick = { expanded = true },
198+ onClick = { expanded = !expanded },
197 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
199 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
198 ) {
200 ) {
199− Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
201+ Text(
202+ if (expanded) stringResource(R.string.show_less)
203+ else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
204+ )
200 }
205 }
201 }
206 }
202 Spacer(Modifier.height(10.dp))
207 Spacer(Modifier.height(10.dp))
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+4−5
@@ -210,23 +210,22 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
210 modifier = Modifier.weight(1f),
210 modifier = Modifier.weight(1f),
211 )
211 )
212 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
212 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
213− IconButton(onClick = { wrap = !wrap }, modifier = Modifier.size(36.dp)) {
213+ IconButton(onClick = { wrap = !wrap }) {
214 Icon(
214 Icon(
215 Icons.AutoMirrored.Filled.WrapText,
215 Icons.AutoMirrored.Filled.WrapText,
216 contentDescription = wrapLabel,
216 contentDescription = wrapLabel,
217− modifier = Modifier.size(16.dp),
217+ modifier = Modifier.size(18.dp),
218 tint = if (wrap) MaterialTheme.colorScheme.primary
218 tint = if (wrap) MaterialTheme.colorScheme.primary
219 else MaterialTheme.colorScheme.onSurfaceVariant,
219 else MaterialTheme.colorScheme.onSurfaceVariant,
220 )
220 )
221 }
221 }
222 IconButton(
222 IconButton(
223− onClick = { copyToClipboard(context, "code", code) },
223+ onClick = { copyToClipboard(context, context.getString(R.string.clip_label_code), code) },
224− modifier = Modifier.size(36.dp),
225 ) {
224 ) {
226 Icon(
225 Icon(
227 Icons.Filled.ContentCopy,
226 Icons.Filled.ContentCopy,
228 contentDescription = context.getString(R.string.copy),
227 contentDescription = context.getString(R.string.copy),
229− modifier = Modifier.size(14.dp),
228+ modifier = Modifier.size(18.dp),
230 tint = MaterialTheme.colorScheme.onSurfaceVariant,
229 tint = MaterialTheme.colorScheme.onSurfaceVariant,
231 )
230 )
232 }
231 }
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+37−2
@@ -1,20 +1,28 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import android.util.Base64
3import android.util.Base64
4+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.heightIn
9+import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
12import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Immutable
13import androidx.compose.runtime.Immutable
14+import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.produceState
16import androidx.compose.runtime.produceState
11import androidx.compose.runtime.remember
17import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
12import androidx.compose.ui.Alignment
19import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
14import androidx.compose.ui.draw.clip
21import androidx.compose.ui.draw.clip
15import androidx.compose.ui.layout.ContentScale
22import androidx.compose.ui.layout.ContentScale
16import androidx.compose.ui.platform.LocalContext
23import androidx.compose.ui.platform.LocalContext
17import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.res.stringResource
25+import androidx.compose.ui.semantics.Role
18import androidx.compose.ui.semantics.contentDescription
26import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
@@ -164,7 +172,10 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
164 ImageStatusBox { BrokenImageIcon() }
172 ImageStatusBox { BrokenImageIcon() }
165 return
173 return
166 }
174 }
175+ // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
176+ // the error state instead of being a permanent broken-image icon.
177+ var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
167− val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
178+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
168 ImageRequest.Builder(context)
179 ImageRequest.Builder(context)
169 .data(model)
180 .data(model)
170 .apply {
181 .apply {
@@ -194,10 +205,34 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
194 )
205 )
195 }
206 }
196 },
207 },
197− error = { ImageStatusBox { BrokenImageIcon() } },
208+ error = { ImageRetry(onRetry = { retryKey++ }) },
198 )
209 )
199}
210}
200
211
212+/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
213+ * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
214+@Composable
215+private fun ImageRetry(onRetry: () -> Unit) {
216+ val label = stringResource(R.string.tap_to_retry)
217+ Box(
218+ modifier = Modifier
219+ .fillMaxWidth()
220+ .heightIn(min = 120.dp)
221+ .clickable(role = Role.Button, onClick = onRetry),
222+ contentAlignment = Alignment.Center,
223+ ) {
224+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
225+ BrokenImageIcon()
226+ Text(
227+ label,
228+ style = MaterialTheme.typography.labelSmall,
229+ color = MaterialTheme.colorScheme.onSurfaceVariant,
230+ modifier = Modifier.padding(top = 4.dp),
231+ )
232+ }
233+ }
234+}
235+
201/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
236/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
202 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
237 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
203private val ImageResolving = Any()
238private val ImageResolving = Any()
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+87−22
@@ -32,6 +32,7 @@ import androidx.compose.material3.FilterChip
32import androidx.compose.material3.HorizontalDivider
32import androidx.compose.material3.HorizontalDivider
33import androidx.compose.material3.Icon
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
34import androidx.compose.material3.IconButton
35+import androidx.compose.material3.LinearProgressIndicator
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.OutlinedTextField
37import androidx.compose.material3.OutlinedTextField
37import androidx.compose.material3.Scaffold
38import androidx.compose.material3.Scaffold
@@ -45,6 +46,8 @@ import androidx.compose.runtime.getValue
45import androidx.compose.runtime.remember
46import androidx.compose.runtime.remember
46import androidx.compose.ui.Alignment
47import androidx.compose.ui.Alignment
47import androidx.compose.ui.Modifier
48import androidx.compose.ui.Modifier
49+import androidx.compose.ui.focus.FocusRequester
50+import androidx.compose.ui.focus.focusRequester
48import androidx.compose.ui.graphics.Color
51import androidx.compose.ui.graphics.Color
49import androidx.compose.ui.platform.LocalSoftwareKeyboardController
52import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.testTag
53import androidx.compose.ui.platform.testTag
@@ -60,6 +63,7 @@ import androidx.compose.ui.text.input.ImeAction
60import androidx.compose.ui.text.style.TextOverflow
63import androidx.compose.ui.text.style.TextOverflow
61import androidx.compose.ui.text.withStyle
64import androidx.compose.ui.text.withStyle
62import androidx.compose.ui.unit.dp
65import androidx.compose.ui.unit.dp
66+import androidx.compose.ui.res.pluralStringResource
63import androidx.compose.ui.res.stringResource
67import androidx.compose.ui.res.stringResource
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
69import androidx.lifecycle.viewmodel.compose.viewModel
@@ -70,6 +74,7 @@ import soy.iko.opencode.data.model.symbolKindLabel
70import soy.iko.opencode.di.AppContainer
74import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.R
75import soy.iko.opencode.R
72import soy.iko.opencode.ui.vmFactory
76import soy.iko.opencode.ui.vmFactory
77+import soy.iko.opencode.util.runCatchingCancellable
73
78
74@OptIn(ExperimentalMaterial3Api::class)
79@OptIn(ExperimentalMaterial3Api::class)
75@Composable
80@Composable
@@ -110,10 +115,18 @@ fun FileBrowserScreen(
110 modifier = Modifier.fillMaxWidth(),
115 modifier = Modifier.fillMaxWidth(),
111 )
116 )
112 val keyboardController = LocalSoftwareKeyboardController.current
117 val keyboardController = LocalSoftwareKeyboardController.current
118+ // Search is the primary reason to open this screen, so focus the field on first
119+ // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
120+ val searchFocus = remember { FocusRequester() }
121+ LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
113 OutlinedTextField(
122 OutlinedTextField(
114 value = state.query,
123 value = state.query,
115 onValueChange = vm::setQuery,
124 onValueChange = vm::setQuery,
125+ modifier = Modifier
126+ .fillMaxWidth()
116− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
127+ .padding(start = 12.dp, end = 12.dp, top = 12.dp)
128+ .focusRequester(searchFocus)
129+ .testTag("file_search"),
117 label = {
130 label = {
118 Text(
131 Text(
119 stringResource(
132 stringResource(
@@ -154,13 +167,14 @@ fun FileBrowserScreen(
154 modifier = Modifier.fillMaxSize(),
167 modifier = Modifier.fillMaxSize(),
155 ) {
168 ) {
156 Box(modifier = Modifier.fillMaxSize()) {
169 Box(modifier = Modifier.fillMaxSize()) {
170+ val loadingLabel = stringResource(R.string.loading)
157 when {
171 when {
158− state.loading || state.searching -> {
172+ // Full-screen spinner only for the initial directory load. An in-flight
159− val loadingLabel = stringResource(R.string.loading)
173+ // search keeps the previous results visible (a slim top bar below shows the
174+ // progress) instead of blanking the list behind a spinner on every keystroke.
160− CircularProgressIndicator(
175+ state.loading -> CircularProgressIndicator(
161− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
176+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
162− )
177+ )
163− }
164 state.error != null -> Column(
178 state.error != null -> Column(
165 modifier = Modifier.align(Alignment.Center).padding(24.dp),
179 modifier = Modifier.align(Alignment.Center).padding(24.dp),
166 horizontalAlignment = Alignment.CenterHorizontally,
180 horizontalAlignment = Alignment.CenterHorizontally,
@@ -180,9 +194,15 @@ fun FileBrowserScreen(
180 }
194 }
181 }
195 }
182 state.mode == SearchMode.TEXT ->
196 state.mode == SearchMode.TEXT ->
197+ TextResults(
198+ state.textResults,
199+ searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
183− TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
200+ ) { path, line -> onOpenFile(path, line) }
184 state.mode == SearchMode.SYMBOL ->
201 state.mode == SearchMode.SYMBOL ->
202+ SymbolResults(
203+ state.symbolResults,
204+ searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
185− SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
205+ ) { path, line -> onOpenFile(path, line) }
186 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
206 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
187 else -> DirectoryListing(
207 else -> DirectoryListing(
188 state = state,
208 state = state,
@@ -191,6 +211,16 @@ fun FileBrowserScreen(
191 onOpenFile = { onOpenFile(it, null) },
211 onOpenFile = { onOpenFile(it, null) },
192 )
212 )
193 }
213 }
214+ // Slim progress bar for an in-flight search; the results list stays visible
215+ // underneath so incremental typing doesn't flash an empty screen each keystroke.
216+ if (state.searching) {
217+ LinearProgressIndicator(
218+ modifier = Modifier
219+ .align(Alignment.TopCenter)
220+ .fillMaxWidth()
221+ .semantics { contentDescription = loadingLabel },
222+ )
223+ }
194 }
224 }
195 }
225 }
196 }
226 }
@@ -200,9 +230,13 @@ fun FileBrowserScreen(
200@Composable
230@Composable
201private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
231private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
202 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
232 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
233+ val scrollState = rememberScrollState()
234+ // Scroll to the deepest segment when the path changes so the current directory stays in
235+ // view instead of hiding off the right edge behind the earlier segments on a deep path.
236+ LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
203 Row(
237 Row(
204 modifier = modifier
238 modifier = modifier
205− .horizontalScroll(rememberScrollState())
239+ .horizontalScroll(scrollState)
206 .padding(horizontal = 16.dp, vertical = 8.dp),
240 .padding(horizontal = 16.dp, vertical = 8.dp),
207 verticalAlignment = Alignment.CenterVertically,
241 verticalAlignment = Alignment.CenterVertically,
208 ) {
242 ) {
@@ -268,18 +302,39 @@ private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit
268 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
302 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
269}
303}
270
304
305+/** Empty-state message for a content/symbol search: a "type to search" hint before anything
306+ * is typed, nothing while a search is in flight (the top progress bar covers that, so the
307+ * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
308+ * query genuinely returned nothing. */
309+@Composable
310+private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
311+ searching -> null
312+ queryBlank -> stringResource(hint)
313+ else -> stringResource(R.string.no_matches)
314+}
315+
271/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
316/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
272@Composable
317@Composable
273−private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
318+private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
274 if (results.isEmpty()) {
319 if (results.isEmpty()) {
320+ if (emptyMessage != null) {
275− EmptyFileState(
321+ EmptyFileState(
276− icon = Icons.Filled.Search,
322+ icon = Icons.Filled.Search,
277− message = stringResource(R.string.no_matches),
323+ message = emptyMessage,
278− modifier = Modifier.fillMaxWidth().padding(24.dp),
324+ modifier = Modifier.fillMaxWidth().padding(24.dp),
279− )
325+ )
326+ }
280 return
327 return
281 }
328 }
282 LazyColumn(modifier = Modifier.fillMaxSize()) {
329 LazyColumn(modifier = Modifier.fillMaxSize()) {
330+ item(key = "__count") {
331+ Text(
332+ pluralStringResource(R.plurals.file_search_results, results.size, results.size),
333+ style = MaterialTheme.typography.labelSmall,
334+ color = MaterialTheme.colorScheme.onSurfaceVariant,
335+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
336+ )
337+ }
283 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
338 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
284 val name = match.filePath.substringAfterLast('/')
339 val name = match.filePath.substringAfterLast('/')
285 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
340 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
@@ -331,16 +386,26 @@ private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit
331
386
332/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
387/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
333@Composable
388@Composable
334−private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
389+private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
335 if (results.isEmpty()) {
390 if (results.isEmpty()) {
391+ if (emptyMessage != null) {
336− EmptyFileState(
392+ EmptyFileState(
337− icon = Icons.Filled.Search,
393+ icon = Icons.Filled.Search,
338− message = stringResource(R.string.no_matches),
394+ message = emptyMessage,
339− modifier = Modifier.fillMaxWidth().padding(24.dp),
395+ modifier = Modifier.fillMaxWidth().padding(24.dp),
340− )
396+ )
397+ }
341 return
398 return
342 }
399 }
343 LazyColumn(modifier = Modifier.fillMaxSize()) {
400 LazyColumn(modifier = Modifier.fillMaxSize()) {
401+ item(key = "__count") {
402+ Text(
403+ pluralStringResource(R.plurals.file_search_results, results.size, results.size),
404+ style = MaterialTheme.typography.labelSmall,
405+ color = MaterialTheme.colorScheme.onSurfaceVariant,
406+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
407+ )
408+ }
344 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
409 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
345 val fileName = symbol.filePath.substringAfterLast('/')
410 val fileName = symbol.filePath.substringAfterLast('/')
346 Column(
411 Column(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+162−64
@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut
9import androidx.compose.animation.togetherWith
9import androidx.compose.animation.togetherWith
10import androidx.compose.foundation.background
10import androidx.compose.foundation.background
11import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.horizontalScroll
12+import androidx.compose.foundation.interaction.DragInteraction
12import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.BoxScope
14import androidx.compose.foundation.layout.BoxScope
14import androidx.compose.foundation.layout.Column
15import androidx.compose.foundation.layout.Column
@@ -35,10 +36,13 @@ import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.ContentCopy
36import androidx.compose.material.icons.filled.ContentCopy
36import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowUp
38import androidx.compose.material.icons.filled.KeyboardArrowUp
39+import androidx.compose.material.icons.filled.MoreVert
38import androidx.compose.material.icons.filled.Refresh
40import androidx.compose.material.icons.filled.Refresh
39import androidx.compose.material.icons.filled.Search
41import androidx.compose.material.icons.filled.Search
40import androidx.compose.material.icons.filled.Share
42import androidx.compose.material.icons.filled.Share
41import androidx.compose.material3.CircularProgressIndicator
43import androidx.compose.material3.CircularProgressIndicator
44+import androidx.compose.material3.DropdownMenu
45+import androidx.compose.material3.DropdownMenuItem
42import androidx.compose.material3.ExperimentalMaterial3Api
46import androidx.compose.material3.ExperimentalMaterial3Api
43import androidx.compose.material3.FilterChip
47import androidx.compose.material3.FilterChip
44import androidx.compose.material3.Icon
48import androidx.compose.material3.Icon
@@ -51,6 +55,7 @@ import androidx.compose.material3.TextButton
51import androidx.compose.material3.TopAppBar
55import androidx.compose.material3.TopAppBar
52import androidx.compose.runtime.Composable
56import androidx.compose.runtime.Composable
53import androidx.compose.runtime.LaunchedEffect
57import androidx.compose.runtime.LaunchedEffect
58+import androidx.compose.runtime.MutableState
54import androidx.compose.runtime.getValue
59import androidx.compose.runtime.getValue
55import androidx.compose.runtime.mutableIntStateOf
60import androidx.compose.runtime.mutableIntStateOf
56import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.mutableStateOf
@@ -85,8 +90,14 @@ import soy.iko.opencode.ui.vmFactory
85import soy.iko.opencode.util.runCatchingCancellable
90import soy.iko.opencode.util.runCatchingCancellable
86import kotlinx.coroutines.Dispatchers
91import kotlinx.coroutines.Dispatchers
87import kotlinx.coroutines.delay
92import kotlinx.coroutines.delay
93+import kotlinx.coroutines.flow.collect
88import kotlinx.coroutines.withContext
94import kotlinx.coroutines.withContext
89
95
96+/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
97+ * split once (not twice) into this many lines at most, so it can't hold two full line
98+ * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99+private const val MAX_RENDERED_LINES = 5000
100+
90@OptIn(ExperimentalMaterial3Api::class)
101@OptIn(ExperimentalMaterial3Api::class)
91@Composable
102@Composable
92fun FileViewScreen(
103fun FileViewScreen(
@@ -121,7 +132,23 @@ fun FileViewScreen(
121 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
132 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
122 val listState = rememberLazyListState()
133 val listState = rememberLazyListState()
123 val rawText = state.content?.content.orEmpty()
134 val rawText = state.content?.content.orEmpty()
135+ // Split the raw text exactly once here (the line viewer and find-in-file both consume this
136+ // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
124− val lines = remember(rawText) { rawText.split("\n") }
137+ val allLines = remember(rawText) { rawText.split("\n") }
138+ val truncated = allLines.size > MAX_RENDERED_LINES
139+ val lines = remember(allLines) {
140+ if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
141+ }
142+ // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
143+ // or starts an in-file find so the landed line stands out. Null once cleared.
144+ val highlightLine = rememberJumpToLineHighlight(
145+ path = path,
146+ initialLine = initialLine,
147+ content = state.content,
148+ isBinary = state.content?.isBinary == true,
149+ lineCount = lines.size,
150+ listState = listState,
151+ )
125 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
152 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
126 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
153 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
127 var debouncedFind by remember { mutableStateOf("") }
154 var debouncedFind by remember { mutableStateOf("") }
@@ -145,19 +172,6 @@ fun FileViewScreen(
145 LaunchedEffect(matchPos, matchIndices) {
172 LaunchedEffect(matchPos, matchIndices) {
146 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
173 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
147 }
174 }
148− // Opened at a specific line (from a search hit): scroll there once the raw content loads.
149− // One-shot so a later find-in-file navigation isn't overridden.
150− var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) }
151− LaunchedEffect(state.content, initialLine) {
152− val line = initialLine ?: return@LaunchedEffect
153− if (didInitialLineScroll) return@LaunchedEffect
154− val content = state.content ?: return@LaunchedEffect
155− if (content.isBinary) return@LaunchedEffect
156− val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
157− runCatchingCancellable { listState.scrollToItem(target) }
158− didInitialLineScroll = true
159− }
160−
161 Scaffold(
175 Scaffold(
162 topBar = {
176 topBar = {
163 FileViewTopBar(
177 FileViewTopBar(
@@ -165,6 +179,7 @@ fun FileViewScreen(
165 loading = state.loading,
179 loading = state.loading,
166 content = rawText,
180 content = rawText,
167 wrap = wrap,
181 wrap = wrap,
182+ diffShown = showToggle && showDiff,
168 onBack = onBack,
183 onBack = onBack,
169 onReload = { vm.reload() },
184 onReload = { vm.reload() },
170 // Find searches the raw line list, which isn't on screen under a diff. Rather
185 // Find searches the raw line list, which isn't on screen under a diff. Rather
@@ -222,13 +237,16 @@ fun FileViewScreen(
222 },
237 },
223 findActive = findActive,
238 findActive = findActive,
224 findQuery = findQuery,
239 findQuery = findQuery,
225− onQueryChange = { findQuery = it; matchPos = 0 },
240+ onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
226 matchIndices = matchIndices,
241 matchIndices = matchIndices,
227 matchPos = matchPos,
242 matchPos = matchPos,
228 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
243 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
229 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
244 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
230 onCloseFind = { findActive = false; findQuery = "" },
245 onCloseFind = { findActive = false; findQuery = "" },
231 wrap = wrap,
246 wrap = wrap,
247+ lines = lines,
248+ truncated = truncated,
249+ highlightLineIndex = highlightLine.value,
232 listState = listState,
250 listState = listState,
233 onRetry = { vm.reload() },
251 onRetry = { vm.reload() },
234 )
252 )
@@ -237,6 +255,37 @@ fun FileViewScreen(
237 }
255 }
238}
256}
239
257
258+// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
259+// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
260+// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
261+@Composable
262+private fun rememberJumpToLineHighlight(
263+ path: String,
264+ initialLine: Int?,
265+ content: Any?,
266+ isBinary: Boolean,
267+ lineCount: Int,
268+ listState: LazyListState,
269+): MutableState<Int?> {
270+ val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
271+ var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
272+ LaunchedEffect(content, initialLine) {
273+ val line = initialLine ?: return@LaunchedEffect
274+ if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
275+ val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
276+ runCatchingCancellable { listState.scrollToItem(target) }
277+ highlight.value = target
278+ didInitialScroll = true
279+ }
280+ LaunchedEffect(listState) {
281+ if (initialLine == null) return@LaunchedEffect
282+ listState.interactionSource.interactions.collect { interaction ->
283+ if (interaction is DragInteraction.Start) highlight.value = null
284+ }
285+ }
286+ return highlight
287+}
288+
240@OptIn(ExperimentalMaterial3Api::class)
289@OptIn(ExperimentalMaterial3Api::class)
241@Composable
290@Composable
242private fun FileViewTopBar(
291private fun FileViewTopBar(
@@ -244,6 +293,7 @@ private fun FileViewTopBar(
244 loading: Boolean,
293 loading: Boolean,
245 content: String,
294 content: String,
246 wrap: Boolean,
295 wrap: Boolean,
296+ diffShown: Boolean,
247 onBack: () -> Unit,
297 onBack: () -> Unit,
248 onReload: () -> Unit,
298 onReload: () -> Unit,
249 onToggleFind: () -> Unit,
299 onToggleFind: () -> Unit,
@@ -263,9 +313,13 @@ private fun FileViewTopBar(
263 // be editing it), so reload without requiring a back-out. Shows a spinner while
313 // be editing it), so reload without requiring a back-out. Shows a spinner while
264 // loading since the content now stays on screen during a reload (the spinner is
314 // loading since the content now stays on screen during a reload (the spinner is
265 // the only progress signal).
315 // the only progress signal).
316+ val loadingLabel = stringResource(R.string.loading)
266 IconButton(onClick = onReload, enabled = !loading) {
317 IconButton(onClick = onReload, enabled = !loading) {
267 if (loading) {
318 if (loading) {
268− CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
319+ CircularProgressIndicator(
320+ Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
321+ strokeWidth = 2.dp,
322+ )
269 } else {
323 } else {
270 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
324 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
271 }
325 }
@@ -278,19 +332,38 @@ private fun FileViewTopBar(
278 IconButton(onClick = onToggleFind) {
332 IconButton(onClick = onToggleFind) {
279 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
333 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
280 }
334 }
281− // Wrap long lines instead of horizontal-scrolling, useful for prose.
335+ // Overflow the less-frequent actions (wrap / copy / share) so a long filename
282− IconButton(onClick = onToggleWrap) {
336+ // title isn't crowded off screen by five inline icons on a phone.
283− Icon(
337+ var menuExpanded by remember { mutableStateOf(false) }
284− Icons.AutoMirrored.Filled.WrapText,
338+ IconButton(onClick = { menuExpanded = true }) {
285− contentDescription = stringResource(R.string.wrap_lines),
339+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
286− tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
287− )
288− }
289− IconButton(onClick = onCopy) {
290− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
291 }
340 }
292− IconButton(onClick = onShare) {
341+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
342+ // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
343+ // in diff mode because DiffView ignores wrap, so an active-looking toggle there
344+ // would be a silent no-op.
345+ DropdownMenuItem(
346+ text = { Text(stringResource(R.string.wrap_lines)) },
347+ onClick = { onToggleWrap(); menuExpanded = false },
348+ enabled = !diffShown,
349+ leadingIcon = {
350+ Icon(
351+ Icons.AutoMirrored.Filled.WrapText,
352+ contentDescription = null,
353+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
354+ )
355+ },
356+ )
357+ DropdownMenuItem(
358+ text = { Text(stringResource(R.string.copy)) },
359+ onClick = { onCopy(); menuExpanded = false },
360+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
361+ )
362+ DropdownMenuItem(
363+ text = { Text(stringResource(R.string.share)) },
364+ onClick = { onShare(); menuExpanded = false },
293− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
365+ leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
366+ )
294 }
367 }
295 }
368 }
296 },
369 },
@@ -313,6 +386,9 @@ private fun BoxScope.FileViewStateContent(
313 onNext: () -> Unit,
386 onNext: () -> Unit,
314 onCloseFind: () -> Unit,
387 onCloseFind: () -> Unit,
315 wrap: Boolean,
388 wrap: Boolean,
389+ lines: List<String>,
390+ truncated: Boolean,
391+ highlightLineIndex: Int?,
316 listState: LazyListState,
392 listState: LazyListState,
317 onRetry: () -> Unit,
393 onRetry: () -> Unit,
318) {
394) {
@@ -353,6 +429,9 @@ private fun BoxScope.FileViewStateContent(
353 onNext = onNext,
429 onNext = onNext,
354 onCloseFind = onCloseFind,
430 onCloseFind = onCloseFind,
355 wrap = wrap,
431 wrap = wrap,
432+ lines = lines,
433+ truncated = truncated,
434+ highlightLineIndex = highlightLineIndex,
356 listState = listState,
435 listState = listState,
357 )
436 )
358 }
437 }
@@ -374,6 +453,9 @@ private fun BoxScope.FileViewContentBody(
374 onNext: () -> Unit,
453 onNext: () -> Unit,
375 onCloseFind: () -> Unit,
454 onCloseFind: () -> Unit,
376 wrap: Boolean,
455 wrap: Boolean,
456+ lines: List<String>,
457+ truncated: Boolean,
458+ highlightLineIndex: Int?,
377 listState: LazyListState,
459 listState: LazyListState,
378) {
460) {
379 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
461 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
@@ -456,12 +538,14 @@ private fun BoxScope.FileViewContentBody(
456 )
538 )
457 } else {
539 } else {
458 FileTextContent(
540 FileTextContent(
459− text = text,
541+ lines = lines,
460 filename = filename,
542 filename = filename,
461 topInset = topInset,
543 topInset = topInset,
544+ truncated = truncated,
462 wrap = wrap,
545 wrap = wrap,
463 findQuery = findQuery,
546 findQuery = findQuery,
464 matchIndices = matchIndices,
547 matchIndices = matchIndices,
548+ highlightLineIndex = highlightLineIndex,
465 listState = listState,
549 listState = listState,
466 )
550 )
467 }
551 }
@@ -522,15 +606,16 @@ private fun FindBar(
522
606
523@Composable
607@Composable
524private fun FileTextContent(
608private fun FileTextContent(
525− text: String,
609+ lines: List<String>,
526 filename: String,
610 filename: String,
527 topInset: androidx.compose.ui.unit.Dp,
611 topInset: androidx.compose.ui.unit.Dp,
612+ truncated: Boolean,
528 wrap: Boolean,
613 wrap: Boolean,
529 findQuery: String,
614 findQuery: String,
530 matchIndices: List<Int>,
615 matchIndices: List<Int>,
616+ highlightLineIndex: Int?,
531 listState: LazyListState,
617 listState: LazyListState,
532) {
618) {
533− val lines = remember(text) { text.split("\n") }
534 // Gutter width scales with the digit count and the font scale so accessibility
619 // Gutter width scales with the digit count and the font scale so accessibility
535 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
620 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
536 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
621 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
@@ -545,39 +630,52 @@ private fun FileTextContent(
545 val syntax = remember(filename) { syntaxFor(filename) }
630 val syntax = remember(filename) { syntaxFor(filename) }
546 val q = findQuery.trim()
631 val q = findQuery.trim()
547 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
632 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
548− LazyColumn(
549− state = listState,
550− modifier = Modifier
551− .fillMaxSize()
552− .padding(top = topInset)
633+ Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
634+ if (truncated) {
635+ Text(
636+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
637+ style = MaterialTheme.typography.labelSmall,
638+ color = MaterialTheme.colorScheme.onSurfaceVariant,
639+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
640+ )
641+ }
642+ LazyColumn(
643+ state = listState,
644+ modifier = Modifier
645+ .weight(1f)
646+ .fillMaxWidth()
553− .padding(8.dp),
647+ .padding(8.dp),
554− ) {
648+ ) {
555− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
649+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
556− val isMatch = q.isNotEmpty() && index in matchSet
650+ val isMatch = q.isNotEmpty() && index in matchSet
651+ // The jump-to-line target keeps the same emphasis as a find match so the line
652+ // the viewer landed on is obvious until the user scrolls or starts a find.
653+ val isTarget = index == highlightLineIndex
557− Row(
654+ Row(
558− modifier = Modifier
655+ modifier = Modifier
559− .fillMaxWidth()
656+ .fillMaxWidth()
560− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
657+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
561− .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
658+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
562− ) {
659+ ) {
563− Text(
660+ Text(
564− "${index + 1}",
661+ "${index + 1}",
565− modifier = Modifier.width(gutterWidth),
662+ modifier = Modifier.width(gutterWidth),
566− style = MaterialTheme.typography.bodySmall,
663+ style = MaterialTheme.typography.bodySmall,
567− fontFamily = FontFamily.Monospace,
664+ fontFamily = FontFamily.Monospace,
568− color = MaterialTheme.colorScheme.onSurfaceVariant,
665+ color = MaterialTheme.colorScheme.onSurfaceVariant,
569− )
666+ )
570− // Heuristic syntax highlighting per line. Falls back to plain text for
667+ // Heuristic syntax highlighting per line. Falls back to plain text for
571− // unknown extensions, so non-code files render unchanged. Memoized so the
668+ // unknown extensions, so non-code files render unchanged. Memoized so the
572− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
669+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
573− // file, or palette actually changes — not on every recomposition (e.g. every
670+ // file, or palette actually changes — not on every recomposition (e.g. every
574− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
671+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
575− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
672+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
576− Text(
673+ Text(
577− highlighted,
674+ highlighted,
578− modifier = Modifier.padding(start = 8.dp),
675+ modifier = Modifier.padding(start = 8.dp),
579− style = MaterialTheme.typography.bodySmall,
676+ style = MaterialTheme.typography.bodySmall,
580− )
677+ )
678+ }
581 }
679 }
582 }
680 }
583 }
681 }
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+17−8
@@ -5,9 +5,11 @@ import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.PaddingValues
6import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.items
13import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
@@ -71,9 +73,16 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
71 Box(modifier = Modifier.fillMaxSize()) {
73 Box(modifier = Modifier.fillMaxSize()) {
72 val loadingLabel = stringResource(R.string.loading)
74 val loadingLabel = stringResource(R.string.loading)
73 when (val s = state) {
75 when (val s = state) {
74− is McpViewModel.State.Loading -> CircularProgressIndicator(
76+ is McpViewModel.State.Loading -> Column(
77+ modifier = Modifier.align(Alignment.Center),
78+ horizontalAlignment = Alignment.CenterHorizontally,
79+ ) {
80+ CircularProgressIndicator(
75− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
81+ Modifier.semantics { contentDescription = loadingLabel },
76− )
82+ )
83+ Spacer(Modifier.size(12.dp))
84+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
85+ }
77 is McpViewModel.State.Disconnected -> EmptyState(
86 is McpViewModel.State.Disconnected -> EmptyState(
78 icon = Icons.Filled.CloudOff,
87 icon = Icons.Filled.CloudOff,
79 title = stringResource(R.string.not_connected),
88 title = stringResource(R.string.not_connected),
@@ -130,15 +139,15 @@ private fun McpServerCard(server: McpServerInfo) {
130 )
139 )
131 }
140 }
132 }
141 }
133− val stateLabel = when {
142+ val stateLabel = if (server.enabled) {
134− !server.enabled -> stringResource(R.string.mcp_disabled)
143+ stringResource(R.string.mcp_enabled)
135− server.type != null -> server.type
144+ } else {
136− else -> stringResource(R.string.mcp_connected)
145+ stringResource(R.string.mcp_disabled)
137 }
146 }
138 Text(
147 Text(
139 stateLabel,
148 stateLabel,
140 style = MaterialTheme.typography.labelMedium,
149 style = MaterialTheme.typography.labelMedium,
141− color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error,
150+ color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
142 )
151 )
143 }
152 }
144 }
153 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+12−2
@@ -35,7 +35,9 @@ import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.focus.focusRequester
37import androidx.compose.ui.platform.LocalSoftwareKeyboardController
37import androidx.compose.ui.platform.LocalSoftwareKeyboardController
38+import androidx.compose.ui.res.pluralStringResource
38import androidx.compose.ui.res.stringResource
39import androidx.compose.ui.res.stringResource
40+import androidx.compose.ui.semantics.Role
39import androidx.compose.ui.semantics.contentDescription
41import androidx.compose.ui.semantics.contentDescription
40import androidx.compose.ui.semantics.semantics
42import androidx.compose.ui.semantics.semantics
41import androidx.compose.ui.text.AnnotatedString
43import androidx.compose.ui.text.AnnotatedString
@@ -117,6 +119,14 @@ fun GlobalSearchScreen(
117 title = stringResource(R.string.search_no_matches),
119 title = stringResource(R.string.search_no_matches),
118 modifier = Modifier.align(Alignment.Center),
120 modifier = Modifier.align(Alignment.Center),
119 )
121 )
122+ // Typed something but below the min length (no search run yet): nudge to keep
123+ // typing rather than reverting to the untouched start state, which reads as if
124+ // the field were empty.
125+ state.query.isNotBlank() && !state.hasSearched -> EmptyState(
126+ icon = Icons.Filled.Search,
127+ title = stringResource(R.string.search_keep_typing),
128+ modifier = Modifier.align(Alignment.Center),
129+ )
120 state.results.isEmpty() -> EmptyState(
130 state.results.isEmpty() -> EmptyState(
121 icon = Icons.Filled.Search,
131 icon = Icons.Filled.Search,
122 title = stringResource(R.string.search_all_start),
132 title = stringResource(R.string.search_all_start),
@@ -130,7 +140,7 @@ fun GlobalSearchScreen(
130 ) {
140 ) {
131 item(key = "__count") {
141 item(key = "__count") {
132 Text(
142 Text(
133− stringResource(R.string.search_results_count, state.results.size),
143+ pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
134 style = MaterialTheme.typography.labelMedium,
144 style = MaterialTheme.typography.labelMedium,
135 color = MaterialTheme.colorScheme.onSurfaceVariant,
145 color = MaterialTheme.colorScheme.onSurfaceVariant,
136 modifier = Modifier.padding(top = 8.dp),
146 modifier = Modifier.padding(top = 8.dp),
@@ -162,7 +172,7 @@ fun GlobalSearchScreen(
162
172
163@Composable
173@Composable
164private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
174private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
165− Card(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
175+ Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
166 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
176 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
167 Text(
177 Text(
168 hit.session.displayTitle,
178 hit.session.displayTitle,
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+30−7
@@ -1,14 +1,17 @@
1package soy.iko.opencode.ui.search
1package soy.iko.opencode.ui.search
2
2
3+import android.util.Log
3import androidx.compose.runtime.Immutable
4import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.R
7import soy.iko.opencode.R
8+import soy.iko.opencode.data.model.MessageWithParts
7import soy.iko.opencode.data.model.Session
9import soy.iko.opencode.data.model.Session
8import soy.iko.opencode.data.model.TextPart
10import soy.iko.opencode.data.model.TextPart
9import soy.iko.opencode.data.network.NetworkConfig
11import soy.iko.opencode.data.network.NetworkConfig
10import soy.iko.opencode.di.AppContainer
12import soy.iko.opencode.di.AppContainer
11import soy.iko.opencode.util.runCatchingCancellable
13import soy.iko.opencode.util.runCatchingCancellable
14+import soy.iko.opencode.util.safeExceptionSummary
12import kotlinx.coroutines.Job
15import kotlinx.coroutines.Job
13import kotlinx.coroutines.coroutineScope
16import kotlinx.coroutines.coroutineScope
14import kotlinx.coroutines.delay
17import kotlinx.coroutines.delay
@@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.update
19import kotlinx.coroutines.launch
22import kotlinx.coroutines.launch
20import kotlinx.coroutines.sync.Semaphore
23import kotlinx.coroutines.sync.Semaphore
21import kotlinx.coroutines.sync.withPermit
24import kotlinx.coroutines.sync.withPermit
25+import java.util.concurrent.ConcurrentHashMap
22
26
23/** One search result: the [session] that matched and a [snippet] of the matching text. */
27/** One search result: the [session] that matched and a [snippet] of the matching text. */
24@Immutable
28@Immutable
@@ -51,6 +55,14 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
51
55
52 private var searchJob: Job? = null
56 private var searchJob: Job? = null
53
57
58+ // In-memory caches for the ViewModel's lifetime so a growing/re-typed query re-filters
59+ // locally instead of re-downloading every session's history on each debounced keystroke.
60+ // Only successful fetches are cached (a failed one isn't, so a later query retries it); an
61+ // explicit retry() clears both to force a fresh fetch. ConcurrentHashMap because the
62+ // per-session fetches run concurrently under the semaphore.
63+ @Volatile private var sessionsCache: List<Session>? = null
64+ private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
65+
54 fun setQuery(query: String) {
66 fun setQuery(query: String) {
55 _state.update { it.copy(query = query) }
67 _state.update { it.copy(query = query) }
56 searchJob?.cancel()
68 searchJob?.cancel()
@@ -68,10 +80,13 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
68 }
80 }
69 }
81 }
70
82
71− /** Re-run the current query immediately (no debounce), used by the error-state Retry. */
83+ /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
84+ * the caches so the retry actually refetches (the prior attempt may have failed). */
72 fun retry() {
85 fun retry() {
73 val trimmed = _state.value.query.trim()
86 val trimmed = _state.value.query.trim()
74 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
87 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
88+ sessionsCache = null
89+ messageCache.clear()
75 searchJob?.cancel()
90 searchJob?.cancel()
76 _state.update { it.copy(searching = true, error = null) }
91 _state.update { it.copy(searching = true, error = null) }
77 searchJob = viewModelScope.launch { runSearch(trimmed) }
92 searchJob = viewModelScope.launch { runSearch(trimmed) }
@@ -84,10 +99,11 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
84 return
99 return
85 }
100 }
86 _state.update { it.copy(searching = true, error = null) }
101 _state.update { it.copy(searching = true, error = null) }
102+ val sessions = sessionsCache
87− val sessions = runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
103+ ?: runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
88− _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
104+ _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
89− return
105+ return
90− }
106+ }.also { sessionsCache = it }
91 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
107 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
92 val truncated = sessions.size > toSearch.size
108 val truncated = sessions.size > toSearch.size
93 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
109 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
@@ -96,8 +112,15 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
96 toSearch.forEach { session ->
112 toSearch.forEach { session ->
97 launch {
113 launch {
98 semaphore.withPermit {
114 semaphore.withPermit {
115+ // Reuse cached history when present so re-typing a query never re-fetches;
116+ // only sessions not yet cached hit the network.
117+ val messages = messageCache[session.id]
99− val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
118+ ?: runCatchingCancellable { conn.api.listMessages(session.id) }
119+ .onSuccess { messageCache[session.id] = it }
120+ .getOrElse {
121+ Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
100− .getOrDefault(emptyList())
122+ emptyList()
123+ }
101 val snippet = matchSnippet(messages, query)
124 val snippet = matchSnippet(messages, query)
102 ?: session.displayTitle.takeIf { it.contains(query, ignoreCase = true) }
125 ?: session.displayTitle.takeIf { it.contains(query, ignoreCase = true) }
103 if (snippet != null) hits[session.id] = SearchHit(session, snippet)
126 if (snippet != null) hits[session.id] = SearchHit(session, snippet)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+22−3
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.heightIn
12import androidx.compose.foundation.layout.imePadding
13import androidx.compose.foundation.layout.imePadding
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.size
@@ -39,6 +40,7 @@ import androidx.compose.material3.Text
39import androidx.compose.material3.TextButton
40import androidx.compose.material3.TextButton
40import androidx.compose.material3.TopAppBar
41import androidx.compose.material3.TopAppBar
41import androidx.compose.runtime.Composable
42import androidx.compose.runtime.Composable
43+import androidx.compose.runtime.LaunchedEffect
42import androidx.compose.runtime.getValue
44import androidx.compose.runtime.getValue
43import androidx.compose.runtime.mutableStateOf
45import androidx.compose.runtime.mutableStateOf
44import androidx.compose.runtime.remember
46import androidx.compose.runtime.remember
@@ -46,8 +48,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
46import androidx.compose.runtime.setValue
48import androidx.compose.runtime.setValue
47import androidx.compose.ui.Alignment
49import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
50import androidx.compose.ui.Modifier
51+import androidx.compose.ui.focus.FocusRequester
52+import androidx.compose.ui.focus.focusRequester
49import androidx.compose.ui.platform.LocalContext
53import androidx.compose.ui.platform.LocalContext
54+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.testTag
55import androidx.compose.ui.platform.testTag
56+import androidx.compose.ui.semantics.Role
51import androidx.compose.ui.semantics.contentDescription
57import androidx.compose.ui.semantics.contentDescription
52import androidx.compose.ui.semantics.semantics
58import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.input.ImeAction
59import androidx.compose.ui.text.input.ImeAction
@@ -160,6 +166,16 @@ private fun ServerEditForm(
160 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
166 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
161 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
167 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
162 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
168 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
169+ // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
170+ // user can start typing the required field immediately.
171+ val baseUrlFocus = remember { FocusRequester() }
172+ val keyboard = LocalSoftwareKeyboardController.current
173+ LaunchedEffect(Unit) {
174+ if (state.isNew && state.baseUrl.isBlank()) {
175+ baseUrlFocus.requestFocus()
176+ keyboard?.show()
177+ }
178+ }
163 Column(
179 Column(
164 modifier = Modifier
180 modifier = Modifier
165 .padding(padding)
181 .padding(padding)
@@ -169,7 +185,8 @@ private fun ServerEditForm(
169 .verticalScroll(rememberScrollState()),
185 .verticalScroll(rememberScrollState()),
170 verticalArrangement = Arrangement.spacedBy(12.dp),
186 verticalArrangement = Arrangement.spacedBy(12.dp),
171 ) {
187 ) {
172− // Base URL is the only required field, so it leads the form and takes focus.
188+ // Base URL is the only required field, so it leads the form and takes focus on a
189+ // fresh add form (via baseUrlFocus above).
173 OutlinedTextField(
190 OutlinedTextField(
174 value = state.baseUrl,
191 value = state.baseUrl,
175 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
192 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
@@ -195,7 +212,7 @@ private fun ServerEditForm(
195 }
212 }
196 },
213 },
197 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
214 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
198− modifier = Modifier.fillMaxWidth().testTag("server_url"),
215+ modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
199 )
216 )
200 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
217 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
201 // server is the primary path; on edit the populated URL keeps it (and its multicast)
218 // server is the primary path; on edit the populated URL keeps it (and its multicast)
@@ -460,8 +477,10 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
460 Column(
477 Column(
461 modifier = Modifier
478 modifier = Modifier
462 .fillMaxWidth()
479 .fillMaxWidth()
480+ .heightIn(min = 48.dp)
463− .clickable { onPick(server.baseUrl) }
481+ .clickable(role = Role.Button) { onPick(server.baseUrl) }
464 .padding(vertical = 8.dp),
482 .padding(vertical = 8.dp),
483+ verticalArrangement = Arrangement.Center,
465 ) {
484 ) {
466 Text(server.name, style = MaterialTheme.typography.bodyMedium)
485 Text(server.name, style = MaterialTheme.typography.bodyMedium)
467 Text(
486 Text(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+8−2
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Dns
20import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.MoreVert
21import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material3.AlertDialog
22import androidx.compose.material3.AlertDialog
23+import androidx.compose.material3.Button
23import androidx.compose.material3.Card
24import androidx.compose.material3.Card
24import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.DropdownMenu
26import androidx.compose.material3.DropdownMenu
@@ -173,7 +174,12 @@ fun ServerListScreen(
173 // so the list isn't double-padded at the top.
174 // so the list isn't double-padded at the top.
174 Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
175 Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
175 if (reconnecting && profiles.isNotEmpty()) {
176 if (reconnecting && profiles.isNotEmpty()) {
177+ val reconnectingLabel = stringResource(R.string.reconnecting)
176− LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
178+ LinearProgressIndicator(
179+ modifier = Modifier
180+ .fillMaxWidth()
181+ .semantics { contentDescription = reconnectingLabel },
182+ )
177 }
183 }
178 if (profiles.isEmpty()) {
184 if (profiles.isEmpty()) {
179 EmptyServers(
185 EmptyServers(
@@ -460,7 +466,7 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
460 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 color = MaterialTheme.colorScheme.onSurfaceVariant,
461 )
467 )
462 Spacer(Modifier.size(20.dp))
468 Spacer(Modifier.size(20.dp))
463− TextButton(onClick = onAdd) {
469+ Button(onClick = onAdd) {
464 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
470 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
465 Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
471 Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
466 }
472 }
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+9
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.rememberScrollState
10import androidx.compose.foundation.rememberScrollState
11import androidx.compose.foundation.selection.selectable
11import androidx.compose.foundation.selection.selectable
12+import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.verticalScroll
14import androidx.compose.foundation.verticalScroll
14import androidx.compose.material3.AlertDialog
15import androidx.compose.material3.AlertDialog
@@ -28,6 +29,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
28import androidx.compose.runtime.setValue
29import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.Modifier
32+import androidx.compose.ui.focus.FocusRequester
33+import androidx.compose.ui.focus.focusRequester
31import androidx.compose.ui.semantics.Role
34import androidx.compose.ui.semantics.Role
32import androidx.compose.ui.text.font.FontFamily
35import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.text.input.ImeAction
36import androidx.compose.ui.text.input.ImeAction
@@ -166,16 +169,22 @@ fun NewSessionDialog(
166 onSelect = { choice = DirChoice.Custom },
169 onSelect = { choice = DirChoice.Custom },
167 )
170 )
168 if (choice is DirChoice.Custom) {
171 if (choice is DirChoice.Custom) {
172+ // Autofocus the path field the moment the Custom row is chosen so the
173+ // keyboard is ready without a second tap.
174+ val customFocus = remember { FocusRequester() }
175+ LaunchedEffect(Unit) { customFocus.requestFocus() }
169 OutlinedTextField(
176 OutlinedTextField(
170 value = customText,
177 value = customText,
171 onValueChange = { customText = it },
178 onValueChange = { customText = it },
172 modifier = Modifier
179 modifier = Modifier
173 .fillMaxWidth()
180 .fillMaxWidth()
181+ .focusRequester(customFocus)
174 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
182 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
175 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
183 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
176 singleLine = true,
184 singleLine = true,
177 enabled = !creating,
185 enabled = !creating,
178 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
186 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
187+ keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved) }),
179 )
188 )
180 }
189 }
181
190
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−8
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.heightIn
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.LazyColumn
@@ -71,13 +72,17 @@ import androidx.compose.ui.Alignment
71import androidx.compose.ui.Modifier
72import androidx.compose.ui.Modifier
72import androidx.compose.ui.draw.alpha
73import androidx.compose.ui.draw.alpha
73import androidx.compose.ui.draw.clip
74import androidx.compose.ui.draw.clip
75+import androidx.compose.ui.focus.FocusRequester
76+import androidx.compose.ui.focus.focusRequester
74import androidx.compose.ui.platform.LocalHapticFeedback
77import androidx.compose.ui.platform.LocalHapticFeedback
75import androidx.compose.ui.platform.LocalSoftwareKeyboardController
78import androidx.compose.ui.platform.LocalSoftwareKeyboardController
76import androidx.compose.ui.platform.testTag
79import androidx.compose.ui.platform.testTag
77import androidx.compose.ui.semantics.Role
80import androidx.compose.ui.semantics.Role
78import androidx.compose.ui.semantics.contentDescription
81import androidx.compose.ui.semantics.contentDescription
79import androidx.compose.ui.semantics.semantics
82import androidx.compose.ui.semantics.semantics
83+import androidx.compose.ui.text.TextRange
80import androidx.compose.ui.text.input.ImeAction
84import androidx.compose.ui.text.input.ImeAction
85+import androidx.compose.ui.text.input.TextFieldValue
81import androidx.compose.ui.text.style.TextOverflow
86import androidx.compose.ui.text.style.TextOverflow
82import androidx.compose.ui.unit.dp
87import androidx.compose.ui.unit.dp
83import androidx.compose.ui.res.stringResource
88import androidx.compose.ui.res.stringResource
@@ -113,6 +118,9 @@ fun SessionListScreen(
113 onOpenSettings: () -> Unit,
118 onOpenSettings: () -> Unit,
114 onAddServer: () -> Unit,
119 onAddServer: () -> Unit,
115 onOpenSearch: () -> Unit = {},
120 onOpenSearch: () -> Unit = {},
121+ // Incremented by a host (the two-pane empty-detail pane) to open the new-session
122+ // directory picker, so that pane shares this screen's dialog instead of bypassing it.
123+ externalNewSessionTrigger: Int = 0,
116 selectedSessionId: String? = null,
124 selectedSessionId: String? = null,
117) {
125) {
118 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
126 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
@@ -145,6 +153,11 @@ fun SessionListScreen(
145 val openNewSession = {
153 val openNewSession = {
146 if (!creating) { vm.loadDirectoryOptions(); showNewSessionDialog = true }
154 if (!creating) { vm.loadDirectoryOptions(); showNewSessionDialog = true }
147 }
155 }
156+ // A host bumps externalNewSessionTrigger to open this exact directory-picker dialog
157+ // (e.g. the two-pane detail pane's "New session" button), reusing one dialog path.
158+ LaunchedEffect(externalNewSessionTrigger) {
159+ if (externalNewSessionTrigger > 0) openNewSession()
160+ }
148 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
161 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
149 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
162 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
150 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
163 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
@@ -161,6 +174,13 @@ fun SessionListScreen(
161 BackHandler(enabled = showServerMenu) { showServerMenu = false }
174 BackHandler(enabled = showServerMenu) { showServerMenu = false }
162 BackHandler(enabled = showSortMenu) { showSortMenu = false }
175 BackHandler(enabled = showSortMenu) { showSortMenu = false }
163
176
177+ // Dismiss the server switcher once an in-flight switch resolves (switchingId returns to
178+ // null), so its per-row spinner stays visible for the whole switch instead of the menu
179+ // closing the instant a profile is tapped.
180+ LaunchedEffect(switchingId) {
181+ if (switchingId == null && showServerMenu) showServerMenu = false
182+ }
183+
164 LaunchedEffect(Unit) {
184 LaunchedEffect(Unit) {
165 vm.transientErrors.collect { msg ->
185 vm.transientErrors.collect { msg ->
166 snackbar.showSnackbar(msg)
186 snackbar.showSnackbar(msg)
@@ -222,8 +242,12 @@ fun SessionListScreen(
222 onExpand = { showServerMenu = true },
242 onExpand = { showServerMenu = true },
223 onDismiss = { showServerMenu = false },
243 onDismiss = { showServerMenu = false },
224 onSelect = { profile ->
244 onSelect = { profile ->
245+ // Keep the switcher open so its per-row spinner (driven by
246+ // switchingId) is visible during the switch; the LaunchedEffect
247+ // above dismisses it once the switch resolves. Tapping the
248+ // already-active server is a no-op, so just close the menu.
225− showServerMenu = false
249+ if (profile.id == connectedId) showServerMenu = false
226− vm.switchServer(profile)
250+ else vm.switchServer(profile)
227 },
251 },
228 onAddServer = {
252 onAddServer = {
229 showServerMenu = false
253 showServerMenu = false
@@ -689,14 +713,31 @@ private fun RenameSessionDialog(
689 onConfirm: () -> Unit,
713 onConfirm: () -> Unit,
690 onDismiss: () -> Unit,
714 onDismiss: () -> Unit,
691) {
715) {
716+ val focusRequester = remember { FocusRequester() }
717+ // Drive the field with a TextFieldValue seeded with the whole title selected so the
718+ // dialog opens with the text highlighted for immediate overtyping; edits sync back to
719+ // the hoisted title (capped) that gates the confirm button and the Done action.
720+ var fieldValue by remember {
721+ mutableStateOf(TextFieldValue(title, selection = TextRange(0, title.length)))
722+ }
723+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
692 AlertDialog(
724 AlertDialog(
693 onDismissRequest = onDismiss,
725 onDismissRequest = onDismiss,
694 title = { Text(stringResource(R.string.rename_session)) },
726 title = { Text(stringResource(R.string.rename_session)) },
695 text = {
727 text = {
696 OutlinedTextField(
728 OutlinedTextField(
697− value = title,
729+ value = fieldValue,
730+ onValueChange = { v ->
731+ val capped = if (v.text.length > NetworkConfig.maxSessionTitleChars) {
698− onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
732+ val t = v.text.take(NetworkConfig.maxSessionTitleChars)
733+ TextFieldValue(t, selection = TextRange(t.length))
734+ } else {
735+ v
736+ }
737+ fieldValue = capped
738+ onTitleChange(capped.text)
739+ },
699− modifier = Modifier.fillMaxWidth(),
740+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
700 placeholder = { Text(stringResource(R.string.session_title_hint)) },
741 placeholder = { Text(stringResource(R.string.session_title_hint)) },
701 label = { Text(stringResource(R.string.rename_session)) },
742 label = { Text(stringResource(R.string.rename_session)) },
702 singleLine = true,
743 singleLine = true,
@@ -742,7 +783,10 @@ private fun SessionCard(
742 // Mute an archived row (unless it's the open one) so it reads as backgrounded
783 // Mute an archived row (unless it's the open one) so it reads as backgrounded
743 // when "show archived" surfaces it alongside active sessions.
784 // when "show archived" surfaces it alongside active sessions.
744 .then(if (isArchived && !isSelected) Modifier.alpha(0.6f) else Modifier)
785 .then(if (isArchived && !isSelected) Modifier.alpha(0.6f) else Modifier)
786+ // Merge the row's text into one semantics node so TalkBack reads it as a single
787+ // item; the overflow menu button stays its own actionable node.
788+ .semantics(mergeDescendants = true) {}
745− .clickable { onClick() },
789+ .clickable(role = Role.Button) { onClick() },
746 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
790 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
747 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
791 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
748 ) {
792 ) {
@@ -778,7 +822,8 @@ private fun SessionCard(
778 contentAlignment = Alignment.Center,
822 contentAlignment = Alignment.Center,
779 ) {
823 ) {
780 Text(
824 Text(
825+ if (unreadCount > 99) stringResource(R.string.count_overflow)
781− unreadCount.toString(),
826+ else unreadCount.toString(),
782 style = MaterialTheme.typography.labelSmall,
827 style = MaterialTheme.typography.labelSmall,
783 color = MaterialTheme.colorScheme.onPrimary,
828 color = MaterialTheme.colorScheme.onPrimary,
784 )
829 )
@@ -823,7 +868,7 @@ private fun SessionCard(
823 verticalAlignment = Alignment.CenterVertically,
868 verticalAlignment = Alignment.CenterVertically,
824 modifier = Modifier
869 modifier = Modifier
825 .padding(top = 2.dp)
870 .padding(top = 2.dp)
826− .semantics { contentDescription = dirDesc },
871+ .semantics(mergeDescendants = true) { contentDescription = dirDesc },
827 ) {
872 ) {
828 Icon(
873 Icon(
829 Icons.Filled.Folder,
874 Icons.Filled.Folder,
@@ -1033,6 +1078,7 @@ private fun ServerSwitcherMenu(
1033 Box {
1078 Box {
1034 Row(
1079 Row(
1035 modifier = Modifier
1080 modifier = Modifier
1081+ .heightIn(min = 48.dp)
1036 .clickable(role = Role.Button) { onExpand() },
1082 .clickable(role = Role.Button) { onExpand() },
1037 verticalAlignment = Alignment.CenterVertically,
1083 verticalAlignment = Alignment.CenterVertically,
1038 ) {
1084 ) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−1
@@ -754,7 +754,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
754 val previousProfile = container.activeConnection.value?.profile
754 val previousProfile = container.activeConnection.value?.profile
755 val savedUnread = container.unread.value
755 val savedUnread = container.unread.value
756 _switchingId.value = profile.id
756 _switchingId.value = profile.id
757+ // Don't flip `loading` here: that would blank the currently-populated list behind a
758+ // full-screen spinner. The switch feedback is the per-row spinner in the server
759+ // switcher (driven by switchingId); the existing list stays visible until refresh()
760+ // replaces it once the new connection lands.
757− _state.update { it.copy(loading = true, error = null) }
761+ _state.update { it.copy(error = null) }
758 viewModelScope.launch {
762 viewModelScope.launch {
759 val result = runCatchingCancellable {
763 val result = runCatchingCancellable {
760 val conn = container.connect(profile)
764 val conn = container.connect(profile)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+39−11
@@ -53,6 +53,10 @@ fun TwoPaneSessionChat(
53 modifier: Modifier = Modifier,
53 modifier: Modifier = Modifier,
54) {
54) {
55 var selected by rememberSaveable { mutableStateOf<String?>(null) }
55 var selected by rememberSaveable { mutableStateOf<String?>(null) }
56+ // Bumped by the empty-detail "New session" button to open the left pane's new-session
57+ // directory picker (the same dialog the FAB uses) instead of creating a directory-less
58+ // session; the created session then opens into the detail pane via onOpenSession.
59+ var newSessionTrigger by remember { mutableStateOf(0) }
56 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
60 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
57 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
61 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
58 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
62 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
@@ -101,12 +105,24 @@ fun TwoPaneSessionChat(
101 // the latch is set the moment any of those wins, it never re-selects after a user clears
105 // the latch is set the moment any of those wins, it never re-selects after a user clears
102 // the pane (BackHandler) or a server switch nulls `selected`.
106 // the pane (BackHandler) or a server switch nulls `selected`.
103 var autoSelected by remember { mutableStateOf(false) }
107 var autoSelected by remember { mutableStateOf(false) }
108+ LaunchedEffect(
109+ sessionListState.loading,
104− LaunchedEffect(sessionListState.loading, sessionListState.sessions, pendingOpenSession, selected) {
110+ sessionListState.sessions,
111+ sessionListState.archivedIds,
112+ sessionListState.showArchived,
113+ pendingOpenSession,
114+ selected,
115+ ) {
105 if (autoSelected || sessionListState.loading) return@LaunchedEffect
116 if (autoSelected || sessionListState.loading) return@LaunchedEffect
106 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
117 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
107 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
118 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
108− // Pick by recency regardless of the list's current sort mode.
119+ // Pick by recency regardless of the list's current sort mode, but only among
120+ // sessions visible under the current archive filter so we never open a hidden row.
109− val id = mostRecentSessionId(sessionListState.sessions) ?: return@LaunchedEffect
121+ val id = mostRecentSessionId(
122+ sessionListState.sessions,
123+ sessionListState.archivedIds,
124+ sessionListState.showArchived,
125+ ) ?: return@LaunchedEffect
110 selected = id
126 selected = id
111 autoSelected = true
127 autoSelected = true
112 }
128 }
@@ -140,6 +156,7 @@ fun TwoPaneSessionChat(
140 onOpenSettings = onOpenSettings,
156 onOpenSettings = onOpenSettings,
141 onAddServer = onAddServer,
157 onAddServer = onAddServer,
142 onOpenSearch = onOpenSearch,
158 onOpenSearch = onOpenSearch,
159+ externalNewSessionTrigger = newSessionTrigger,
143 selectedSessionId = selected,
160 selectedSessionId = selected,
144 )
161 )
145 }
162 }
@@ -151,11 +168,10 @@ fun TwoPaneSessionChat(
151 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
168 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
152 val sessionId = selected
169 val sessionId = selected
153 if (sessionId == null) {
170 if (sessionId == null) {
154− // Reuse the same VM create path the session-list FAB uses; open the new
171+ // Route through the same new-session directory picker the left-pane FAB
172+ // opens (rather than creating a directory-less session here); the created
155− // session straight into the detail pane.
173+ // session opens into the detail pane via the shared onOpenSession callback.
156− EmptyDetail(onNewSession = {
174+ EmptyDetail(onNewSession = { newSessionTrigger++ })
157− sessionListVm.createSession(directory = null) { id -> selected = id }
158− })
159 } else {
175 } else {
160 BackHandler { selected = null }
176 BackHandler { selected = null }
161 // key() on the session id so switching conversations in two-pane mode
177 // key() on the session id so switching conversations in two-pane mode
@@ -179,9 +195,21 @@ fun TwoPaneSessionChat(
179 }
195 }
180}
196}
181
197
198+/**
182−/** Id of the most recently updated (or created) session, or null when the list is empty. */
199+ * Id of the most recently updated (or created) session that is *visible* under the current
200+ * archive filter (archived sessions excluded unless [showArchived]), or null when none
201+ * qualify. Auto-selecting a hidden/archived session would open the detail pane on a row with
202+ * no corresponding highlighted entry in the list.
203+ */
183−private fun mostRecentSessionId(sessions: List<Session>): String? =
204+private fun mostRecentSessionId(
205+ sessions: List<Session>,
206+ archivedIds: Set<String>,
207+ showArchived: Boolean,
208+): String? =
209+ sessions
210+ .filter { showArchived || it.id !in archivedIds }
184− sessions.maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }?.id
211+ .maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }
212+ ?.id
185
213
186/** Whether a session with [id] is present in [sessions]. */
214/** Whether a session with [id] is present in [sessions]. */
187private fun containsSession(sessions: List<Session>, id: String): Boolean =
215private fun containsSession(sessions: List<Session>, id: String): Boolean =
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+11−1
@@ -59,6 +59,7 @@ import kotlinx.coroutines.launch
59import kotlinx.coroutines.withContext
59import kotlinx.coroutines.withContext
60import soy.iko.opencode.util.runCatchingCancellable
60import soy.iko.opencode.util.runCatchingCancellable
61import soy.iko.opencode.ui.components.AppTopBar
61import soy.iko.opencode.ui.components.AppTopBar
62+import soy.iko.opencode.ui.components.copyToClipboard
62import soy.iko.opencode.ui.components.showToast
63import soy.iko.opencode.ui.components.showToast
63import androidx.compose.ui.Alignment
64import androidx.compose.ui.Alignment
64import androidx.compose.ui.Modifier
65import androidx.compose.ui.Modifier
@@ -66,6 +67,7 @@ import androidx.compose.ui.draw.clip
66import androidx.compose.ui.platform.LocalContext
67import androidx.compose.ui.platform.LocalContext
67import androidx.compose.ui.platform.LocalHapticFeedback
68import androidx.compose.ui.platform.LocalHapticFeedback
68import androidx.compose.ui.res.stringResource
69import androidx.compose.ui.res.stringResource
70+import androidx.compose.ui.text.font.FontFamily
69import androidx.compose.ui.semantics.Role
71import androidx.compose.ui.semantics.Role
70import androidx.compose.ui.semantics.contentDescription
72import androidx.compose.ui.semantics.contentDescription
71import androidx.compose.ui.semantics.heading
73import androidx.compose.ui.semantics.heading
@@ -311,6 +313,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
311 },
313 },
312 dismissButton = {
314 dismissButton = {
313 Row {
315 Row {
316+ TextButton(
317+ // Disable Copy until the report has loaded so it can't copy an empty body.
318+ enabled = content != null,
319+ onClick = {
320+ copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
321+ },
322+ ) { Text(stringResource(R.string.copy)) }
323+ Spacer(Modifier.size(8.dp))
314 TextButton(
324 TextButton(
315 // Disable Share until the report has loaded — otherwise tapping it
325 // Disable Share until the report has loaded — otherwise tapping it
316 // while the async read is still in flight (content == null) shares an
326 // while the async read is still in flight (content == null) shares an
@@ -351,7 +361,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
351 ) {
361 ) {
352 Text(
362 Text(
353 content,
363 content,
354− style = MaterialTheme.typography.bodySmall,
364+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
355 modifier = Modifier.fillMaxWidth(),
365 modifier = Modifier.fillMaxWidth(),
356 )
366 )
357 }
367 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+24−10
@@ -207,6 +207,9 @@ fun SettingsScreen(
207 ThemeRow(
207 ThemeRow(
208 mode = mode,
208 mode = mode,
209 selected = s.themeMode == mode,
209 selected = s.themeMode == mode,
210+ // When Material You is active the static Tokyo Night swatches would be a
211+ // lie, so ThemeRow hides them and notes that system colors are in use.
212+ usingSystemColors = s.dynamicColor && dynamicColorAvailable,
210 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
213 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
211 )
214 )
212 }
215 }
@@ -646,7 +649,7 @@ private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
646}
649}
647
650
648@Composable
651@Composable
649−private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
652+private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Boolean, onSelect: () -> Unit) {
650 // Preview swatches so the user can see what each mode looks like before selecting,
653 // Preview swatches so the user can see what each mode looks like before selecting,
651 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
654 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
652 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
655 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
@@ -676,15 +679,26 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
676 style = MaterialTheme.typography.bodyLarge,
679 style = MaterialTheme.typography.bodyLarge,
677 modifier = Modifier.weight(1f),
680 modifier = Modifier.weight(1f),
678 )
681 )
682+ if (usingSystemColors) {
683+ // Dynamic color (Material You) overrides the static palettes, so the dots would
684+ // misrepresent the actual colors — note that system colors are in use instead.
685+ Text(
686+ stringResource(R.string.theme_using_system_colors),
687+ style = MaterialTheme.typography.labelSmall,
688+ color = MaterialTheme.colorScheme.onSurfaceVariant,
689+ textAlign = TextAlign.End,
690+ )
691+ } else {
679− // Three small color dots previewing the palette for this mode.
692+ // Three small color dots previewing the palette for this mode.
680− Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
693+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
681− swatches.forEach { color ->
694+ swatches.forEach { color ->
682− Box(
695+ Box(
683− modifier = Modifier
696+ modifier = Modifier
684− .size(14.dp)
697+ .size(14.dp)
685− .clip(CircleShape)
698+ .clip(CircleShape)
686− .background(color),
699+ .background(color),
687− )
700+ )
701+ }
688 }
702 }
689 }
703 }
690 }
704 }
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+22−7
@@ -1,6 +1,7 @@
1package soy.iko.opencode.ui.usage
1package soy.iko.opencode.ui.usage
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
@@ -33,7 +34,9 @@ import androidx.compose.runtime.getValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.draw.clip
37+import androidx.compose.ui.res.pluralStringResource
36import androidx.compose.ui.res.stringResource
38import androidx.compose.ui.res.stringResource
39+import androidx.compose.ui.semantics.Role
37import androidx.compose.ui.semantics.contentDescription
40import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.semantics
41import androidx.compose.ui.semantics.semantics
39import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.text.style.TextOverflow
@@ -52,7 +55,7 @@ import java.util.Locale
52
55
53@OptIn(ExperimentalMaterial3Api::class)
56@OptIn(ExperimentalMaterial3Api::class)
54@Composable
57@Composable
55−fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
58+fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
56 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
59 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
57 val state by vm.state.collectAsStateWithLifecycle()
60 val state by vm.state.collectAsStateWithLifecycle()
58 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
61 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
@@ -105,7 +108,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
105 modifier = Modifier.align(Alignment.Center),
108 modifier = Modifier.align(Alignment.Center),
106 )
109 )
107 } else {
110 } else {
108− UsageContent(s.report)
111+ UsageContent(s.report, onOpenSession)
109 }
112 }
110 }
113 }
111 }
114 }
@@ -121,7 +124,7 @@ private fun Centered(content: @Composable androidx.compose.foundation.layout.Col
121}
124}
122
125
123@Composable
126@Composable
124−private fun UsageContent(report: UsageReport) {
127+private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
125 LazyColumn(
128 LazyColumn(
126 modifier = Modifier.fillMaxSize(),
129 modifier = Modifier.fillMaxSize(),
127 contentPadding = PaddingValues(16.dp),
130 contentPadding = PaddingValues(16.dp),
@@ -152,6 +155,7 @@ private fun UsageContent(report: UsageReport) {
152 tokens = s.tokens,
155 tokens = s.tokens,
153 messages = s.messages,
156 messages = s.messages,
154 costFraction = fractionOf(s.cost, report.totalCost),
157 costFraction = fractionOf(s.cost, report.totalCost),
158+ onClick = { onOpenSession(s.sessionId) },
155 )
159 )
156 }
160 }
157 }
161 }
@@ -199,7 +203,7 @@ private fun TotalsCard(report: UsageReport) {
199 )
203 )
200 Spacer(Modifier.size(8.dp))
204 Spacer(Modifier.size(8.dp))
201 Text(
205 Text(
202− stringResource(R.string.usage_messages_count, report.messageCount),
206+ pluralStringResource(R.plurals.usage_messages_count, report.messageCount, report.messageCount),
203 style = MaterialTheme.typography.bodySmall,
207 style = MaterialTheme.typography.bodySmall,
204 color = MaterialTheme.colorScheme.onSurfaceVariant,
208 color = MaterialTheme.colorScheme.onSurfaceVariant,
205 )
209 )
@@ -238,13 +242,24 @@ private fun StatRow(label: String, value: String) {
238}
242}
239
243
240@Composable
244@Composable
241−private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int, costFraction: Float) {
245+private fun UsageRow(
246+ title: String,
247+ cost: Double,
248+ tokens: Tokens,
249+ messages: Int,
250+ costFraction: Float,
251+ onClick: (() -> Unit)? = null,
252+) {
253+ val rowModifier = Modifier
254+ .fillMaxWidth()
255+ .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
242− Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
256+ .padding(vertical = 6.dp)
257+ Column(modifier = rowModifier) {
243 Row(verticalAlignment = Alignment.CenterVertically) {
258 Row(verticalAlignment = Alignment.CenterVertically) {
244 Column(modifier = Modifier.weight(1f)) {
259 Column(modifier = Modifier.weight(1f)) {
245 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
260 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
246 Text(
261 Text(
247− stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
262+ pluralStringResource(R.plurals.usage_messages_count, messages, messages) + " · " + formatInt(tokens.total),
248 style = MaterialTheme.typography.labelSmall,
263 style = MaterialTheme.typography.labelSmall,
249 color = MaterialTheme.colorScheme.onSurfaceVariant,
264 color = MaterialTheme.colorScheme.onSurfaceVariant,
250 )
265 )
app/src/main/res/values-es/strings.xml+40−3
@@ -148,7 +148,10 @@
148 <string name="thoughts">Pensamientos</string>
148 <string name="thoughts">Pensamientos</string>
149 <string name="show_more">Mostrar más</string>
149 <string name="show_more">Mostrar más</string>
150 <string name="show_less">Mostrar menos</string>
150 <string name="show_less">Mostrar menos</string>
151− <string name="show_more_lines">Mostrar %1$d líneas más</string>
151+ <plurals name="show_more_lines">
152+ <item quantity="one">Mostrar %1$d línea más</item>
153+ <item quantity="other">Mostrar %1$d líneas más</item>
154+ </plurals>
152 <string name="tool_completed">Completado</string>
155 <string name="tool_completed">Completado</string>
153 <string name="tool_error">Error</string>
156 <string name="tool_error">Error</string>
154 <string name="tool_running">En ejecución</string>
157 <string name="tool_running">En ejecución</string>
@@ -364,7 +367,10 @@
364 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
367 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
365 <string name="search_all_start">Busca en tus conversaciones</string>
368 <string name="search_all_start">Busca en tus conversaciones</string>
366 <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
369 <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
367− <string name="search_results_count">%1$d resultados</string>
370+ <plurals name="search_results_count">
371+ <item quantity="one">%1$d resultado</item>
372+ <item quantity="other">%1$d resultados</item>
373+ </plurals>
368
374
369 <!-- Plan de tareas del agente (herramienta todowrite) -->
375 <!-- Plan de tareas del agente (herramienta todowrite) -->
370 <string name="plan">Plan</string>
376 <string name="plan">Plan</string>
@@ -418,7 +424,10 @@
418 <string name="usage_this_session">Esta sesión</string>
424 <string name="usage_this_session">Esta sesión</string>
419 <string name="usage_all_sessions">Todas las sesiones</string>
425 <string name="usage_all_sessions">Todas las sesiones</string>
420 <string name="usage_empty">Aún no hay uso registrado</string>
426 <string name="usage_empty">Aún no hay uso registrado</string>
421− <string name="usage_messages_count">%1$d mensajes del asistente</string>
427+ <plurals name="usage_messages_count">
428+ <item quantity="one">%1$d mensaje del asistente</item>
429+ <item quantity="other">%1$d mensajes del asistente</item>
430+ </plurals>
422 <string name="usage_loading">Calculando uso…</string>
431 <string name="usage_loading">Calculando uso…</string>
423 <string name="usage_failed">No se pudo cargar el uso</string>
432 <string name="usage_failed">No se pudo cargar el uso</string>
424
433
@@ -501,4 +510,32 @@
501 <string name="import_confirm_replace">Reemplazar</string>
510 <string name="import_confirm_replace">Reemplazar</string>
502 <string name="export_passwords_title">¿Exportar contraseñas en texto plano?</string>
511 <string name="export_passwords_title">¿Exportar contraseñas en texto plano?</string>
503 <string name="export_passwords_warning">Esta copia de seguridad contendrá las contraseñas de tus servidores en texto plano. Cualquiera que pueda leer el archivo podrá verlas.</string>
512 <string name="export_passwords_warning">Esta copia de seguridad contendrá las contraseñas de tus servidores en texto plano. Cualquiera que pueda leer el archivo podrá verlas.</string>
513+
514+ <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
515+ <string name="permission_progress">%1$d de %2$d</string>
516+ <string name="action_continue">Continuar</string>
517+ <string name="summarize_confirm_title">¿Resumir la conversación?</string>
518+ <string name="summarize_confirm_text">Los mensajes anteriores se compactan en un resumen para liberar contexto. Esto no se puede deshacer aquí.</string>
519+ <string name="init_confirm_title">¿Generar AGENTS.md?</string>
520+ <string name="init_confirm_text">El agente creará o sobrescribirá AGENTS.md en el directorio del proyecto. Un archivo existente puede ser reemplazado.</string>
521+ <string name="composer_hint_running">Mensaje — se enviará al terminar la ejecución actual</string>
522+ <string name="clip_label_code">Código</string>
523+ <string name="clip_label_diff">Diferencias</string>
524+ <string name="clip_label_reasoning">Razonamiento</string>
525+ <string name="clip_label_output">Salida</string>
526+ <string name="clip_label_path">Ruta</string>
527+ <string name="clip_label_message">Mensaje</string>
528+ <string name="clip_label_share">Enlace para compartir</string>
529+ <string name="search_contents_hint">Escribe para buscar en el contenido del archivo</string>
530+ <string name="search_symbols_hint">Escribe para buscar símbolos</string>
531+ <string name="search_keep_typing">Sigue escribiendo para buscar…</string>
532+ <string name="theme_using_system_colors">Usando los colores del sistema (Material You)</string>
533+ <string name="tap_to_retry">Toca para reintentar</string>
534+ <string name="count_overflow">99+</string>
535+ <string name="mcp_enabled">Activado</string>
536+ <string name="file_truncated">Archivo grande — mostrando las primeras %1$d líneas.</string>
537+ <plurals name="file_search_results">
538+ <item quantity="one">%1$d resultado</item>
539+ <item quantity="other">%1$d resultados</item>
540+ </plurals>
504</resources>
541</resources>
app/src/main/res/values/strings.xml+40−3
@@ -148,7 +148,10 @@
148 <string name="thoughts">Thoughts</string>
148 <string name="thoughts">Thoughts</string>
149 <string name="show_more">Show more</string>
149 <string name="show_more">Show more</string>
150 <string name="show_less">Show less</string>
150 <string name="show_less">Show less</string>
151− <string name="show_more_lines">Show %1$d more lines</string>
151+ <plurals name="show_more_lines">
152+ <item quantity="one">Show %1$d more line</item>
153+ <item quantity="other">Show %1$d more lines</item>
154+ </plurals>
152 <string name="tool_completed">Completed</string>
155 <string name="tool_completed">Completed</string>
153 <string name="tool_error">Error</string>
156 <string name="tool_error">Error</string>
154 <string name="tool_running">Running</string>
157 <string name="tool_running">Running</string>
@@ -345,7 +348,10 @@
345 <string name="search_truncated">Showing results from the most recent sessions only.</string>
348 <string name="search_truncated">Showing results from the most recent sessions only.</string>
346 <string name="search_all_start">Search your conversations</string>
349 <string name="search_all_start">Search your conversations</string>
347 <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
350 <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
348− <string name="search_results_count">%1$d results</string>
351+ <plurals name="search_results_count">
352+ <item quantity="one">%1$d result</item>
353+ <item quantity="other">%1$d results</item>
354+ </plurals>
349
355
350 <!-- Sharing / summarize / init / shell -->
356 <!-- Sharing / summarize / init / shell -->
351 <string name="create_share_link">Create share link</string>
357 <string name="create_share_link">Create share link</string>
@@ -420,7 +426,10 @@
420 <string name="usage_this_session">This session</string>
426 <string name="usage_this_session">This session</string>
421 <string name="usage_all_sessions">All sessions</string>
427 <string name="usage_all_sessions">All sessions</string>
422 <string name="usage_empty">No usage recorded yet</string>
428 <string name="usage_empty">No usage recorded yet</string>
423− <string name="usage_messages_count">%1$d assistant messages</string>
429+ <plurals name="usage_messages_count">
430+ <item quantity="one">%1$d assistant message</item>
431+ <item quantity="other">%1$d assistant messages</item>
432+ </plurals>
424 <string name="usage_loading">Calculating usage…</string>
433 <string name="usage_loading">Calculating usage…</string>
425 <string name="usage_failed">Couldn\'t load usage</string>
434 <string name="usage_failed">Couldn\'t load usage</string>
426
435
@@ -503,4 +512,32 @@
503 <string name="import_confirm_replace">Replace</string>
512 <string name="import_confirm_replace">Replace</string>
504 <string name="export_passwords_title">Export plaintext passwords?</string>
513 <string name="export_passwords_title">Export plaintext passwords?</string>
505 <string name="export_passwords_warning">This backup will contain your server passwords in plain text. Anyone who can read the file can read them.</string>
514 <string name="export_passwords_warning">This backup will contain your server passwords in plain text. Anyone who can read the file can read them.</string>
515+
516+ <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
517+ <string name="permission_progress">%1$d of %2$d</string>
518+ <string name="action_continue">Continue</string>
519+ <string name="summarize_confirm_title">Summarize conversation?</string>
520+ <string name="summarize_confirm_text">Earlier messages are compacted into a summary to free up context. This can\'t be undone here.</string>
521+ <string name="init_confirm_title">Generate AGENTS.md?</string>
522+ <string name="init_confirm_text">The agent will create or overwrite AGENTS.md in the project directory. An existing file may be replaced.</string>
523+ <string name="composer_hint_running">Message — sends after the current run</string>
524+ <string name="clip_label_code">Code</string>
525+ <string name="clip_label_diff">Diff</string>
526+ <string name="clip_label_reasoning">Reasoning</string>
527+ <string name="clip_label_output">Output</string>
528+ <string name="clip_label_path">Path</string>
529+ <string name="clip_label_message">Message</string>
530+ <string name="clip_label_share">Share link</string>
531+ <string name="search_contents_hint">Type to search file contents</string>
532+ <string name="search_symbols_hint">Type to search symbols</string>
533+ <string name="search_keep_typing">Keep typing to search…</string>
534+ <string name="theme_using_system_colors">Using system colors (Material You)</string>
535+ <string name="tap_to_retry">Tap to retry</string>
536+ <string name="count_overflow">99+</string>
537+ <string name="mcp_enabled">Enabled</string>
538+ <string name="file_truncated">Large file — showing the first %1$d lines.</string>
539+ <plurals name="file_search_results">
540+ <item quantity="one">%1$d result</item>
541+ <item quantity="other">%1$d results</item>
542+ </plurals>
506</resources>
543</resources>