git.iko.soy/opencode-android
on master help sign in
Fix 17 UI/UX issues: edit focus, copy feedback, reasoning streaming, FAB badge, archive undo, sort menu, auto-select, error placement, wrap setting, import confirm
commitacb4dcc4ce1ca79a08d27415ccd805113490809f authoriko <mail@iko.soy> dateFri 3 Jul 2026, 10:39 (3 months ago) parent017cb9a
24 files changed+945−248
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+22 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+27−5 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+55−8 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+44−3 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+89−29 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+11−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+32−6 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+45−2 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+27−4 app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+45 app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+68 app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.kt+25 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+21−10 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+4−9 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−10 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+15−10 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+122−30 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+90−26 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+85−56 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−9 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+57−9 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+5−21 app/src/main/res/values-es/strings.xml+25 app/src/main/res/values/strings.xml+25
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+22
@@ -200,8 +200,30 @@ object NetworkConfig {
200200 const val twoPaneWidthThresholdDp = 840
201201 /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
202202 const val userBubbleWidthFraction = 0.85f
203+ /** 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. */
205+ const val chatListFabInsetDp = 80
203206 /** Left pane weight in the two-pane layout (session list). */
204207 const val twoPaneLeftWeight = 0.38f
205208 /** Right pane weight in the two-pane layout (chat detail). */
206209 const val twoPaneRightWeight = 0.62f
210+
211+ /** Minimum window width (dp) at which a NavigationRail is shown alongside the
212+ * two-pane layout for top-level destination discoverability on large screens. */
213+ const val navigationRailThresholdDp = 840
214+
215+ // --- Motion tokens (shared across screens) ---
216+
217+ /** Duration (ms) for horizontal slide push/pop nav transitions. */
218+ const val motionSlideDurationMs = 220
219+ /** Duration (ms) for fade in/out nav and content transitions. */
220+ const val motionFadeDurationMs = 180
221+
222+ // --- Font scaling ---
223+
224+ /** Upper bound on the *combined* app×OS font scale. The chat text-size preference
225+ * multiplies on top of the system font scale, so a user at a large system size and
226+ * a large app size can reach ~2× and break tight layouts. The provided app scale is
227+ * clamped so the product with the OS fontScale never exceeds this. */
228+ const val maxCombinedFontScale = 1.8f
207229}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+27−5
@@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf
1414import androidx.compose.runtime.rememberCoroutineScope
1515import androidx.compose.runtime.saveable.rememberSaveable
1616import androidx.compose.runtime.setValue
17+import androidx.compose.ui.platform.LocalDensity
1718import androidx.compose.ui.unit.dp
1819import androidx.navigation.NavType
1920import androidx.navigation.compose.NavHost
@@ -116,8 +117,23 @@ fun OpencodeApp(container: AppContainer) {
116117 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
117118 }
118119
120+ // The chat text-size preference multiplies on top of the OS font scale (sp already
121+ // honors the system setting), so a large system size × large app size can reach ~2×
122+ // and break tight layouts. Clamp the provided app scale so the product with the OS
123+ // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
124+ // below the user's chosen value's intent (floor of 1× app scale is preserved when the
125+ // system size alone already exceeds the cap).
126+ val systemFontScale = LocalDensity.current.fontScale
127+ val clampedChatTextScale = if (systemFontScale <= 0f) {
128+ chatTextScale
129+ } else {
130+ chatTextScale.coerceAtMost(
131+ (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
132+ )
133+ }
134+
119135 CompositionLocalProvider(
120− LocalChatTextScale provides chatTextScale,
136+ LocalChatTextScale provides clampedChatTextScale,
121137 LocalCodeWrap provides codeWrap,
122138 ) {
123139 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
@@ -165,10 +181,16 @@ fun OpencodeApp(container: AppContainer) {
165181 startDestination = Routes.SERVERS,
166182 // Slide horizontally for forward/back navigation to feel native; fade for
167183 // the root so the first frame doesn't slide in from off-screen.
168− enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
169− exitTransition = { fadeOut(tween(180)) },
170− popEnterTransition = { fadeIn(tween(180)) },
171− popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
184+ enterTransition = {
185+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
186+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
187+ },
188+ exitTransition = { fadeOut(tween(NetworkConfig.motionFadeDurationMs)) },
189+ popEnterTransition = { fadeIn(tween(NetworkConfig.motionFadeDurationMs)) },
190+ popExitTransition = {
191+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
192+ fadeOut(tween(NetworkConfig.motionFadeDurationMs))
193+ },
172194 ) {
173195
174196 composable(Routes.SERVERS) {
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+55−8
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.lazy.rememberLazyListState
1213import androidx.compose.foundation.text.KeyboardActions
1314import androidx.compose.foundation.text.KeyboardOptions
1415import androidx.compose.material.icons.Icons
@@ -25,6 +26,7 @@ import androidx.compose.material3.OutlinedTextField
2526import androidx.compose.material3.Text
2627import androidx.compose.material3.TextButton
2728import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.LaunchedEffect
2830import androidx.compose.runtime.getValue
2931import androidx.compose.runtime.mutableStateOf
3032import androidx.compose.runtime.remember
@@ -32,6 +34,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
3234import androidx.compose.runtime.setValue
3335import androidx.compose.ui.Alignment
3436import androidx.compose.ui.Modifier
37+import androidx.compose.ui.focus.FocusRequester
38+import androidx.compose.ui.focus.focusRequester
39+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40+import androidx.compose.ui.platform.LocalHapticFeedback
3541import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3642import androidx.compose.ui.res.stringResource
3743import androidx.compose.ui.semantics.Role
@@ -45,6 +51,28 @@ import androidx.compose.foundation.layout.Box
4551import soy.iko.opencode.data.model.Agent
4652import soy.iko.opencode.R
4753
54+/**
55+ * Flat LazyColumn index of the selected agent, offset by the leading "Default" row when it's
56+ * shown. A null selection targets that Default row (index 0). -1 when the selection isn't in
57+ * the current list.
58+ */
59+/** Whether the synthetic "Default" agent row matches the current search query. */
60+private fun defaultAgentMatches(query: String, label: String, desc: String): Boolean =
61+ query.isEmpty() ||
62+ label.contains(query, ignoreCase = true) ||
63+ desc.contains(query, ignoreCase = true)
64+
65+private fun selectedAgentIndex(
66+ distinctAgents: List<Agent>,
67+ selected: String?,
68+ defaultMatches: Boolean,
69+): Int {
70+ if (selected == null) return if (defaultMatches) 0 else -1
71+ val base = if (defaultMatches) 1 else 0
72+ val pos = distinctAgents.indexOfFirst { it.name == selected }
73+ return if (pos >= 0) base + pos else -1
74+}
75+
4876/** Bottom sheet that lists every agent and lets the user pick one. */
4977@OptIn(ExperimentalMaterial3Api::class)
5078@Composable
@@ -93,10 +121,14 @@ fun AgentPickerSheet(
93121 } else {
94122 var query by rememberSaveable { mutableStateOf("") }
95123 val keyboardController = LocalSoftwareKeyboardController.current
124+ val haptics = LocalHapticFeedback.current
125+ // Auto-focus the search field (and raise the keyboard) when the sheet opens.
126+ val searchFocus = remember { FocusRequester() }
127+ LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
96128 OutlinedTextField(
97129 value = query,
98130 onValueChange = { query = it },
99− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
131+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
100132 placeholder = { Text(stringResource(R.string.search_agents)) },
101133 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102134 trailingIcon = if (query.isNotEmpty()) {
@@ -125,9 +157,7 @@ fun AgentPickerSheet(
125157 val defaultLabel = stringResource(R.string.default_agent)
126158 val defaultDesc = stringResource(R.string.default_agent_desc)
127159 val q = query.trim()
128− val defaultMatches = q.isEmpty() ||
129− defaultLabel.contains(q, ignoreCase = true) ||
130− defaultDesc.contains(q, ignoreCase = true)
160+ val defaultMatches = defaultAgentMatches(q, defaultLabel, defaultDesc)
131161 if (filtered.isEmpty() && !defaultMatches) {
132162 Text(
133163 stringResource(R.string.no_agents_match, query.trim()),
@@ -136,7 +166,18 @@ fun AgentPickerSheet(
136166 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
137167 )
138168 } else {
139− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
169+ val listState = rememberLazyListState()
170+ val distinctAgents = remember(filtered) { filtered.distinctBy { it.name } }
171+ // Flat index of the selected agent, offset by the leading "Default" row when
172+ // it's shown. selected == null targets that Default row (index 0). -1 when the
173+ // selection isn't in the current list.
174+ val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
175+ selectedAgentIndex(distinctAgents, selected, defaultMatches)
176+ }
177+ LaunchedEffect(Unit) {
178+ if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
179+ }
180+ LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
140181 if (defaultMatches) {
141182 item(key = "__default") {
142183 Column(
@@ -145,7 +186,10 @@ fun AgentPickerSheet(
145186 .semantics(mergeDescendants = true) {
146187 if (selected == null) this.selected = true
147188 }
148− .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
189+ .clickable(role = Role.RadioButton) {
190+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
191+ onSelect(null); onDismiss()
192+ }
149193 .padding(horizontal = 24.dp, vertical = 12.dp),
150194 ) {
151195 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -178,7 +222,7 @@ fun AgentPickerSheet(
178222 // Namespace the agent keys so an agent literally named "__default" can't
179223 // collide with the sentinel key of the default item above (a duplicate key
180224 // crashes LazyColumn).
181− items(filtered.distinctBy { it.name }, key = { "agent_" + it.name }) { agent ->
225+ items(distinctAgents, key = { "agent_" + it.name }) { agent ->
182226 val isSelected = agent.name == selected
183227 Column(
184228 modifier = Modifier
@@ -186,7 +230,10 @@ fun AgentPickerSheet(
186230 .semantics(mergeDescendants = true) {
187231 if (isSelected) this.selected = true
188232 }
189− .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
233+ .clickable(role = Role.RadioButton) {
234+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
235+ onSelect(agent); onDismiss()
236+ }
190237 .padding(horizontal = 24.dp, vertical = 12.dp),
191238 ) {
192239 Row(verticalAlignment = Alignment.CenterVertically) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+44−3
@@ -11,6 +11,7 @@ import androidx.compose.foundation.rememberScrollState
1111import androidx.compose.material.icons.Icons
1212import androidx.compose.material.icons.filled.Close
1313import androidx.compose.material.icons.filled.Description
14+import androidx.compose.material3.CircularProgressIndicator
1415import androidx.compose.material3.Icon
1516import androidx.compose.material3.IconButton
1617import androidx.compose.material3.MaterialTheme
@@ -22,6 +23,8 @@ import androidx.compose.ui.Modifier
2223import androidx.compose.ui.draw.clip
2324import androidx.compose.ui.layout.ContentScale
2425import androidx.compose.ui.res.stringResource
26+import androidx.compose.ui.semantics.contentDescription
27+import androidx.compose.ui.semantics.semantics
2528import androidx.compose.ui.text.style.TextOverflow
2629import androidx.compose.ui.unit.dp
2730import coil.compose.AsyncImage
@@ -32,8 +35,14 @@ import soy.iko.opencode.R
3235 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
3336 */
3437@Composable
35−internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (String) -> Unit) {
36− if (attachments.isEmpty()) return
38+internal fun AttachmentStrip(
39+ attachments: List<PendingAttachment>,
40+ onRemove: (String) -> Unit,
41+ staging: Boolean = false,
42+) {
43+ // Show the strip while an encode is in flight even before the first chip exists, so the
44+ // user gets immediate feedback that their pick is being processed.
45+ if (attachments.isEmpty() && !staging) return
3746 // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
3847 Row(
3948 modifier = Modifier
@@ -45,6 +54,36 @@ internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (St
4554 attachments.forEach { att ->
4655 AttachmentChip(att, onRemove = { onRemove(att.id) })
4756 }
57+ // A placeholder chip with an indeterminate spinner while any pick is still being
58+ // read + base64-encoded off the main thread (chips only materialize once done).
59+ if (staging) StagingChip()
60+ }
61+}
62+
63+/** A placeholder chip shown while an attachment is being encoded. */
64+@Composable
65+private fun StagingChip() {
66+ val stagingLabel = stringResource(R.string.attachment_staging)
67+ Surface(
68+ shape = MaterialTheme.shapes.small,
69+ color = MaterialTheme.colorScheme.surfaceVariant,
70+ tonalElevation = 1.dp,
71+ ) {
72+ Row(
73+ verticalAlignment = Alignment.CenterVertically,
74+ modifier = Modifier.padding(horizontal = 10.dp),
75+ ) {
76+ CircularProgressIndicator(
77+ Modifier.size(18.dp).semantics { contentDescription = stagingLabel },
78+ strokeWidth = 2.dp,
79+ )
80+ Text(
81+ stagingLabel,
82+ style = MaterialTheme.typography.labelMedium,
83+ maxLines = 1,
84+ modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
85+ )
86+ }
4887 }
4988}
5089
@@ -80,7 +119,9 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
80119 overflow = TextOverflow.Ellipsis,
81120 modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
82121 )
83− IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
122+ // No .size() override so the IconButton keeps its default 48dp touch target
123+ // (a11y minimum) while the visual X stays small (16dp).
124+ IconButton(onClick = onRemove) {
84125 Icon(
85126 Icons.Filled.Close,
86127 contentDescription = stringResource(R.string.remove),
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+89−29
@@ -46,6 +46,8 @@ import androidx.compose.material.icons.filled.Refresh
4646import androidx.compose.material.icons.filled.Restore
4747import androidx.compose.material.icons.filled.Stop
4848import androidx.compose.material3.AlertDialog
49+import androidx.compose.material3.Badge
50+import androidx.compose.material3.BadgedBox
4951import androidx.compose.material3.Button
5052import androidx.compose.material3.CircularProgressIndicator
5153import androidx.compose.material3.DropdownMenu
@@ -281,21 +283,30 @@ fun ChatScreen(
281283 val voicePrompt = stringResource(R.string.voice_prompt)
282284 val linkCopiedMsg = stringResource(R.string.link_copied)
283285
286+ // Count of picks currently being read + base64-encoded off the main thread, so the
287+ // composer can show a staging placeholder immediately (chips only materialize once done).
288+ var stagingCount by remember { mutableStateOf(0) }
289+
284290 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
285291 // per-prompt count cap and surfacing per-file errors without aborting the batch.
286292 fun stageUris(uris: List<Uri>) {
287293 if (uris.isEmpty()) return
288294 scope.launch {
289− for (uri in uris) {
290− if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
291− snackbar.showSnackbar(attachLimitMsg)
292− break
293− }
294− when (val result = uri.toAttachmentResult(appContext)) {
295− is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
296− AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
297− AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
295+ stagingCount++
296+ try {
297+ for (uri in uris) {
298+ if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
299+ snackbar.showSnackbar(attachLimitMsg)
300+ break
301+ }
302+ when (val result = uri.toAttachmentResult(appContext)) {
303+ is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
304+ AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
305+ AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
306+ }
298307 }
308+ } finally {
309+ stagingCount--
299310 }
300311 }
301312 }
@@ -659,6 +670,7 @@ fun ChatScreen(
659670 onCancelQueue = { vm.queueFollowUp("") },
660671 focusRequester = inputFocusRequester,
661672 attachments = attachments,
673+ staging = stagingCount > 0,
662674 onRemoveAttachment = vm::removeAttachment,
663675 onPickPhoto = {
664676 photoPicker.launch(
@@ -700,6 +712,10 @@ fun ChatScreen(
700712 // whose value tracks the latest list.
701713 val currentListItems by rememberUpdatedState(listItems)
702714
715+ // New-content signal for the jump-to-latest FAB: set when content grows while the user
716+ // is scrolled away from the bottom, cleared once they return (see the collector below).
717+ var hasNewContent by remember { mutableStateOf(false) }
718+
703719 val isPinnedToBottom by remember {
704720 derivedStateOf {
705721 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
@@ -746,6 +762,8 @@ fun ChatScreen(
746762 }
747763
748764 LaunchedEffect(Unit) {
765+ var prevSize = 0
766+ var prevLen = 0
749767 snapshotFlow {
750768 // Track list size + the streaming length of the last part so we auto-scroll
751769 // as content arrives, plus the pinned flag. A small data class with primitive
@@ -757,14 +775,23 @@ fun ChatScreen(
757775 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
758776 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
759777 }.collect { signal ->
760− if (signal.size > 0 && signal.pinned) {
761− // Scroll to the effective last index, including the trailing
762− // "__typing" row when a run is active so the working indicator
763− // is brought into view (not just the last message).
764− val items = currentListItems
765− val target = if (running) items.size else items.lastIndex
766− listState.scrollToItem(target)
778+ if (signal.pinned) {
779+ // Back at the bottom: clear the new-content badge.
780+ hasNewContent = false
781+ if (signal.size > 0) {
782+ // Scroll to the effective last index, including the trailing
783+ // "__typing" row when a run is active so the working indicator
784+ // is brought into view (not just the last message).
785+ val items = currentListItems
786+ val target = if (running) items.size else items.lastIndex
787+ listState.scrollToItem(target)
788+ }
789+ } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
790+ // Content arrived while scrolled up — badge the jump-to-latest FAB.
791+ hasNewContent = true
767792 }
793+ prevSize = signal.size
794+ prevLen = signal.lastTextLength
768795 }
769796 }
770797
@@ -875,7 +902,14 @@ fun ChatScreen(
875902 LazyColumn(
876903 state = listState,
877904 modifier = Modifier.fillMaxSize(),
878− contentPadding = PaddingValues(16.dp),
905+ // Extra bottom padding reserves room for the jump-to-latest FAB so it
906+ // never floats over the last message.
907+ contentPadding = PaddingValues(
908+ start = 16.dp,
909+ top = 16.dp,
910+ end = 16.dp,
911+ bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
912+ ),
879913 verticalArrangement = Arrangement.spacedBy(16.dp),
880914 ) {
881915 items(
@@ -901,7 +935,20 @@ fun ChatScreen(
901935 modelLabel = modelLabel,
902936 onOpenFile = onOpenFile,
903937 onRevert = { vm.revertTo(message.info.id) },
904− onEdit = { text -> vm.editMessage(message.info.id, text) },
938+ onEdit = { text ->
939+ // editMessage reverts to before this message (hiding it and
940+ // everything after); the revert banner surfaces the rewind
941+ // with an Undo. Focus + scroll the composer into view like the
942+ // Quote action so the prefilled draft isn't off-screen.
943+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
944+ vm.editMessage(message.info.id, text)
945+ runCatching { inputFocusRequester.requestFocus() }
946+ contentScope.launch {
947+ runCatchingCancellable {
948+ listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
949+ }
950+ }
951+ },
905952 onSpeak = { text -> tts.toggle(message.info.id, text) },
906953 isSpeaking = message.info.id == speakingMessageId,
907954 onQuote = { text ->
@@ -934,19 +981,31 @@ fun ChatScreen(
934981 visible = !isPinnedToBottom && listItems.isNotEmpty(),
935982 modifier = Modifier.align(Alignment.BottomEnd),
936983 ) {
937− ExtendedFloatingActionButton(
938− onClick = {
939− if (listItems.isNotEmpty()) {
940− // Scroll to the effective last index, including the
941− // trailing "__typing" row when a run is active.
942− val target = if (running) listItems.size else listItems.lastIndex
943− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
984+ // Badge the FAB when new content arrived while the user was scrolled up, so
985+ // they know there's something new to jump to (cleared once back at bottom).
986+ BadgedBox(
987+ badge = {
988+ if (hasNewContent) {
989+ val newContentLabel = stringResource(R.string.new_messages)
990+ Badge(modifier = Modifier.semantics { contentDescription = newContentLabel })
944991 }
945992 },
946− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
947− text = { Text(stringResource(R.string.latest)) },
948993 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
949− )
994+ ) {
995+ ExtendedFloatingActionButton(
996+ onClick = {
997+ hasNewContent = false
998+ if (listItems.isNotEmpty()) {
999+ // Scroll to the effective last index, including the
1000+ // trailing "__typing" row when a run is active.
1001+ val target = if (running) listItems.size else listItems.lastIndex
1002+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1003+ }
1004+ },
1005+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
1006+ text = { Text(stringResource(R.string.latest)) },
1007+ )
1008+ }
9501009 }
9511010 }
9521011 }
@@ -1092,6 +1151,7 @@ private fun ChatInputBar(
10921151 onCancelQueue: () -> Unit,
10931152 focusRequester: androidx.compose.ui.focus.FocusRequester,
10941153 attachments: List<PendingAttachment>,
1154+ staging: Boolean,
10951155 onRemoveAttachment: (String) -> Unit,
10961156 onPickPhoto: () -> Unit,
10971157 onPickFile: () -> Unit,
@@ -1131,7 +1191,7 @@ private fun ChatInputBar(
11311191 }
11321192 }
11331193 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1134− AttachmentStrip(attachments, onRemove = onRemoveAttachment)
1194+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
11351195 Row(
11361196 modifier = Modifier.fillMaxWidth().padding(8.dp),
11371197 verticalAlignment = Alignment.Bottom,
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+11−1
@@ -24,6 +24,7 @@ import androidx.compose.material3.Text
2424import androidx.compose.material3.AlertDialog
2525import androidx.compose.material3.TextButton
2626import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.LaunchedEffect
2728import androidx.compose.runtime.getValue
2829import androidx.compose.runtime.mutableStateOf
2930import androidx.compose.runtime.remember
@@ -31,6 +32,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
3132import androidx.compose.runtime.setValue
3233import androidx.compose.ui.Alignment
3334import androidx.compose.ui.Modifier
35+import androidx.compose.ui.focus.FocusRequester
36+import androidx.compose.ui.focus.focusRequester
37+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
38+import androidx.compose.ui.platform.LocalHapticFeedback
3439import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3540import androidx.compose.ui.res.stringResource
3641import androidx.compose.ui.semantics.Role
@@ -96,10 +101,14 @@ fun CommandPickerSheet(
96101 } else {
97102 var query by rememberSaveable { mutableStateOf("") }
98103 val keyboardController = LocalSoftwareKeyboardController.current
104+ val haptics = LocalHapticFeedback.current
105+ // Auto-focus the search field (and raise the keyboard) when the sheet opens.
106+ val searchFocus = remember { FocusRequester() }
107+ LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
99108 OutlinedTextField(
100109 value = query,
101110 onValueChange = { query = it },
102− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
111+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
103112 placeholder = { Text(stringResource(R.string.search_commands)) },
104113 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105114 trailingIcon = if (query.isNotEmpty()) {
@@ -137,6 +146,7 @@ fun CommandPickerSheet(
137146 modifier = Modifier
138147 .fillMaxWidth()
139148 .clickable(role = Role.Button) {
149+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
140150 if (isDestructiveCommand(cmd.name)) {
141151 pendingCommandName = cmd.name
142152 } else {
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+32−6
@@ -32,7 +32,9 @@ import androidx.compose.runtime.setValue
3232import androidx.compose.ui.Alignment
3333import androidx.compose.ui.Modifier
3434import androidx.compose.ui.draw.clip
35+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
3536import androidx.compose.ui.platform.LocalContext
37+import androidx.compose.ui.platform.LocalHapticFeedback
3638import androidx.compose.ui.res.stringResource
3739import androidx.compose.ui.unit.dp
3840import soy.iko.opencode.R
@@ -97,6 +99,7 @@ private fun MessageOverflow(
9799 if (onQuote == null && onBranch == null) return
98100 var expanded by remember { mutableStateOf(false) }
99101 val moreLabel = stringResource(R.string.message_actions)
102+ val haptics = LocalHapticFeedback.current
100103 androidx.compose.foundation.layout.Box {
101104 IconButton(onClick = { expanded = true }) {
102105 Icon(
@@ -111,14 +114,22 @@ private fun MessageOverflow(
111114 DropdownMenuItem(
112115 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
113116 text = { Text(stringResource(R.string.quote_reply)) },
114− onClick = { expanded = false; quote(text) },
117+ onClick = {
118+ expanded = false
119+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
120+ quote(text)
121+ },
115122 )
116123 }
117124 onBranch?.let { branch ->
118125 DropdownMenuItem(
119126 leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
120127 text = { Text(stringResource(R.string.branch_session)) },
121− onClick = { expanded = false; branch(text) },
128+ onClick = {
129+ expanded = false
130+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
131+ branch(text)
132+ },
122133 )
123134 }
124135 }
@@ -131,7 +142,11 @@ private fun MessageOverflow(
131142@Composable
132143private fun RevertButton(onRevert: () -> Unit) {
133144 val label = stringResource(R.string.revert_to_here)
134− IconButton(onClick = onRevert) {
145+ val haptics = LocalHapticFeedback.current
146+ IconButton(onClick = {
147+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
148+ onRevert()
149+ }) {
135150 Icon(
136151 Icons.Filled.Restore,
137152 contentDescription = label,
@@ -194,6 +209,7 @@ private fun UserBubble(
194209 onBranch: ((String) -> Unit)? = null,
195210) {
196211 val context = LocalContext.current
212+ val haptics = LocalHapticFeedback.current
197213 val copyLabel = stringResource(R.string.copy)
198214 val editLabel = stringResource(R.string.edit_message)
199215 // Collect text from all TextParts for copying, so a user can reuse/repost their
@@ -243,7 +259,10 @@ private fun UserBubble(
243259 // long-press on the markdown text (and only TextParts support that).
244260 if (textToCopy != null) {
245261 IconButton(
246− onClick = { copyToClipboard(context, "message", textToCopy) },
262+ onClick = {
263+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
264+ copyToClipboard(context, "message", textToCopy)
265+ },
247266 ) {
248267 Icon(
249268 Icons.Filled.ContentCopy,
@@ -371,13 +390,17 @@ private fun AssistantActions(
371390 onQuote: ((String) -> Unit)? = null,
372391) {
373392 val context = LocalContext.current
393+ val haptics = LocalHapticFeedback.current
374394 val copyLabel = stringResource(R.string.copy)
375395 Row(verticalAlignment = Alignment.CenterVertically) {
376396 // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
377397 // icon while this message is the one being spoken.
378398 if (onSpeak != null && textToCopy != null) {
379399 val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
380− IconButton(onClick = { onSpeak(textToCopy) }) {
400+ IconButton(onClick = {
401+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
402+ onSpeak(textToCopy)
403+ }) {
381404 Icon(
382405 if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
383406 contentDescription = speakLabel,
@@ -389,7 +412,10 @@ private fun AssistantActions(
389412 }
390413 onRevert?.let { RevertButton(it) }
391414 if (textToCopy != null) {
392− IconButton(onClick = { copyToClipboard(context, "message", textToCopy) }) {
415+ IconButton(onClick = {
416+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
417+ copyToClipboard(context, "message", textToCopy)
418+ }) {
393419 Icon(
394420 Icons.Filled.ContentCopy,
395421 contentDescription = copyLabel,
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+45−2
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.lazy.LazyColumn
1111import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.lazy.rememberLazyListState
1213import androidx.compose.foundation.text.KeyboardActions
1314import androidx.compose.foundation.text.KeyboardOptions
1415import androidx.compose.material.icons.Icons
@@ -25,6 +26,7 @@ import androidx.compose.material3.OutlinedTextField
2526import androidx.compose.material3.Text
2627import androidx.compose.material3.TextButton
2728import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.LaunchedEffect
2830import androidx.compose.runtime.getValue
2931import androidx.compose.runtime.mutableStateOf
3032import androidx.compose.runtime.remember
@@ -32,6 +34,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
3234import androidx.compose.runtime.setValue
3335import androidx.compose.ui.Alignment
3436import androidx.compose.ui.Modifier
37+import androidx.compose.ui.focus.FocusRequester
38+import androidx.compose.ui.focus.focusRequester
39+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40+import androidx.compose.ui.platform.LocalHapticFeedback
3541import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3642import androidx.compose.ui.res.stringResource
3743import androidx.compose.ui.semantics.Role
@@ -45,6 +51,29 @@ import androidx.compose.foundation.layout.Box
4551import soy.iko.opencode.data.model.ModelOption
4652import soy.iko.opencode.R
4753
54+/**
55+ * Flat LazyColumn index of the selected model within the provider-grouped list (each group
56+ * preceded by a header row), so the sheet can scroll it into view on open. -1 when nothing
57+ * is selected or the selection isn't in the current list.
58+ */
59+private fun selectedModelIndex(
60+ grouped: Map<String, List<ModelOption>>,
61+ selected: ModelOption?,
62+): Int {
63+ if (selected == null) return -1
64+ var idx = 0
65+ for ((_, opts) in grouped) {
66+ idx++ // provider header
67+ val distinct = opts.distinctBy { it.providerID to it.modelID }
68+ val pos = distinct.indexOfFirst {
69+ it.providerID == selected.providerID && it.modelID == selected.modelID
70+ }
71+ if (pos >= 0) return idx + pos
72+ idx += distinct.size
73+ }
74+ return -1
75+}
76+
4877/** Bottom sheet that lists every provider/model and lets the user pick one. */
4978@OptIn(ExperimentalMaterial3Api::class)
5079@Composable
@@ -93,10 +122,15 @@ fun ModelPickerSheet(
93122 } else {
94123 var query by rememberSaveable { mutableStateOf("") }
95124 val keyboardController = LocalSoftwareKeyboardController.current
125+ val haptics = LocalHapticFeedback.current
126+ // 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+ val searchFocus = remember { FocusRequester() }
129+ LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
96130 OutlinedTextField(
97131 value = query,
98132 onValueChange = { query = it },
99− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
133+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
100134 placeholder = { Text(stringResource(R.string.search_models)) },
101135 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102136 trailingIcon = if (query.isNotEmpty()) {
@@ -129,7 +163,15 @@ fun ModelPickerSheet(
129163 // Group by provider so a long catalog is scannable: a provider header
130164 // followed by its models, instead of a flat list mixing providers.
131165 val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
132− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
166+ val listState = rememberLazyListState()
167+ // Flat LazyColumn index of the selected model (accounting for the provider
168+ // header rows), so we can scroll it into view when the sheet opens. -1 when
169+ // nothing is selected or the selection isn't in the current list.
170+ val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
171+ LaunchedEffect(Unit) {
172+ if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
173+ }
174+ LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133175 grouped.forEach { (providerID, opts) ->
134176 item(key = "header_$providerID") {
135177 Text(
@@ -151,6 +193,7 @@ fun ModelPickerSheet(
151193 if (isSelected) this.selected = true
152194 }
153195 .clickable(role = Role.RadioButton) {
196+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
154197 onSelect(option)
155198 onDismiss()
156199 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+27−4
@@ -4,6 +4,8 @@ import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.foundation.background
55import androidx.compose.foundation.clickable
66import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.horizontalScroll
8+import androidx.compose.foundation.rememberScrollState
79import androidx.compose.foundation.layout.Column
810import androidx.compose.foundation.layout.Row
911import androidx.compose.foundation.layout.defaultMinSize
@@ -36,7 +38,9 @@ import androidx.compose.runtime.setValue
3638import androidx.compose.ui.Alignment
3739import androidx.compose.ui.Modifier
3840import androidx.compose.ui.draw.clip
41+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
3942import androidx.compose.ui.platform.LocalContext
43+import androidx.compose.ui.platform.LocalHapticFeedback
4044import androidx.compose.ui.semantics.Role
4145import androidx.compose.ui.semantics.contentDescription
4246import androidx.compose.ui.semantics.semantics
@@ -155,7 +159,12 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
155159 // call site already disambiguates sibling parts positionally, so a constant per
156160 // block type won't collide.
157161 val saveableKey = keyId.ifBlank { "reasoning" }
158− var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
162+ val haptics = LocalHapticFeedback.current
163+ // While the reasoning is still streaming, default to expanded so live "Thinking…" is
164+ // 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.
166+ var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
167+ val expanded = userOverride ?: streaming
159168 val expandedState = stringResource(R.string.state_expanded)
160169 val collapsedState = stringResource(R.string.state_collapsed)
161170 val thinkingLabel = stringResource(R.string.thinking)
@@ -165,7 +174,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
165174 modifier = Modifier
166175 .fillMaxWidth()
167176 .defaultMinSize(minHeight = 48.dp)
168− .clickable(role = Role.Button) { expanded = !expanded }
177+ .clickable(role = Role.Button) { userOverride = !expanded }
169178 .padding(vertical = 4.dp)
170179 .semantics {
171180 stateDescription = if (expanded) expandedState else collapsedState
@@ -206,7 +215,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
206215 )
207216 }
208217 TextButton(
209− onClick = { copyToClipboard(context, "reasoning", text) },
218+ onClick = {
219+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
220+ copyToClipboard(context, "reasoning", text)
221+ },
210222 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
211223 modifier = Modifier.semantics(mergeDescendants = true) {},
212224 ) {
@@ -222,6 +234,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
222234@Composable
223235private fun ToolCallView(part: ToolPart, modifier: Modifier) {
224236 val context = LocalContext.current
237+ val haptics = LocalHapticFeedback.current
225238 Column(
226239 modifier = modifier
227240 .fillMaxWidth()
@@ -311,7 +324,10 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
311324 moreLines = moreLines,
312325 expandedState = expandedState,
313326 collapsedState = collapsedState,
314− onCopy = { copyToClipboard(context, "output", detail) },
327+ onCopy = {
328+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
329+ copyToClipboard(context, "output", detail)
330+ },
315331 diffSaveKey = part.id,
316332 )
317333 }
@@ -369,11 +385,15 @@ private fun CollapsibleDetail(
369385 } else {
370386 // SelectionContainer so the user can select a portion of the output (e.g.
371387 // a single line of stdout) instead of only copy-all via the button below.
388+ // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
389+ // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
372390 SelectionContainer {
373391 Text(
374392 detail,
375393 style = MaterialTheme.typography.bodySmall,
376394 fontFamily = FontFamily.Monospace,
395+ softWrap = false,
396+ modifier = Modifier.horizontalScroll(rememberScrollState()),
377397 )
378398 }
379399 }
@@ -449,6 +469,7 @@ private fun ToolStatusIcon(state: ToolState) {
449469@Composable
450470private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
451471 val context = LocalContext.current
472+ val haptics = LocalHapticFeedback.current
452473 val path = part.sourcePath ?: part.url ?: part.filename
453474 val copyLabel = stringResource(R.string.copy_path)
454475 val openLabel = stringResource(R.string.open_file)
@@ -473,12 +494,14 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
473494 if (toOpen != null) {
474495 opener?.invoke(toOpen)
475496 } else if (!path.isNullOrBlank()) {
497+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
476498 copyToClipboard(context, "path", path)
477499 showToast(context, context.getString(R.string.path_copied))
478500 }
479501 },
480502 onLongClick = {
481503 if (!path.isNullOrBlank()) {
504+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
482505 copyToClipboard(context, "path", path)
483506 showToast(context, context.getString(R.string.path_copied))
484507 }
app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.ktnew file+45
@@ -0,0 +1,45 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.RowScope
4+import androidx.compose.material.icons.Icons
5+import androidx.compose.material.icons.automirrored.filled.ArrowBack
6+import androidx.compose.material3.ExperimentalMaterial3Api
7+import androidx.compose.material3.Icon
8+import androidx.compose.material3.IconButton
9+import androidx.compose.material3.Text
10+import androidx.compose.material3.TopAppBar
11+import androidx.compose.material3.TopAppBarScrollBehavior
12+import androidx.compose.runtime.Composable
13+import androidx.compose.ui.res.stringResource
14+import soy.iko.opencode.R
15+
16+/**
17+ * The app's standard top bar: a title and an optional up/back button plus trailing actions.
18+ * Extracted so the many plain back-title-actions screens share one implementation instead of
19+ * hand-rolling a [TopAppBar] each. Screens with a bespoke title (dropdown server switcher,
20+ * clickable model picker) keep their own bar.
21+ */
22+@OptIn(ExperimentalMaterial3Api::class)
23+@Composable
24+fun AppTopBar(
25+ title: String,
26+ onBack: (() -> Unit)? = null,
27+ scrollBehavior: TopAppBarScrollBehavior? = null,
28+ actions: @Composable RowScope.() -> Unit = {},
29+) {
30+ TopAppBar(
31+ title = { Text(title) },
32+ navigationIcon = {
33+ if (onBack != null) {
34+ IconButton(onClick = onBack) {
35+ Icon(
36+ Icons.AutoMirrored.Filled.ArrowBack,
37+ contentDescription = stringResource(R.string.back),
38+ )
39+ }
40+ }
41+ },
42+ actions = actions,
43+ scrollBehavior = scrollBehavior,
44+ )
45+}
app/src/main/java/soy/iko/opencode/ui/components/EmptyState.ktnew file+68
@@ -0,0 +1,68 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.padding
6+import androidx.compose.foundation.layout.size
7+import androidx.compose.material3.Button
8+import androidx.compose.material3.Icon
9+import androidx.compose.material3.MaterialTheme
10+import androidx.compose.material3.Text
11+import androidx.compose.runtime.Composable
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.graphics.vector.ImageVector
15+import androidx.compose.ui.text.style.TextAlign
16+import androidx.compose.ui.unit.dp
17+
18+/**
19+ * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
20+ * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
21+ * state reads and behaves the same (see EmptySessions/EmptyServers/etc. which predate this).
22+ */
23+@Composable
24+fun EmptyState(
25+ icon: ImageVector,
26+ title: String,
27+ modifier: Modifier = Modifier,
28+ description: String? = null,
29+ actionIcon: ImageVector? = null,
30+ actionLabel: String? = null,
31+ onAction: (() -> Unit)? = null,
32+) {
33+ Column(
34+ modifier = modifier.padding(32.dp),
35+ horizontalAlignment = Alignment.CenterHorizontally,
36+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
37+ ) {
38+ Icon(
39+ icon,
40+ contentDescription = null,
41+ modifier = Modifier.size(56.dp),
42+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
43+ )
44+ Text(
45+ title,
46+ style = MaterialTheme.typography.titleMedium,
47+ textAlign = TextAlign.Center,
48+ )
49+ if (description != null) {
50+ Text(
51+ description,
52+ style = MaterialTheme.typography.bodyMedium,
53+ color = MaterialTheme.colorScheme.onSurfaceVariant,
54+ textAlign = TextAlign.Center,
55+ )
56+ }
57+ if (actionLabel != null && onAction != null) {
58+ Button(onClick = onAction, modifier = Modifier.padding(top = 12.dp)) {
59+ if (actionIcon != null) {
60+ Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
61+ Text(actionLabel, modifier = Modifier.padding(start = 6.dp))
62+ } else {
63+ Text(actionLabel)
64+ }
65+ }
66+ }
67+ }
68+}
app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.ktnew file+25
@@ -0,0 +1,25 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.padding
4+import androidx.compose.material3.MaterialTheme
5+import androidx.compose.material3.Text
6+import androidx.compose.runtime.Composable
7+import androidx.compose.ui.Modifier
8+import androidx.compose.ui.semantics.heading
9+import androidx.compose.ui.semantics.semantics
10+import androidx.compose.ui.unit.dp
11+
12+/**
13+ * A primary-tinted section title, marked as an accessibility heading so TalkBack users
14+ * can navigate between sections. Shared so section headings look and behave identically
15+ * across Settings, Usage, and other grouped screens.
16+ */
17+@Composable
18+fun SectionHeader(text: String, modifier: Modifier = Modifier) {
19+ Text(
20+ text,
21+ style = MaterialTheme.typography.titleSmall,
22+ color = MaterialTheme.colorScheme.primary,
23+ modifier = modifier.padding(top = 8.dp).semantics { heading() },
24+ )
25+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+21−10
@@ -76,6 +76,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
7676import soy.iko.opencode.di.AppContainer
7777import soy.iko.opencode.R
7878import soy.iko.opencode.ui.components.DiffView
79+import soy.iko.opencode.ui.components.LocalCodeWrap
7980import soy.iko.opencode.ui.components.copyToClipboard
8081import soy.iko.opencode.ui.components.highlightLine
8182import soy.iko.opencode.ui.components.syntaxFor
@@ -114,7 +115,10 @@ fun FileViewScreen(
114115 var findActive by rememberSaveable(path) { mutableStateOf(false) }
115116 var findQuery by rememberSaveable(path) { mutableStateOf("") }
116117 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
117− var wrap by rememberSaveable(path) { mutableStateOf(false) }
118+ // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
119+ // respects it by default; they can still flip it per-file (persisted across rotation).
120+ val codeWrap = LocalCodeWrap.current
121+ var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
118122 val listState = rememberLazyListState()
119123 val rawText = state.content?.content.orEmpty()
120124 val lines = remember(rawText) { rawText.split("\n") }
@@ -161,10 +165,19 @@ fun FileViewScreen(
161165 loading = state.loading,
162166 content = rawText,
163167 wrap = wrap,
164− inDiffMode = showToggle && showDiff,
165168 onBack = onBack,
166169 onReload = { vm.reload() },
167− onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
170+ // Find searches the raw line list, which isn't on screen under a diff. Rather
171+ // than silently greying the icon out, keep it tappable and explain where find
172+ // lives when the user is in diff mode.
173+ onToggleFind = {
174+ if (showToggle && showDiff) {
175+ showToast(context, context.getString(R.string.switch_to_raw_to_search))
176+ } else {
177+ findActive = !findActive
178+ if (!findActive) findQuery = ""
179+ }
180+ },
168181 onToggleWrap = { wrap = !wrap },
169182 onCopy = { copyToClipboard(context, filename, rawText) },
170183 onShare = {
@@ -231,7 +244,6 @@ private fun FileViewTopBar(
231244 loading: Boolean,
232245 content: String,
233246 wrap: Boolean,
234− inDiffMode: Boolean,
235247 onBack: () -> Unit,
236248 onReload: () -> Unit,
237249 onToggleFind: () -> Unit,
@@ -253,12 +265,11 @@ private fun FileViewTopBar(
253265 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
254266 }
255267 if (content.isNotEmpty()) {
256− // Find-in-file: toggles a search bar over the raw content. Disabled only when
257− // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
258− // raw text and scrolls the line LazyColumn, neither of which is visible under a
259− // diff, so find would silently no-op there. A plain file with no diff (the common
260− // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
261− IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
268+ // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
269+ // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
270+ // neither of which is visible under a diff, so tapping it there surfaces a hint to
271+ // switch to Raw rather than silently no-opping behind a greyed-out icon.
272+ IconButton(onClick = onToggleFind) {
262273 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
263274 }
264275 // Wrap long lines instead of horizontal-scrolling, useful for prose.
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+4−9
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.size
1414import androidx.compose.foundation.lazy.LazyColumn
1515import androidx.compose.foundation.lazy.items
1616import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.automirrored.filled.ArrowBack
1817import androidx.compose.material.icons.filled.CloudQueue
1918import androidx.compose.material.icons.filled.Computer
2019import androidx.compose.material.icons.filled.Refresh
@@ -27,7 +26,6 @@ import androidx.compose.material3.IconButton
2726import androidx.compose.material3.MaterialTheme
2827import androidx.compose.material3.Scaffold
2928import androidx.compose.material3.Text
30−import androidx.compose.material3.TopAppBar
3129import androidx.compose.runtime.Composable
3230import androidx.compose.runtime.getValue
3331import androidx.compose.ui.Alignment
@@ -39,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
3937import androidx.lifecycle.viewmodel.compose.viewModel
4038import soy.iko.opencode.R
4139import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.ui.components.AppTopBar
4241import soy.iko.opencode.ui.vmFactory
4342
4443@OptIn(ExperimentalMaterial3Api::class)
@@ -49,13 +48,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
4948
5049 Scaffold(
5150 topBar = {
52− TopAppBar(
53− title = { Text(stringResource(R.string.mcp_servers)) },
54− navigationIcon = {
55− IconButton(onClick = onBack) {
56− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
57− }
58− },
51+ AppTopBar(
52+ title = stringResource(R.string.mcp_servers),
53+ onBack = onBack,
5954 actions = {
6055 IconButton(onClick = { vm.load() }) {
6156 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−10
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items
1414import androidx.compose.foundation.text.KeyboardActions
1515import androidx.compose.foundation.text.KeyboardOptions
1616import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.automirrored.filled.ArrowBack
1817import androidx.compose.material.icons.filled.Close
1918import androidx.compose.material.icons.filled.Search
2019import androidx.compose.material3.Card
@@ -26,7 +25,6 @@ import androidx.compose.material3.MaterialTheme
2625import androidx.compose.material3.OutlinedTextField
2726import androidx.compose.material3.Scaffold
2827import androidx.compose.material3.Text
29−import androidx.compose.material3.TopAppBar
3028import androidx.compose.runtime.Composable
3129import androidx.compose.runtime.LaunchedEffect
3230import androidx.compose.runtime.getValue
@@ -43,6 +41,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
4341import androidx.lifecycle.viewmodel.compose.viewModel
4442import soy.iko.opencode.R
4543import soy.iko.opencode.di.AppContainer
44+import soy.iko.opencode.ui.components.AppTopBar
4645import soy.iko.opencode.ui.components.RelativeTimeText
4746import soy.iko.opencode.ui.vmFactory
4847import soy.iko.opencode.util.runCatchingCancellable
@@ -66,14 +65,7 @@ fun GlobalSearchScreen(
6665
6766 Scaffold(
6867 topBar = {
69− TopAppBar(
70− title = { Text(stringResource(R.string.search_all_title)) },
71− navigationIcon = {
72− IconButton(onClick = onBack) {
73− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
74− }
75− },
76− )
68+ AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
7769 },
7870 ) { padding ->
7971 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+15−10
@@ -157,6 +157,9 @@ private fun ServerEditForm(
157157 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
158158 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
159159 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
160+ // 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.
162+ val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
160163 Column(
161164 modifier = Modifier
162165 .padding(padding)
@@ -173,9 +176,9 @@ private fun ServerEditForm(
173176 label = { Text(stringResource(R.string.base_url)) },
174177 placeholder = { Text(stringResource(R.string.base_url_hint)) },
175178 singleLine = true,
176− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
179+ isError = state.baseUrl.isNotBlank() && !urlValid,
177180 supportingText = {
178− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
181+ if (state.baseUrl.isNotBlank() && !urlValid) {
179182 // Offer a one-tap "http://" fix for bare host:port input instead of a
180183 // generic error, so the user doesn't have to know the URL needs a scheme.
181184 val suggestion = suggestUrlScheme(state.baseUrl)
@@ -234,6 +237,16 @@ private fun ServerEditForm(
234237 Text(stringResource(R.string.connect))
235238 }
236239 }
240+ // Keep the error directly under the primary action so it's in view where the user is
241+ // looking when a Connect/Save fails — not buried further down the form.
242+ state.error?.let { message ->
243+ Text(
244+ message,
245+ color = MaterialTheme.colorScheme.error,
246+ style = MaterialTheme.typography.bodySmall,
247+ modifier = Modifier.fillMaxWidth(),
248+ )
249+ }
237250 // Secondary, low-emphasis: persist without connecting (offline setup).
238251 TextButton(
239252 onClick = {
@@ -245,14 +258,6 @@ private fun ServerEditForm(
245258 ) {
246259 Text(stringResource(R.string.save_without_connecting))
247260 }
248− state.error?.let { message ->
249− Text(
250− message,
251− color = MaterialTheme.colorScheme.error,
252− style = MaterialTheme.typography.bodySmall,
253− modifier = Modifier.fillMaxWidth(),
254− )
255− }
256261 // De-emphasized optional label at the bottom: displayLabel already falls back to the
257262 // URL, so most servers don't need one.
258263 OutlinedTextField(
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+122−30
@@ -21,8 +21,12 @@ import androidx.compose.material.icons.Icons
2121import androidx.compose.material.icons.automirrored.filled.Logout
2222import androidx.compose.material.icons.automirrored.filled.Sort
2323import androidx.compose.material.icons.filled.Add
24+import androidx.compose.material.icons.filled.Archive
25+import androidx.compose.material.icons.filled.ArrowDownward
2426import androidx.compose.material.icons.filled.ArrowDropDown
27+import androidx.compose.material.icons.filled.ArrowUpward
2528import androidx.compose.material.icons.filled.ChatBubbleOutline
29+import androidx.compose.material.icons.filled.Check
2630import androidx.compose.material.icons.filled.CheckCircle
2731import androidx.compose.material.icons.filled.Close
2832import androidx.compose.material.icons.filled.Delete
@@ -64,6 +68,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
6468import androidx.compose.runtime.setValue
6569import androidx.compose.ui.Alignment
6670import androidx.compose.ui.Modifier
71+import androidx.compose.ui.draw.alpha
6772import androidx.compose.ui.draw.clip
6873import androidx.compose.ui.platform.LocalHapticFeedback
6974import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -200,6 +205,8 @@ fun SessionListScreen(
200205 }
201206 }
202207
208+ SessionActionUndoEffect(vm, snackbar)
209+
203210 Scaffold(
204211 topBar = {
205212 TopAppBar(
@@ -226,36 +233,18 @@ fun SessionListScreen(
226233 IconButton(onClick = onOpenSearch) {
227234 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
228235 }
229− Box {
230− IconButton(onClick = { showSortMenu = true }) {
231− Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
232− }
233− DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
234− DropdownMenuItem(
235− text = { Text(stringResource(R.string.sort_recent)) },
236− onClick = { vm.setSortMode(SessionSortMode.RECENT); showSortMenu = false },
237− )
238− DropdownMenuItem(
239− text = { Text(stringResource(R.string.sort_title)) },
240− onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
241− )
242− // Toggle archived visibility. Only offered once at least one
243− // session is archived, so the menu stays uncluttered otherwise.
244− if (state.showArchived || state.hiddenArchivedCount > 0) {
245− androidx.compose.material3.HorizontalDivider()
246− DropdownMenuItem(
247− text = {
248− Text(
249− stringResource(
250− if (state.showArchived) R.string.hide_archived else R.string.show_archived,
251− ),
252− )
253− },
254− onClick = { vm.setShowArchived(!state.showArchived); showSortMenu = false },
255− )
256− }
257− }
258− }
236+ SortMenu(
237+ sortMode = state.sortMode,
238+ sortDescending = state.sortDescending,
239+ showArchived = state.showArchived,
240+ hiddenArchivedCount = state.hiddenArchivedCount,
241+ expanded = showSortMenu,
242+ onExpand = { showSortMenu = true },
243+ onDismiss = { showSortMenu = false },
244+ onSetSortMode = { vm.setSortMode(it) },
245+ onToggleDirection = { vm.toggleSortDirection() },
246+ onSetShowArchived = { vm.setShowArchived(it) },
247+ )
259248 IconButton(onClick = { vm.refresh() }) {
260249 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
261250 }
@@ -694,6 +683,9 @@ private fun SessionCard(
694683 Card(
695684 modifier = modifier
696685 .fillMaxWidth()
686+ // Mute an archived row (unless it's the open one) so it reads as backgrounded
687+ // when "show archived" surfaces it alongside active sessions.
688+ .then(if (isArchived && !isSelected) Modifier.alpha(0.6f) else Modifier)
697689 .clickable { onClick() },
698690 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
699691 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
@@ -745,6 +737,14 @@ private fun SessionCard(
745737 tint = MaterialTheme.colorScheme.primary,
746738 )
747739 }
740+ if (isArchived) {
741+ Icon(
742+ Icons.Filled.Archive,
743+ contentDescription = stringResource(R.string.session_archived_badge),
744+ modifier = Modifier.padding(end = 4.dp).size(14.dp),
745+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
746+ )
747+ }
748748 Text(
749749 session.displayTitle,
750750 style = MaterialTheme.typography.titleMedium,
@@ -837,6 +837,98 @@ private fun SessionCard(
837837 }
838838}
839839
840+// Confirmation + Undo for pin/archive toggles. Archiving hides the row from the default
841+// view, so without this the user gets no feedback and no way back. collectLatest so a rapid
842+// re-toggle supersedes the prior snackbar instead of queuing behind it.
843+@Composable
844+private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: SnackbarHostState) {
845+ val undoLabel = stringResource(R.string.undo)
846+ val archived = stringResource(R.string.session_archived)
847+ val unarchived = stringResource(R.string.session_unarchived)
848+ val pinned = stringResource(R.string.session_pinned_msg)
849+ val unpinned = stringResource(R.string.session_unpinned_msg)
850+ LaunchedEffect(Unit) {
851+ vm.sessionActionEvents.collectLatest { event ->
852+ val message = when (event.kind) {
853+ SessionActionKind.ARCHIVED -> archived
854+ SessionActionKind.UNARCHIVED -> unarchived
855+ SessionActionKind.PINNED -> pinned
856+ SessionActionKind.UNPINNED -> unpinned
857+ }
858+ val result = snackbar.showSnackbar(
859+ message = message,
860+ actionLabel = undoLabel,
861+ duration = androidx.compose.material3.SnackbarDuration.Short,
862+ )
863+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
864+ vm.undoSessionAction(event)
865+ }
866+ }
867+ }
868+}
869+
870+@Composable
871+private fun SortMenu(
872+ sortMode: SessionSortMode,
873+ sortDescending: Boolean,
874+ showArchived: Boolean,
875+ hiddenArchivedCount: Int,
876+ expanded: Boolean,
877+ onExpand: () -> Unit,
878+ onDismiss: () -> Unit,
879+ onSetSortMode: (SessionSortMode) -> Unit,
880+ onToggleDirection: () -> Unit,
881+ onSetShowArchived: (Boolean) -> Unit,
882+) {
883+ Box {
884+ IconButton(onClick = onExpand) {
885+ Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
886+ }
887+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
888+ DropdownMenuItem(
889+ text = { Text(stringResource(R.string.sort_recent)) },
890+ trailingIcon = if (sortMode == SessionSortMode.RECENT) {
891+ { Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.sort_active)) }
892+ } else null,
893+ onClick = { onSetSortMode(SessionSortMode.RECENT); onDismiss() },
894+ )
895+ DropdownMenuItem(
896+ text = { Text(stringResource(R.string.sort_title)) },
897+ trailingIcon = if (sortMode == SessionSortMode.TITLE) {
898+ { Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.sort_active)) }
899+ } else null,
900+ onClick = { onSetSortMode(SessionSortMode.TITLE); onDismiss() },
901+ )
902+ androidx.compose.material3.HorizontalDivider()
903+ // Direction toggle: the label shows the current direction and the arrow its
904+ // sense; tapping flips it and re-sorts the list in place.
905+ DropdownMenuItem(
906+ text = {
907+ Text(stringResource(if (sortDescending) R.string.sort_descending else R.string.sort_ascending))
908+ },
909+ leadingIcon = {
910+ Icon(
911+ if (sortDescending) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward,
912+ contentDescription = null,
913+ )
914+ },
915+ onClick = onToggleDirection,
916+ )
917+ // Toggle archived visibility. Only offered once at least one session is
918+ // archived, so the menu stays uncluttered otherwise.
919+ if (showArchived || hiddenArchivedCount > 0) {
920+ androidx.compose.material3.HorizontalDivider()
921+ DropdownMenuItem(
922+ text = {
923+ Text(stringResource(if (showArchived) R.string.hide_archived else R.string.show_archived))
924+ },
925+ onClick = { onSetShowArchived(!showArchived); onDismiss() },
926+ )
927+ }
928+ }
929+ }
930+}
931+
840932@Composable
841933private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
842934 Column(
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+90−26
@@ -45,6 +45,12 @@ import kotlinx.coroutines.sync.withPermit
4545/** Sort order for the session list. */
4646enum class SessionSortMode { RECENT, TITLE }
4747
48+/** A pin/archive action the user just took, surfaced so the UI can confirm it with an
49+ * Undo snackbar. [undoable] via [SessionListViewModel.undoSessionAction]. */
50+enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED }
51+
52+data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
53+
4854/** Directory options for the new-session picker: worktree paths the server knows about
4955 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
5056 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
@@ -64,6 +70,10 @@ data class SessionListState(
6470 val loading: Boolean = true,
6571 val error: String? = null,
6672 val sortMode: SessionSortMode = SessionSortMode.RECENT,
73+ // Primary-key sort direction. RECENT's natural order is descending (newest first),
74+ // TITLE's is ascending (A→Z); this defaults to RECENT's and is reset to each mode's
75+ // natural direction on a mode switch, then flipped by the direction toggle.
76+ val sortDescending: Boolean = true,
6777 val pinnedIds: Set<String> = emptySet(),
6878 val archivedIds: Set<String> = emptySet(),
6979 val showArchived: Boolean = false,
@@ -94,18 +104,24 @@ data class SessionListState(
94104 get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
95105}
96106
97−/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
98− * display title asc (case-insensitive), falling back to recency for ties. */
99−private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
100− SessionSortMode.RECENT ->
101− sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
102− SessionSortMode.TITLE ->
103− // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
104− // sort re-runs on every debounced list flush during streaming.
105− sortedWith(
106− compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
107− .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
108− )
107+/** Sort sessions by the given mode and direction. In each mode's *natural* direction,
108+ * RECENT sorts by last activity time desc; TITLE by display title asc (case-insensitive),
109+ * falling back to recency for ties. When [descending] differs from the mode's natural
110+ * direction (RECENT natural = desc, TITLE natural = asc) the whole order is reversed. */
111+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode, descending: Boolean): List<Session> {
112+ val natural = when (mode) {
113+ SessionSortMode.RECENT ->
114+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
115+ SessionSortMode.TITLE ->
116+ // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
117+ // sort re-runs on every debounced list flush during streaming.
118+ sortedWith(
119+ compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
120+ .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
121+ )
122+ }
123+ val naturalDescending = mode == SessionSortMode.RECENT
124+ return if (descending == naturalDescending) natural else natural.reversed()
109125}
110126
111127// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
@@ -175,6 +191,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
175191 )
176192 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
177193
194+ /** One-shot events carrying a pin/archive action the user just took, so the UI can show
195+ * a confirmation + Undo. Archiving hides the row from the default view, so undo is the
196+ * only feedback that the action can be reversed. */
197+ private val _sessionActionEvents = MutableSharedFlow<SessionActionEvent>(
198+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
199+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
200+ )
201+ val sessionActionEvents: SharedFlow<SessionActionEvent> = _sessionActionEvents.asSharedFlow()
202+
178203 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
179204 * Session so Undo can restore it. */
180205 // Keyed by session id, not a single field: the undo window lets several deletes be
@@ -233,25 +258,50 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
233258 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
234259 fun togglePin(session: Session) {
235260 val pin = session.id !in _state.value.pinnedIds
236− // Optimistically update the displayed state so rapid taps toggle correctly instead
237− // of both reading the same stale set (pre-observer-round-trip) and double-pinning.
238− _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + session.id else it.pinnedIds - session.id) }
239− viewModelScope.launch {
240− runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
241− .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
242− }
261+ applyPin(session.id, pin)
262+ _sessionActionEvents.tryEmit(
263+ SessionActionEvent(session.id, if (pin) SessionActionKind.PINNED else SessionActionKind.UNPINNED),
264+ )
243265 }
244266
245267 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
246268 fun toggleArchive(session: Session) {
247269 val archive = session.id !in _state.value.archivedIds
248− _state.update { it.copy(archivedIds = if (archive) it.archivedIds + session.id else it.archivedIds - session.id) }
270+ applyArchive(session.id, archive)
271+ _sessionActionEvents.tryEmit(
272+ SessionActionEvent(session.id, if (archive) SessionActionKind.ARCHIVED else SessionActionKind.UNARCHIVED),
273+ )
274+ }
275+
276+ // Optimistically update the displayed state so rapid taps toggle correctly instead of
277+ // both reading the same stale set (pre-observer-round-trip) and double-toggling, then
278+ // persist. Shared by the toggle actions and their undo.
279+ private fun applyPin(sessionId: String, pin: Boolean) {
280+ _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + sessionId else it.pinnedIds - sessionId) }
281+ viewModelScope.launch {
282+ runCatchingCancellable { container.sessionPrefsStore.setPinned(sessionId, pin) }
283+ .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
284+ }
285+ }
286+
287+ private fun applyArchive(sessionId: String, archive: Boolean) {
288+ _state.update { it.copy(archivedIds = if (archive) it.archivedIds + sessionId else it.archivedIds - sessionId) }
249289 viewModelScope.launch {
250− runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
290+ runCatchingCancellable { container.sessionPrefsStore.setArchived(sessionId, archive) }
251291 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
252292 }
253293 }
254294
295+ /** Reverse a pin/archive action surfaced via [sessionActionEvents] (Undo snackbar). */
296+ fun undoSessionAction(event: SessionActionEvent) {
297+ when (event.kind) {
298+ SessionActionKind.PINNED -> applyPin(event.sessionId, false)
299+ SessionActionKind.UNPINNED -> applyPin(event.sessionId, true)
300+ SessionActionKind.ARCHIVED -> applyArchive(event.sessionId, false)
301+ SessionActionKind.UNARCHIVED -> applyArchive(event.sessionId, true)
302+ }
303+ }
304+
255305 /** Toggle whether archived sessions are shown in the list. */
256306 fun setShowArchived(show: Boolean) {
257307 _state.update { it.copy(showArchived = show) }
@@ -391,7 +441,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
391441 // new state (and trigger recomposition) when the batch held no real changes
392442 // (e.g. a duplicate SessionUpdated for an unchanged session).
393443 if (changedIds.isEmpty()) return@update s
394− val sorted = byId.values.sortedByMode(s.sortMode)
444+ val sorted = byId.values.sortedByMode(s.sortMode, s.sortDescending)
395445 // If the new ordering matches the existing one (e.g. only the already-newest
396446 // session updated its timestamp), reuse the same list instance so downstream
397447 // skips a recomposition for an identical reference.
@@ -453,7 +503,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
453503 // REST delete hasn't fired yet — the server still returns them, and
454504 // re-adding them here would resurrect a just-hidden row (and later
455505 // collide on the LazyColumn id key when undo/restore re-appends it).
456− val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }.sortedByMode(_state.value.sortMode)
506+ val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }
507+ .sortedByMode(_state.value.sortMode, _state.value.sortDescending)
457508 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
458509 loadPreviews(sorted)
459510 }
@@ -640,7 +691,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
640691 // we never produce two rows with the same id.
641692 pendingDeletes.remove(session.id)
642693 _state.update { s ->
643− s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
694+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode, s.sortDescending))
644695 }
645696 _transientErrors.tryEmit(container.friendlyError(err))
646697 }
@@ -660,13 +711,26 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
660711 val pending = pendingDeletes.remove(sessionId) ?: return
661712 _state.update { s ->
662713 // Dedup by id in case a refresh during the undo window already re-added it.
663− s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode))
714+ s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode, s.sortDescending))
664715 }
665716 }
666717
667718 fun setSortMode(mode: SessionSortMode) {
668719 if (mode == _state.value.sortMode) return
669− _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
720+ // Reset to the new mode's natural direction (RECENT=desc, TITLE=asc) so switching
721+ // modes doesn't inherit a surprising reversed order from the previous mode.
722+ val descending = mode == SessionSortMode.RECENT
723+ _state.update { s ->
724+ s.copy(sortMode = mode, sortDescending = descending, sessions = s.sessions.sortedByMode(mode, descending))
725+ }
726+ }
727+
728+ /** Flip the ascending/descending direction of the current sort mode. */
729+ fun toggleSortDirection() {
730+ _state.update { s ->
731+ val descending = !s.sortDescending
732+ s.copy(sortDescending = descending, sessions = s.sessions.sortedByMode(s.sortMode, descending))
733+ }
670734 }
671735
672736 fun renameSession(session: Session, newTitle: String) {
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+85−56
@@ -1,18 +1,13 @@
11package soy.iko.opencode.ui.session
22
3−import androidx.compose.foundation.layout.Arrangement
43import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.Column
64import androidx.compose.foundation.layout.Row
75import androidx.compose.foundation.layout.fillMaxSize
8−import androidx.compose.foundation.layout.padding
96import androidx.compose.foundation.layout.widthIn
107import androidx.activity.compose.BackHandler
118import androidx.compose.material.icons.Icons
129import androidx.compose.material.icons.automirrored.filled.Chat
13−import androidx.compose.material3.Icon
14−import androidx.compose.material3.MaterialTheme
15−import androidx.compose.material3.Text
10+import androidx.compose.material.icons.filled.Add
1611import androidx.compose.runtime.Composable
1712import androidx.compose.runtime.LaunchedEffect
1813import androidx.compose.runtime.getValue
@@ -21,16 +16,17 @@ import androidx.compose.runtime.mutableStateOf
2116import androidx.compose.runtime.remember
2217import androidx.compose.runtime.saveable.rememberSaveable
2318import androidx.compose.runtime.setValue
24−import androidx.compose.ui.Alignment
2519import androidx.compose.ui.Modifier
2620import androidx.compose.ui.res.stringResource
2721import androidx.compose.ui.unit.dp
2822import androidx.lifecycle.compose.collectAsStateWithLifecycle
2923import androidx.lifecycle.viewmodel.compose.viewModel
3024import soy.iko.opencode.R
25+import soy.iko.opencode.data.model.Session
3126import soy.iko.opencode.data.network.NetworkConfig
3227import soy.iko.opencode.di.AppContainer
3328import soy.iko.opencode.ui.chat.ChatScreen
29+import soy.iko.opencode.ui.components.EmptyState
3430import soy.iko.opencode.ui.vmFactory
3531
3632/**
@@ -87,38 +83,30 @@ fun TwoPaneSessionChat(
8783 lastProfileId = currentId
8884 }
8985
90− // Clear the selection only when the open session is *deleted* — i.e. it was present
91− // in the list and then dropped out. A freshly created session sets `selected` to the
92− // new id before the async refresh lands, so the id is legitimately absent from the
93− // (still-stale) list for a moment; a naive "not in list" check would wrongly clear it
94− // and blank the detail pane. The `hasAppeared` latch (reset whenever `selected`
95− // changes, via remember keyed to it) distinguishes "never appeared yet" (keep) from
96− // "appeared, then removed" (a real deletion → clear).
97− var hasAppeared by remember(selected) { mutableStateOf(false) }
98− LaunchedEffect(selected, sessionListState.sessions) {
99− val target = selected ?: return@LaunchedEffect
100− if (sessionListState.sessions.any { it.id == target }) {
101− hasAppeared = true
102− } else if (hasAppeared) {
103− selected = null
104− }
105− }
86+ StaleSelectionCleanup(
87+ loading = sessionListState.loading,
88+ error = sessionListState.error,
89+ sessions = sessionListState.sessions,
90+ selected = selected,
91+ onClear = { selected = null },
92+ )
10693
107− // The hasAppeared latch above can't survive process death (plain remember), so a
108− // `selected` restored across process death whose session was deleted server-side while
109− // dead never "appears" and is kept forever — the detail pane then renders ChatScreen for
110− // a nonexistent session (permanent load error). Once the first list load completes
111− // successfully without the restored target, clear it. Guarded to run once (non-saveable
112− // flag, so it re-arms after process death) and only on a successful, completed load — not
113− // during the initial load, and not for a freshly-created session whose refresh keeps
114− // `loading` false while its row is still pending.
115− var initialSelectionValidated by remember { mutableStateOf(false) }
116− LaunchedEffect(sessionListState.loading, sessionListState.error, sessionListState.sessions) {
117− if (initialSelectionValidated) return@LaunchedEffect
118− if (sessionListState.loading || sessionListState.error != null) return@LaunchedEffect
119− initialSelectionValidated = true
120− val target = selected ?: return@LaunchedEffect
121− if (sessionListState.sessions.none { it.id == target }) selected = null
94+ // Auto-select the most-recent session into the detail pane so a wide screen doesn't
95+ // open on a blank right pane. Runs at most once per composition (the `autoSelected`
96+ // latch, non-saveable so it re-arms after process death), and only once the first list
97+ // load has completed. It defers to any explicit selection already present — a restored
98+ // `selected`, a deep-link/notification (pendingOpenSession), or a user pick — and, since
99+ // the latch is set the moment any of those wins, it never re-selects after a user clears
100+ // the pane (BackHandler) or a server switch nulls `selected`.
101+ var autoSelected by remember { mutableStateOf(false) }
102+ LaunchedEffect(sessionListState.loading, sessionListState.sessions, pendingOpenSession, selected) {
103+ if (autoSelected || sessionListState.loading) return@LaunchedEffect
104+ // A pending deep-link or an existing selection takes precedence; yield the pane to it.
105+ if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
106+ // Pick by recency regardless of the list's current sort mode.
107+ val id = mostRecentSessionId(sessionListState.sessions) ?: return@LaunchedEffect
108+ selected = id
109+ autoSelected = true
122110 }
123111
124112 // Inject a pending share into the currently selected session's draft (if any).
@@ -157,7 +145,11 @@ fun TwoPaneSessionChat(
157145 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
158146 val sessionId = selected
159147 if (sessionId == null) {
160− EmptyDetail()
148+ // Reuse the same VM create path the session-list FAB uses; open the new
149+ // session straight into the detail pane.
150+ EmptyDetail(onNewSession = {
151+ sessionListVm.createSession(directory = null) { id -> selected = id }
152+ })
161153 } else {
162154 BackHandler { selected = null }
163155 // key() on the session id so switching conversations in two-pane mode
@@ -181,23 +173,60 @@ fun TwoPaneSessionChat(
181173 }
182174}
183175
176+/** Id of the most recently updated (or created) session, or null when the list is empty. */
177+private fun mostRecentSessionId(sessions: List<Session>): String? =
178+ sessions.maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }?.id
179+
180+/** Whether a session with [id] is present in [sessions]. */
181+private fun containsSession(sessions: List<Session>, id: String): Boolean =
182+ sessions.any { it.id == id }
183+
184+/**
185+ * Clears a stale detail-pane selection. Two guards:
186+ *
187+ * - Clear only when the open session is *deleted* — present in the list, then dropped out.
188+ * A freshly created session sets `selected` before its async refresh lands, so the id is
189+ * legitimately absent for a moment; the `hasAppeared` latch (reset when `selected` changes)
190+ * distinguishes "never appeared yet" (keep) from "appeared, then removed" (clear).
191+ * - The `hasAppeared` latch can't survive process death, so a `selected` restored across a
192+ * kill whose session was deleted server-side while dead never "appears" and would be kept
193+ * forever (permanent load error). Once the first list load completes without the restored
194+ * target, clear it. Runs once (non-saveable flag, re-arms after death) and only on a
195+ * successful, completed load — not mid-load, and not for a freshly-created pending row.
196+ */
184197@Composable
185−private fun EmptyDetail() {
186− Column(
187− modifier = Modifier.fillMaxSize().padding(32.dp),
188− verticalArrangement = Arrangement.Center,
189− horizontalAlignment = Alignment.CenterHorizontally,
190− ) {
191− Icon(
192− Icons.AutoMirrored.Filled.Chat,
193− contentDescription = null,
194− tint = MaterialTheme.colorScheme.outline,
195− )
196− Text(
197− stringResource(R.string.empty_detail_pane),
198− style = MaterialTheme.typography.bodyLarge,
199− color = MaterialTheme.colorScheme.onSurfaceVariant,
200− modifier = Modifier.padding(top = 12.dp),
201− )
198+private fun StaleSelectionCleanup(
199+ loading: Boolean,
200+ error: String?,
201+ sessions: List<Session>,
202+ selected: String?,
203+ onClear: () -> Unit,
204+) {
205+ var hasAppeared by remember(selected) { mutableStateOf(false) }
206+ LaunchedEffect(selected, sessions) {
207+ val target = selected ?: return@LaunchedEffect
208+ if (containsSession(sessions, target)) hasAppeared = true
209+ else if (hasAppeared) onClear()
210+ }
211+ var initialSelectionValidated by remember { mutableStateOf(false) }
212+ LaunchedEffect(loading, error, sessions) {
213+ if (initialSelectionValidated) return@LaunchedEffect
214+ if (loading || error != null) return@LaunchedEffect
215+ initialSelectionValidated = true
216+ val target = selected ?: return@LaunchedEffect
217+ if (!containsSession(sessions, target)) onClear()
202218 }
203219}
220+
221+@Composable
222+private fun EmptyDetail(onNewSession: () -> Unit) {
223+ EmptyState(
224+ icon = Icons.AutoMirrored.Filled.Chat,
225+ title = stringResource(R.string.empty_detail_title),
226+ description = stringResource(R.string.empty_detail_pane),
227+ actionIcon = Icons.Filled.Add,
228+ actionLabel = stringResource(R.string.new_session),
229+ onAction = onNewSession,
230+ modifier = Modifier.fillMaxSize(),
231+ )
232+}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−9
@@ -20,7 +20,6 @@ import androidx.compose.foundation.lazy.items
2020import androidx.compose.foundation.rememberScrollState
2121import androidx.compose.foundation.verticalScroll
2222import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.automirrored.filled.ArrowBack
2423import androidx.compose.material.icons.filled.BugReport
2524import androidx.compose.material.icons.filled.DeleteSweep
2625import androidx.compose.material.icons.filled.Share
@@ -40,7 +39,6 @@ import androidx.compose.material3.SwipeToDismissBox
4039import androidx.compose.material3.SwipeToDismissBoxValue
4140import androidx.compose.material3.Text
4241import androidx.compose.material3.TextButton
43−import androidx.compose.material3.TopAppBar
4442import androidx.compose.material3.rememberSwipeToDismissBoxState
4543import androidx.compose.runtime.Composable
4644import androidx.compose.runtime.CompositionLocalProvider
@@ -60,6 +58,7 @@ import kotlinx.coroutines.flow.collectLatest
6058import kotlinx.coroutines.launch
6159import kotlinx.coroutines.withContext
6260import soy.iko.opencode.util.runCatchingCancellable
61+import soy.iko.opencode.ui.components.AppTopBar
6362import soy.iko.opencode.ui.components.showToast
6463import androidx.compose.ui.Alignment
6564import androidx.compose.ui.Modifier
@@ -141,13 +140,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
141140
142141 Scaffold(
143142 topBar = {
144− TopAppBar(
145− title = { Text(stringResource(R.string.diagnostics)) },
146− navigationIcon = {
147− IconButton(onClick = onBack) {
148− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
149− }
150− },
143+ AppTopBar(
144+ title = stringResource(R.string.diagnostics),
145+ onBack = onBack,
151146 actions = {
152147 if (reports.isNotEmpty()) {
153148 IconButton(onClick = {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+57−9
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Download
3030import androidx.compose.material.icons.filled.Hub
3131import androidx.compose.material.icons.filled.QueryStats
3232import androidx.compose.material.icons.filled.Upload
33+import androidx.compose.material3.AlertDialog
3334import androidx.compose.material3.ExperimentalMaterial3Api
3435import androidx.compose.material3.CircularProgressIndicator
3536import androidx.compose.material3.HorizontalDivider
@@ -151,6 +152,11 @@ fun SettingsScreen(
151152 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
152153 // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
153154 var includePasswords by rememberSaveable { mutableStateOf(false) }
155+ // Import overwrites all profiles/settings with no undo, so a picked file is staged here
156+ // and only applied once the user confirms the replace dialog below. Export with passwords
157+ // included warns first (plaintext credentials leave the app).
158+ var pendingImportUri by remember { mutableStateOf<android.net.Uri?>(null) }
159+ var showExportPasswordWarning by rememberSaveable { mutableStateOf(false) }
154160 val exportedMsg = stringResource(R.string.backup_exported)
155161 val exportFailedMsg = stringResource(R.string.backup_export_failed)
156162 val importedMsg = stringResource(R.string.backup_imported)
@@ -172,14 +178,8 @@ fun SettingsScreen(
172178 ActivityResultContracts.OpenDocument(),
173179 ) { uri ->
174180 if (uri == null) return@rememberLauncherForActivityResult
175− scope.launch {
176− val ok = runCatchingCancellable {
177− val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
178− ?: error("no input stream")
179− container.backupManager.import(text)
180− }.isSuccess
181− showToast(context, if (ok) importedMsg else importFailedMsg)
182− }
181+ // Stage the file; the actual destructive import waits on the confirm dialog.
182+ pendingImportUri = uri
183183 }
184184
185185 Scaffold(
@@ -464,7 +464,12 @@ fun SettingsScreen(
464464 icon = Icons.Filled.Upload,
465465 label = stringResource(R.string.export_backup),
466466 enabled = true,
467− onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
467+ // Warn before writing plaintext credentials out of the app; confirm then launches
468+ // the file picker. Without passwords, export straight away.
469+ onClick = {
470+ if (includePasswords) showExportPasswordWarning = true
471+ else runCatching { exportLauncher.launch("opencode-backup.json") }
472+ },
468473 )
469474 NavRow(
470475 icon = Icons.Filled.Download,
@@ -518,6 +523,49 @@ fun SettingsScreen(
518523 }
519524 }
520525 }
526+
527+ // Destructive-import confirmation: applies the staged file only on Replace.
528+ pendingImportUri?.let { uri ->
529+ AlertDialog(
530+ onDismissRequest = { pendingImportUri = null },
531+ title = { Text(stringResource(R.string.import_confirm_title)) },
532+ text = { Text(stringResource(R.string.import_confirm_text)) },
533+ confirmButton = {
534+ TextButton(onClick = {
535+ pendingImportUri = null
536+ scope.launch {
537+ val ok = runCatchingCancellable {
538+ val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
539+ ?: error("no input stream")
540+ container.backupManager.import(text)
541+ }.isSuccess
542+ showToast(context, if (ok) importedMsg else importFailedMsg)
543+ }
544+ }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
545+ },
546+ dismissButton = {
547+ TextButton(onClick = { pendingImportUri = null }) { Text(stringResource(R.string.cancel)) }
548+ },
549+ )
550+ }
551+
552+ // Plaintext-password export warning: only launches the file picker on confirm.
553+ if (showExportPasswordWarning) {
554+ AlertDialog(
555+ onDismissRequest = { showExportPasswordWarning = false },
556+ title = { Text(stringResource(R.string.export_passwords_title)) },
557+ text = { Text(stringResource(R.string.export_passwords_warning)) },
558+ confirmButton = {
559+ TextButton(onClick = {
560+ showExportPasswordWarning = false
561+ runCatching { exportLauncher.launch("opencode-backup.json") }
562+ }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
563+ },
564+ dismissButton = {
565+ TextButton(onClick = { showExportPasswordWarning = false }) { Text(stringResource(R.string.cancel)) }
566+ },
567+ )
568+ }
521569}
522570
523571/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+5−21
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.size
1313import androidx.compose.foundation.lazy.LazyColumn
1414import androidx.compose.foundation.lazy.items
1515import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.automirrored.filled.ArrowBack
1716import androidx.compose.material.icons.filled.Refresh
1817import androidx.compose.material3.Button
1918import androidx.compose.material3.Card
@@ -24,14 +23,11 @@ import androidx.compose.material3.IconButton
2423import androidx.compose.material3.MaterialTheme
2524import androidx.compose.material3.Scaffold
2625import androidx.compose.material3.Text
27−import androidx.compose.material3.TopAppBar
2826import androidx.compose.runtime.Composable
2927import androidx.compose.runtime.getValue
3028import androidx.compose.ui.Alignment
3129import androidx.compose.ui.Modifier
3230import androidx.compose.ui.res.stringResource
33−import androidx.compose.ui.semantics.heading
34−import androidx.compose.ui.semantics.semantics
3531import androidx.compose.ui.text.style.TextOverflow
3632import androidx.compose.ui.unit.dp
3733import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -39,6 +35,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
3935import soy.iko.opencode.R
4036import soy.iko.opencode.data.model.Tokens
4137import soy.iko.opencode.di.AppContainer
38+import soy.iko.opencode.ui.components.AppTopBar
39+import soy.iko.opencode.ui.components.SectionHeader
4240import soy.iko.opencode.ui.vmFactory
4341import java.text.NumberFormat
4442import java.util.Locale
@@ -51,13 +49,9 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
5149
5250 Scaffold(
5351 topBar = {
54− TopAppBar(
55− title = { Text(stringResource(R.string.usage_title)) },
56− navigationIcon = {
57− IconButton(onClick = onBack) {
58− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
59− }
60− },
52+ AppTopBar(
53+ title = stringResource(R.string.usage_title),
54+ onBack = onBack,
6155 actions = {
6256 IconButton(onClick = { vm.load() }) {
6357 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
@@ -181,16 +175,6 @@ private fun StatRow(label: String, value: String) {
181175 }
182176}
183177
184−@Composable
185−private fun SectionHeader(text: String) {
186− Text(
187− text,
188− style = MaterialTheme.typography.titleSmall,
189− color = MaterialTheme.colorScheme.primary,
190− modifier = Modifier.padding(top = 8.dp).semantics { heading() },
191− )
192−}
193−
194178@Composable
195179private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
196180 Row(
app/src/main/res/values-es/strings.xml+25
@@ -469,4 +469,29 @@
469469 <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
470470 <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
471471 <string name="cert_pin_invalid">El pin debe ser sha256/&lt;base64&gt;</string>
472+
473+ <!-- Chat -->
474+ <string name="attachment_staging">Adjuntando…</string>
475+ <string name="new_messages">Mensajes nuevos</string>
476+
477+ <!-- Session list -->
478+ <string name="session_archived">Sesión archivada</string>
479+ <string name="session_unarchived">Sesión desarchivada</string>
480+ <string name="session_pinned_msg">Sesión fijada</string>
481+ <string name="session_unpinned_msg">Sesión desfijada</string>
482+ <string name="session_archived_badge">Archivada</string>
483+ <string name="sort_active">Orden activo</string>
484+ <string name="sort_descending">Descendente</string>
485+ <string name="sort_ascending">Ascendente</string>
486+ <string name="empty_detail_title">Ninguna conversación abierta</string>
487+
488+ <!-- File view -->
489+ <string name="switch_to_raw_to_search">Cambia a Sin formato para buscar</string>
490+
491+ <!-- Settings backup/restore -->
492+ <string name="import_confirm_title">¿Reemplazar todos los datos?</string>
493+ <string name="import_confirm_text">Esto reemplazará tus servidores y ajustes con el contenido de esta copia de seguridad. No se puede deshacer.</string>
494+ <string name="import_confirm_replace">Reemplazar</string>
495+ <string name="export_passwords_title">¿Exportar contraseñas en texto plano?</string>
496+ <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>
472497</resources>
app/src/main/res/values/strings.xml+25
@@ -471,4 +471,29 @@
471471 <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
472472 <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>
473473 <string name="cert_pin_invalid">Pin must look like sha256/&lt;base64&gt;</string>
474+
475+ <!-- Chat -->
476+ <string name="attachment_staging">Attaching…</string>
477+ <string name="new_messages">New messages</string>
478+
479+ <!-- Session list -->
480+ <string name="session_archived">Session archived</string>
481+ <string name="session_unarchived">Session unarchived</string>
482+ <string name="session_pinned_msg">Session pinned</string>
483+ <string name="session_unpinned_msg">Session unpinned</string>
484+ <string name="session_archived_badge">Archived</string>
485+ <string name="sort_active">Active sort</string>
486+ <string name="sort_descending">Descending</string>
487+ <string name="sort_ascending">Ascending</string>
488+ <string name="empty_detail_title">No conversation open</string>
489+
490+ <!-- File view -->
491+ <string name="switch_to_raw_to_search">Switch to Raw to search</string>
492+
493+ <!-- Settings backup/restore -->
494+ <string name="import_confirm_title">Replace all data?</string>
495+ <string name="import_confirm_text">This will replace your servers and settings with the contents of this backup. This can\'t be undone.</string>
496+ <string name="import_confirm_replace">Replace</string>
497+ <string name="export_passwords_title">Export plaintext passwords?</string>
498+ <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>
474499</resources>