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
acb4dcciko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+22
@@ -200,8 +200,30 @@ object NetworkConfig {
200 const val twoPaneWidthThresholdDp = 840
200 const val twoPaneWidthThresholdDp = 840
201 /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
201 /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
202 const val userBubbleWidthFraction = 0.85f
202 const val userBubbleWidthFraction = 0.85f
203+ /** 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
203 /** Left pane weight in the two-pane layout (session list). */
206 /** Left pane weight in the two-pane layout (session list). */
204 const val twoPaneLeftWeight = 0.38f
207 const val twoPaneLeftWeight = 0.38f
205 /** Right pane weight in the two-pane layout (chat detail). */
208 /** Right pane weight in the two-pane layout (chat detail). */
206 const val twoPaneRightWeight = 0.62f
209 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
207}
229}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+27−5
@@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.rememberCoroutineScope
14import androidx.compose.runtime.rememberCoroutineScope
15import androidx.compose.runtime.saveable.rememberSaveable
15import androidx.compose.runtime.saveable.rememberSaveable
16import androidx.compose.runtime.setValue
16import androidx.compose.runtime.setValue
17+import androidx.compose.ui.platform.LocalDensity
17import androidx.compose.ui.unit.dp
18import androidx.compose.ui.unit.dp
18import androidx.navigation.NavType
19import androidx.navigation.NavType
19import androidx.navigation.compose.NavHost
20import androidx.navigation.compose.NavHost
@@ -116,8 +117,23 @@ fun OpencodeApp(container: AppContainer) {
116 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
117 navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
117 }
118 }
118
119
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+
119 CompositionLocalProvider(
135 CompositionLocalProvider(
120− LocalChatTextScale provides chatTextScale,
136+ LocalChatTextScale provides clampedChatTextScale,
121 LocalCodeWrap provides codeWrap,
137 LocalCodeWrap provides codeWrap,
122 ) {
138 ) {
123 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
139 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
@@ -165,10 +181,16 @@ fun OpencodeApp(container: AppContainer) {
165 startDestination = Routes.SERVERS,
181 startDestination = Routes.SERVERS,
166 // Slide horizontally for forward/back navigation to feel native; fade for
182 // Slide horizontally for forward/back navigation to feel native; fade for
167 // the root so the first frame doesn't slide in from off-screen.
183 // the root so the first frame doesn't slide in from off-screen.
184+ enterTransition = {
168− enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
185+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
186+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
187+ },
169− exitTransition = { fadeOut(tween(180)) },
188+ exitTransition = { fadeOut(tween(NetworkConfig.motionFadeDurationMs)) },
170− popEnterTransition = { fadeIn(tween(180)) },
189+ popEnterTransition = { fadeIn(tween(NetworkConfig.motionFadeDurationMs)) },
190+ popExitTransition = {
171− popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
191+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
192+ fadeOut(tween(NetworkConfig.motionFadeDurationMs))
193+ },
172 ) {
194 ) {
173
195
174 composable(Routes.SERVERS) {
196 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
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.lazy.rememberLazyListState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
@@ -25,6 +26,7 @@ import androidx.compose.material3.OutlinedTextField
25import androidx.compose.material3.Text
26import androidx.compose.material3.Text
26import androidx.compose.material3.TextButton
27import androidx.compose.material3.TextButton
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.getValue
30import androidx.compose.runtime.getValue
29import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.mutableStateOf
30import androidx.compose.runtime.remember
32import androidx.compose.runtime.remember
@@ -32,6 +34,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
32import androidx.compose.runtime.setValue
34import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
35import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
36import 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
35import androidx.compose.ui.platform.LocalSoftwareKeyboardController
41import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36import androidx.compose.ui.res.stringResource
42import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.semantics.Role
43import androidx.compose.ui.semantics.Role
@@ -45,6 +51,28 @@ import androidx.compose.foundation.layout.Box
45import soy.iko.opencode.data.model.Agent
51import soy.iko.opencode.data.model.Agent
46import soy.iko.opencode.R
52import soy.iko.opencode.R
47
53
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+
48/** Bottom sheet that lists every agent and lets the user pick one. */
76/** Bottom sheet that lists every agent and lets the user pick one. */
49@OptIn(ExperimentalMaterial3Api::class)
77@OptIn(ExperimentalMaterial3Api::class)
50@Composable
78@Composable
@@ -93,10 +121,14 @@ fun AgentPickerSheet(
93 } else {
121 } else {
94 var query by rememberSaveable { mutableStateOf("") }
122 var query by rememberSaveable { mutableStateOf("") }
95 val keyboardController = LocalSoftwareKeyboardController.current
123 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() } }
96 OutlinedTextField(
128 OutlinedTextField(
97 value = query,
129 value = query,
98 onValueChange = { query = it },
130 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),
100 placeholder = { Text(stringResource(R.string.search_agents)) },
132 placeholder = { Text(stringResource(R.string.search_agents)) },
101 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
133 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102 trailingIcon = if (query.isNotEmpty()) {
134 trailingIcon = if (query.isNotEmpty()) {
@@ -125,9 +157,7 @@ fun AgentPickerSheet(
125 val defaultLabel = stringResource(R.string.default_agent)
157 val defaultLabel = stringResource(R.string.default_agent)
126 val defaultDesc = stringResource(R.string.default_agent_desc)
158 val defaultDesc = stringResource(R.string.default_agent_desc)
127 val q = query.trim()
159 val q = query.trim()
128− val defaultMatches = q.isEmpty() ||
160+ val defaultMatches = defaultAgentMatches(q, defaultLabel, defaultDesc)
129− defaultLabel.contains(q, ignoreCase = true) ||
130− defaultDesc.contains(q, ignoreCase = true)
131 if (filtered.isEmpty() && !defaultMatches) {
161 if (filtered.isEmpty() && !defaultMatches) {
132 Text(
162 Text(
133 stringResource(R.string.no_agents_match, query.trim()),
163 stringResource(R.string.no_agents_match, query.trim()),
@@ -136,7 +166,18 @@ fun AgentPickerSheet(
136 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
166 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
137 )
167 )
138 } else {
168 } else {
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+ }
139− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
180+ LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
140 if (defaultMatches) {
181 if (defaultMatches) {
141 item(key = "__default") {
182 item(key = "__default") {
142 Column(
183 Column(
@@ -145,7 +186,10 @@ fun AgentPickerSheet(
145 .semantics(mergeDescendants = true) {
186 .semantics(mergeDescendants = true) {
146 if (selected == null) this.selected = true
187 if (selected == null) this.selected = true
147 }
188 }
148− .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
189+ .clickable(role = Role.RadioButton) {
190+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
191+ onSelect(null); onDismiss()
192+ }
149 .padding(horizontal = 24.dp, vertical = 12.dp),
193 .padding(horizontal = 24.dp, vertical = 12.dp),
150 ) {
194 ) {
151 Row(verticalAlignment = Alignment.CenterVertically) {
195 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -178,7 +222,7 @@ fun AgentPickerSheet(
178 // Namespace the agent keys so an agent literally named "__default" can't
222 // Namespace the agent keys so an agent literally named "__default" can't
179 // collide with the sentinel key of the default item above (a duplicate key
223 // collide with the sentinel key of the default item above (a duplicate key
180 // crashes LazyColumn).
224 // crashes LazyColumn).
181− items(filtered.distinctBy { it.name }, key = { "agent_" + it.name }) { agent ->
225+ items(distinctAgents, key = { "agent_" + it.name }) { agent ->
182 val isSelected = agent.name == selected
226 val isSelected = agent.name == selected
183 Column(
227 Column(
184 modifier = Modifier
228 modifier = Modifier
@@ -186,7 +230,10 @@ fun AgentPickerSheet(
186 .semantics(mergeDescendants = true) {
230 .semantics(mergeDescendants = true) {
187 if (isSelected) this.selected = true
231 if (isSelected) this.selected = true
188 }
232 }
189− .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
233+ .clickable(role = Role.RadioButton) {
234+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
235+ onSelect(agent); onDismiss()
236+ }
190 .padding(horizontal = 24.dp, vertical = 12.dp),
237 .padding(horizontal = 24.dp, vertical = 12.dp),
191 ) {
238 ) {
192 Row(verticalAlignment = Alignment.CenterVertically) {
239 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
11import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.filled.Close
12import androidx.compose.material.icons.filled.Close
13import androidx.compose.material.icons.filled.Description
13import androidx.compose.material.icons.filled.Description
14+import androidx.compose.material3.CircularProgressIndicator
14import androidx.compose.material3.Icon
15import androidx.compose.material3.Icon
15import androidx.compose.material3.IconButton
16import androidx.compose.material3.IconButton
16import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.MaterialTheme
@@ -22,6 +23,8 @@ import androidx.compose.ui.Modifier
22import androidx.compose.ui.draw.clip
23import androidx.compose.ui.draw.clip
23import androidx.compose.ui.layout.ContentScale
24import androidx.compose.ui.layout.ContentScale
24import androidx.compose.ui.res.stringResource
25import androidx.compose.ui.res.stringResource
26+import androidx.compose.ui.semantics.contentDescription
27+import androidx.compose.ui.semantics.semantics
25import androidx.compose.ui.text.style.TextOverflow
28import androidx.compose.ui.text.style.TextOverflow
26import androidx.compose.ui.unit.dp
29import androidx.compose.ui.unit.dp
27import coil.compose.AsyncImage
30import coil.compose.AsyncImage
@@ -32,8 +35,14 @@ import soy.iko.opencode.R
32 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
35 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
33 */
36 */
34@Composable
37@Composable
38+internal fun AttachmentStrip(
35−internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (String) -> Unit) {
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.
36− if (attachments.isEmpty()) return
45+ if (attachments.isEmpty() && !staging) return
37 // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
46 // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
38 Row(
47 Row(
39 modifier = Modifier
48 modifier = Modifier
@@ -45,6 +54,36 @@ internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (St
45 attachments.forEach { att ->
54 attachments.forEach { att ->
46 AttachmentChip(att, onRemove = { onRemove(att.id) })
55 AttachmentChip(att, onRemove = { onRemove(att.id) })
47 }
56 }
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+ }
48 }
87 }
49}
88}
50
89
@@ -80,7 +119,9 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
80 overflow = TextOverflow.Ellipsis,
119 overflow = TextOverflow.Ellipsis,
81 modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
120 modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
82 )
121 )
122+ // No .size() override so the IconButton keeps its default 48dp touch target
123+ // (a11y minimum) while the visual X stays small (16dp).
83− IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
124+ IconButton(onClick = onRemove) {
84 Icon(
125 Icon(
85 Icons.Filled.Close,
126 Icons.Filled.Close,
86 contentDescription = stringResource(R.string.remove),
127 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
46import androidx.compose.material.icons.filled.Restore
46import androidx.compose.material.icons.filled.Restore
47import androidx.compose.material.icons.filled.Stop
47import androidx.compose.material.icons.filled.Stop
48import androidx.compose.material3.AlertDialog
48import androidx.compose.material3.AlertDialog
49+import androidx.compose.material3.Badge
50+import androidx.compose.material3.BadgedBox
49import androidx.compose.material3.Button
51import androidx.compose.material3.Button
50import androidx.compose.material3.CircularProgressIndicator
52import androidx.compose.material3.CircularProgressIndicator
51import androidx.compose.material3.DropdownMenu
53import androidx.compose.material3.DropdownMenu
@@ -281,21 +283,30 @@ fun ChatScreen(
281 val voicePrompt = stringResource(R.string.voice_prompt)
283 val voicePrompt = stringResource(R.string.voice_prompt)
282 val linkCopiedMsg = stringResource(R.string.link_copied)
284 val linkCopiedMsg = stringResource(R.string.link_copied)
283
285
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+
284 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
290 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
285 // per-prompt count cap and surfacing per-file errors without aborting the batch.
291 // per-prompt count cap and surfacing per-file errors without aborting the batch.
286 fun stageUris(uris: List<Uri>) {
292 fun stageUris(uris: List<Uri>) {
287 if (uris.isEmpty()) return
293 if (uris.isEmpty()) return
288 scope.launch {
294 scope.launch {
295+ stagingCount++
296+ try {
289− for (uri in uris) {
297+ for (uri in uris) {
290− if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
298+ if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
291− snackbar.showSnackbar(attachLimitMsg)
299+ snackbar.showSnackbar(attachLimitMsg)
292− break
300+ break
293− }
301+ }
294− when (val result = uri.toAttachmentResult(appContext)) {
302+ when (val result = uri.toAttachmentResult(appContext)) {
295− is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
303+ is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
296− AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
304+ AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
297− AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
305+ AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
306+ }
298 }
307 }
308+ } finally {
309+ stagingCount--
299 }
310 }
300 }
311 }
301 }
312 }
@@ -659,6 +670,7 @@ fun ChatScreen(
659 onCancelQueue = { vm.queueFollowUp("") },
670 onCancelQueue = { vm.queueFollowUp("") },
660 focusRequester = inputFocusRequester,
671 focusRequester = inputFocusRequester,
661 attachments = attachments,
672 attachments = attachments,
673+ staging = stagingCount > 0,
662 onRemoveAttachment = vm::removeAttachment,
674 onRemoveAttachment = vm::removeAttachment,
663 onPickPhoto = {
675 onPickPhoto = {
664 photoPicker.launch(
676 photoPicker.launch(
@@ -700,6 +712,10 @@ fun ChatScreen(
700 // whose value tracks the latest list.
712 // whose value tracks the latest list.
701 val currentListItems by rememberUpdatedState(listItems)
713 val currentListItems by rememberUpdatedState(listItems)
702
714
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+
703 val isPinnedToBottom by remember {
719 val isPinnedToBottom by remember {
704 derivedStateOf {
720 derivedStateOf {
705 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
721 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
@@ -746,6 +762,8 @@ fun ChatScreen(
746 }
762 }
747
763
748 LaunchedEffect(Unit) {
764 LaunchedEffect(Unit) {
765+ var prevSize = 0
766+ var prevLen = 0
749 snapshotFlow {
767 snapshotFlow {
750 // Track list size + the streaming length of the last part so we auto-scroll
768 // Track list size + the streaming length of the last part so we auto-scroll
751 // as content arrives, plus the pinned flag. A small data class with primitive
769 // as content arrives, plus the pinned flag. A small data class with primitive
@@ -757,14 +775,23 @@ fun ChatScreen(
757 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
775 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
758 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
776 AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
759 }.collect { signal ->
777 }.collect { signal ->
760− if (signal.size > 0 && signal.pinned) {
778+ if (signal.pinned) {
779+ // Back at the bottom: clear the new-content badge.
780+ hasNewContent = false
781+ if (signal.size > 0) {
761− // Scroll to the effective last index, including the trailing
782+ // Scroll to the effective last index, including the trailing
762− // "__typing" row when a run is active so the working indicator
783+ // "__typing" row when a run is active so the working indicator
763− // is brought into view (not just the last message).
784+ // is brought into view (not just the last message).
764− val items = currentListItems
785+ val items = currentListItems
765− val target = if (running) items.size else items.lastIndex
786+ val target = if (running) items.size else items.lastIndex
766− listState.scrollToItem(target)
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
767 }
792 }
793+ prevSize = signal.size
794+ prevLen = signal.lastTextLength
768 }
795 }
769 }
796 }
770
797
@@ -875,7 +902,14 @@ fun ChatScreen(
875 LazyColumn(
902 LazyColumn(
876 state = listState,
903 state = listState,
877 modifier = Modifier.fillMaxSize(),
904 modifier = Modifier.fillMaxSize(),
905+ // Extra bottom padding reserves room for the jump-to-latest FAB so it
906+ // never floats over the last message.
878− contentPadding = PaddingValues(16.dp),
907+ contentPadding = PaddingValues(
908+ start = 16.dp,
909+ top = 16.dp,
910+ end = 16.dp,
911+ bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
912+ ),
879 verticalArrangement = Arrangement.spacedBy(16.dp),
913 verticalArrangement = Arrangement.spacedBy(16.dp),
880 ) {
914 ) {
881 items(
915 items(
@@ -901,7 +935,20 @@ fun ChatScreen(
901 modelLabel = modelLabel,
935 modelLabel = modelLabel,
902 onOpenFile = onOpenFile,
936 onOpenFile = onOpenFile,
903 onRevert = { vm.revertTo(message.info.id) },
937 onRevert = { vm.revertTo(message.info.id) },
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)
904− onEdit = { text -> vm.editMessage(message.info.id, text) },
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+ },
905 onSpeak = { text -> tts.toggle(message.info.id, text) },
952 onSpeak = { text -> tts.toggle(message.info.id, text) },
906 isSpeaking = message.info.id == speakingMessageId,
953 isSpeaking = message.info.id == speakingMessageId,
907 onQuote = { text ->
954 onQuote = { text ->
@@ -934,19 +981,31 @@ fun ChatScreen(
934 visible = !isPinnedToBottom && listItems.isNotEmpty(),
981 visible = !isPinnedToBottom && listItems.isNotEmpty(),
935 modifier = Modifier.align(Alignment.BottomEnd),
982 modifier = Modifier.align(Alignment.BottomEnd),
936 ) {
983 ) {
937− ExtendedFloatingActionButton(
984+ // Badge the FAB when new content arrived while the user was scrolled up, so
938− onClick = {
985+ // they know there's something new to jump to (cleared once back at bottom).
939− if (listItems.isNotEmpty()) {
986+ BadgedBox(
940− // Scroll to the effective last index, including the
987+ badge = {
941− // trailing "__typing" row when a run is active.
988+ if (hasNewContent) {
942− val target = if (running) listItems.size else listItems.lastIndex
989+ val newContentLabel = stringResource(R.string.new_messages)
943− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
990+ Badge(modifier = Modifier.semantics { contentDescription = newContentLabel })
944 }
991 }
945 },
992 },
946− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
947− text = { Text(stringResource(R.string.latest)) },
948 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
993 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+ }
950 }
1009 }
951 }
1010 }
952 }
1011 }
@@ -1092,6 +1151,7 @@ private fun ChatInputBar(
1092 onCancelQueue: () -> Unit,
1151 onCancelQueue: () -> Unit,
1093 focusRequester: androidx.compose.ui.focus.FocusRequester,
1152 focusRequester: androidx.compose.ui.focus.FocusRequester,
1094 attachments: List<PendingAttachment>,
1153 attachments: List<PendingAttachment>,
1154+ staging: Boolean,
1095 onRemoveAttachment: (String) -> Unit,
1155 onRemoveAttachment: (String) -> Unit,
1096 onPickPhoto: () -> Unit,
1156 onPickPhoto: () -> Unit,
1097 onPickFile: () -> Unit,
1157 onPickFile: () -> Unit,
@@ -1131,7 +1191,7 @@ private fun ChatInputBar(
1131 }
1191 }
1132 }
1192 }
1133 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1193 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1134− AttachmentStrip(attachments, onRemove = onRemoveAttachment)
1194+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1135 Row(
1195 Row(
1136 modifier = Modifier.fillMaxWidth().padding(8.dp),
1196 modifier = Modifier.fillMaxWidth().padding(8.dp),
1137 verticalAlignment = Alignment.Bottom,
1197 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
24import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.AlertDialog
25import androidx.compose.material3.TextButton
25import androidx.compose.material3.TextButton
26import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.LaunchedEffect
27import androidx.compose.runtime.getValue
28import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.remember
@@ -31,6 +32,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
31import androidx.compose.runtime.setValue
32import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import 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
34import androidx.compose.ui.platform.LocalSoftwareKeyboardController
39import androidx.compose.ui.platform.LocalSoftwareKeyboardController
35import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.Role
@@ -96,10 +101,14 @@ fun CommandPickerSheet(
96 } else {
101 } else {
97 var query by rememberSaveable { mutableStateOf("") }
102 var query by rememberSaveable { mutableStateOf("") }
98 val keyboardController = LocalSoftwareKeyboardController.current
103 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() } }
99 OutlinedTextField(
108 OutlinedTextField(
100 value = query,
109 value = query,
101 onValueChange = { query = it },
110 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),
103 placeholder = { Text(stringResource(R.string.search_commands)) },
112 placeholder = { Text(stringResource(R.string.search_commands)) },
104 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
113 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105 trailingIcon = if (query.isNotEmpty()) {
114 trailingIcon = if (query.isNotEmpty()) {
@@ -137,6 +146,7 @@ fun CommandPickerSheet(
137 modifier = Modifier
146 modifier = Modifier
138 .fillMaxWidth()
147 .fillMaxWidth()
139 .clickable(role = Role.Button) {
148 .clickable(role = Role.Button) {
149+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
140 if (isDestructiveCommand(cmd.name)) {
150 if (isDestructiveCommand(cmd.name)) {
141 pendingCommandName = cmd.name
151 pendingCommandName = cmd.name
142 } else {
152 } else {
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+32−6
@@ -32,7 +32,9 @@ import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
35+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
35import androidx.compose.ui.platform.LocalContext
36import androidx.compose.ui.platform.LocalContext
37+import androidx.compose.ui.platform.LocalHapticFeedback
36import androidx.compose.ui.res.stringResource
38import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
38import soy.iko.opencode.R
40import soy.iko.opencode.R
@@ -97,6 +99,7 @@ private fun MessageOverflow(
97 if (onQuote == null && onBranch == null) return
99 if (onQuote == null && onBranch == null) return
98 var expanded by remember { mutableStateOf(false) }
100 var expanded by remember { mutableStateOf(false) }
99 val moreLabel = stringResource(R.string.message_actions)
101 val moreLabel = stringResource(R.string.message_actions)
102+ val haptics = LocalHapticFeedback.current
100 androidx.compose.foundation.layout.Box {
103 androidx.compose.foundation.layout.Box {
101 IconButton(onClick = { expanded = true }) {
104 IconButton(onClick = { expanded = true }) {
102 Icon(
105 Icon(
@@ -111,14 +114,22 @@ private fun MessageOverflow(
111 DropdownMenuItem(
114 DropdownMenuItem(
112 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
115 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
113 text = { Text(stringResource(R.string.quote_reply)) },
116 text = { Text(stringResource(R.string.quote_reply)) },
117+ onClick = {
114− onClick = { expanded = false; quote(text) },
118+ expanded = false
119+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
120+ quote(text)
121+ },
115 )
122 )
116 }
123 }
117 onBranch?.let { branch ->
124 onBranch?.let { branch ->
118 DropdownMenuItem(
125 DropdownMenuItem(
119 leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
126 leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
120 text = { Text(stringResource(R.string.branch_session)) },
127 text = { Text(stringResource(R.string.branch_session)) },
128+ onClick = {
121− onClick = { expanded = false; branch(text) },
129+ expanded = false
130+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
131+ branch(text)
132+ },
122 )
133 )
123 }
134 }
124 }
135 }
@@ -131,7 +142,11 @@ private fun MessageOverflow(
131@Composable
142@Composable
132private fun RevertButton(onRevert: () -> Unit) {
143private fun RevertButton(onRevert: () -> Unit) {
133 val label = stringResource(R.string.revert_to_here)
144 val label = stringResource(R.string.revert_to_here)
145+ val haptics = LocalHapticFeedback.current
134− IconButton(onClick = onRevert) {
146+ IconButton(onClick = {
147+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
148+ onRevert()
149+ }) {
135 Icon(
150 Icon(
136 Icons.Filled.Restore,
151 Icons.Filled.Restore,
137 contentDescription = label,
152 contentDescription = label,
@@ -194,6 +209,7 @@ private fun UserBubble(
194 onBranch: ((String) -> Unit)? = null,
209 onBranch: ((String) -> Unit)? = null,
195) {
210) {
196 val context = LocalContext.current
211 val context = LocalContext.current
212+ val haptics = LocalHapticFeedback.current
197 val copyLabel = stringResource(R.string.copy)
213 val copyLabel = stringResource(R.string.copy)
198 val editLabel = stringResource(R.string.edit_message)
214 val editLabel = stringResource(R.string.edit_message)
199 // Collect text from all TextParts for copying, so a user can reuse/repost their
215 // Collect text from all TextParts for copying, so a user can reuse/repost their
@@ -243,7 +259,10 @@ private fun UserBubble(
243 // long-press on the markdown text (and only TextParts support that).
259 // long-press on the markdown text (and only TextParts support that).
244 if (textToCopy != null) {
260 if (textToCopy != null) {
245 IconButton(
261 IconButton(
262+ onClick = {
263+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
246− onClick = { copyToClipboard(context, "message", textToCopy) },
264+ copyToClipboard(context, "message", textToCopy)
265+ },
247 ) {
266 ) {
248 Icon(
267 Icon(
249 Icons.Filled.ContentCopy,
268 Icons.Filled.ContentCopy,
@@ -371,13 +390,17 @@ private fun AssistantActions(
371 onQuote: ((String) -> Unit)? = null,
390 onQuote: ((String) -> Unit)? = null,
372) {
391) {
373 val context = LocalContext.current
392 val context = LocalContext.current
393+ val haptics = LocalHapticFeedback.current
374 val copyLabel = stringResource(R.string.copy)
394 val copyLabel = stringResource(R.string.copy)
375 Row(verticalAlignment = Alignment.CenterVertically) {
395 Row(verticalAlignment = Alignment.CenterVertically) {
376 // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
396 // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
377 // icon while this message is the one being spoken.
397 // icon while this message is the one being spoken.
378 if (onSpeak != null && textToCopy != null) {
398 if (onSpeak != null && textToCopy != null) {
379 val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
399 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+ }) {
381 Icon(
404 Icon(
382 if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
405 if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
383 contentDescription = speakLabel,
406 contentDescription = speakLabel,
@@ -389,7 +412,10 @@ private fun AssistantActions(
389 }
412 }
390 onRevert?.let { RevertButton(it) }
413 onRevert?.let { RevertButton(it) }
391 if (textToCopy != null) {
414 if (textToCopy != null) {
415+ IconButton(onClick = {
416+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
392− IconButton(onClick = { copyToClipboard(context, "message", textToCopy) }) {
417+ copyToClipboard(context, "message", textToCopy)
418+ }) {
393 Icon(
419 Icon(
394 Icons.Filled.ContentCopy,
420 Icons.Filled.ContentCopy,
395 contentDescription = copyLabel,
421 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
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.lazy.rememberLazyListState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
@@ -25,6 +26,7 @@ import androidx.compose.material3.OutlinedTextField
25import androidx.compose.material3.Text
26import androidx.compose.material3.Text
26import androidx.compose.material3.TextButton
27import androidx.compose.material3.TextButton
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.getValue
30import androidx.compose.runtime.getValue
29import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.mutableStateOf
30import androidx.compose.runtime.remember
32import androidx.compose.runtime.remember
@@ -32,6 +34,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
32import androidx.compose.runtime.setValue
34import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
35import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
36import 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
35import androidx.compose.ui.platform.LocalSoftwareKeyboardController
41import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36import androidx.compose.ui.res.stringResource
42import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.semantics.Role
43import androidx.compose.ui.semantics.Role
@@ -45,6 +51,29 @@ import androidx.compose.foundation.layout.Box
45import soy.iko.opencode.data.model.ModelOption
51import soy.iko.opencode.data.model.ModelOption
46import soy.iko.opencode.R
52import soy.iko.opencode.R
47
53
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+
48/** Bottom sheet that lists every provider/model and lets the user pick one. */
77/** Bottom sheet that lists every provider/model and lets the user pick one. */
49@OptIn(ExperimentalMaterial3Api::class)
78@OptIn(ExperimentalMaterial3Api::class)
50@Composable
79@Composable
@@ -93,10 +122,15 @@ fun ModelPickerSheet(
93 } else {
122 } else {
94 var query by rememberSaveable { mutableStateOf("") }
123 var query by rememberSaveable { mutableStateOf("") }
95 val keyboardController = LocalSoftwareKeyboardController.current
124 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() } }
96 OutlinedTextField(
130 OutlinedTextField(
97 value = query,
131 value = query,
98 onValueChange = { query = it },
132 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),
100 placeholder = { Text(stringResource(R.string.search_models)) },
134 placeholder = { Text(stringResource(R.string.search_models)) },
101 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
135 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102 trailingIcon = if (query.isNotEmpty()) {
136 trailingIcon = if (query.isNotEmpty()) {
@@ -129,7 +163,15 @@ fun ModelPickerSheet(
129 // Group by provider so a long catalog is scannable: a provider header
163 // Group by provider so a long catalog is scannable: a provider header
130 // followed by its models, instead of a flat list mixing providers.
164 // followed by its models, instead of a flat list mixing providers.
131 val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
165 val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
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+ }
132− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
174+ LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133 grouped.forEach { (providerID, opts) ->
175 grouped.forEach { (providerID, opts) ->
134 item(key = "header_$providerID") {
176 item(key = "header_$providerID") {
135 Text(
177 Text(
@@ -151,6 +193,7 @@ fun ModelPickerSheet(
151 if (isSelected) this.selected = true
193 if (isSelected) this.selected = true
152 }
194 }
153 .clickable(role = Role.RadioButton) {
195 .clickable(role = Role.RadioButton) {
196+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
154 onSelect(option)
197 onSelect(option)
155 onDismiss()
198 onDismiss()
156 }
199 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+27−4
@@ -4,6 +4,8 @@ import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.horizontalScroll
8+import androidx.compose.foundation.rememberScrollState
7import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.defaultMinSize
11import androidx.compose.foundation.layout.defaultMinSize
@@ -36,7 +38,9 @@ import androidx.compose.runtime.setValue
36import androidx.compose.ui.Alignment
38import androidx.compose.ui.Alignment
37import androidx.compose.ui.Modifier
39import androidx.compose.ui.Modifier
38import androidx.compose.ui.draw.clip
40import androidx.compose.ui.draw.clip
41+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
39import androidx.compose.ui.platform.LocalContext
42import androidx.compose.ui.platform.LocalContext
43+import androidx.compose.ui.platform.LocalHapticFeedback
40import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.contentDescription
42import androidx.compose.ui.semantics.semantics
46import androidx.compose.ui.semantics.semantics
@@ -155,7 +159,12 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
155 // call site already disambiguates sibling parts positionally, so a constant per
159 // call site already disambiguates sibling parts positionally, so a constant per
156 // block type won't collide.
160 // block type won't collide.
157 val saveableKey = keyId.ifBlank { "reasoning" }
161 val saveableKey = keyId.ifBlank { "reasoning" }
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.
158− var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
166+ var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
167+ val expanded = userOverride ?: streaming
159 val expandedState = stringResource(R.string.state_expanded)
168 val expandedState = stringResource(R.string.state_expanded)
160 val collapsedState = stringResource(R.string.state_collapsed)
169 val collapsedState = stringResource(R.string.state_collapsed)
161 val thinkingLabel = stringResource(R.string.thinking)
170 val thinkingLabel = stringResource(R.string.thinking)
@@ -165,7 +174,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
165 modifier = Modifier
174 modifier = Modifier
166 .fillMaxWidth()
175 .fillMaxWidth()
167 .defaultMinSize(minHeight = 48.dp)
176 .defaultMinSize(minHeight = 48.dp)
168− .clickable(role = Role.Button) { expanded = !expanded }
177+ .clickable(role = Role.Button) { userOverride = !expanded }
169 .padding(vertical = 4.dp)
178 .padding(vertical = 4.dp)
170 .semantics {
179 .semantics {
171 stateDescription = if (expanded) expandedState else collapsedState
180 stateDescription = if (expanded) expandedState else collapsedState
@@ -206,7 +215,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
206 )
215 )
207 }
216 }
208 TextButton(
217 TextButton(
218+ onClick = {
219+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
209− onClick = { copyToClipboard(context, "reasoning", text) },
220+ copyToClipboard(context, "reasoning", text)
221+ },
210 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
222 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
211 modifier = Modifier.semantics(mergeDescendants = true) {},
223 modifier = Modifier.semantics(mergeDescendants = true) {},
212 ) {
224 ) {
@@ -222,6 +234,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
222@Composable
234@Composable
223private fun ToolCallView(part: ToolPart, modifier: Modifier) {
235private fun ToolCallView(part: ToolPart, modifier: Modifier) {
224 val context = LocalContext.current
236 val context = LocalContext.current
237+ val haptics = LocalHapticFeedback.current
225 Column(
238 Column(
226 modifier = modifier
239 modifier = modifier
227 .fillMaxWidth()
240 .fillMaxWidth()
@@ -311,7 +324,10 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
311 moreLines = moreLines,
324 moreLines = moreLines,
312 expandedState = expandedState,
325 expandedState = expandedState,
313 collapsedState = collapsedState,
326 collapsedState = collapsedState,
327+ onCopy = {
328+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
314− onCopy = { copyToClipboard(context, "output", detail) },
329+ copyToClipboard(context, "output", detail)
330+ },
315 diffSaveKey = part.id,
331 diffSaveKey = part.id,
316 )
332 )
317 }
333 }
@@ -369,11 +385,15 @@ private fun CollapsibleDetail(
369 } else {
385 } else {
370 // SelectionContainer so the user can select a portion of the output (e.g.
386 // SelectionContainer so the user can select a portion of the output (e.g.
371 // a single line of stdout) instead of only copy-all via the button below.
387 // 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).
372 SelectionContainer {
390 SelectionContainer {
373 Text(
391 Text(
374 detail,
392 detail,
375 style = MaterialTheme.typography.bodySmall,
393 style = MaterialTheme.typography.bodySmall,
376 fontFamily = FontFamily.Monospace,
394 fontFamily = FontFamily.Monospace,
395+ softWrap = false,
396+ modifier = Modifier.horizontalScroll(rememberScrollState()),
377 )
397 )
378 }
398 }
379 }
399 }
@@ -449,6 +469,7 @@ private fun ToolStatusIcon(state: ToolState) {
449@Composable
469@Composable
450private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
470private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
451 val context = LocalContext.current
471 val context = LocalContext.current
472+ val haptics = LocalHapticFeedback.current
452 val path = part.sourcePath ?: part.url ?: part.filename
473 val path = part.sourcePath ?: part.url ?: part.filename
453 val copyLabel = stringResource(R.string.copy_path)
474 val copyLabel = stringResource(R.string.copy_path)
454 val openLabel = stringResource(R.string.open_file)
475 val openLabel = stringResource(R.string.open_file)
@@ -473,12 +494,14 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
473 if (toOpen != null) {
494 if (toOpen != null) {
474 opener?.invoke(toOpen)
495 opener?.invoke(toOpen)
475 } else if (!path.isNullOrBlank()) {
496 } else if (!path.isNullOrBlank()) {
497+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
476 copyToClipboard(context, "path", path)
498 copyToClipboard(context, "path", path)
477 showToast(context, context.getString(R.string.path_copied))
499 showToast(context, context.getString(R.string.path_copied))
478 }
500 }
479 },
501 },
480 onLongClick = {
502 onLongClick = {
481 if (!path.isNullOrBlank()) {
503 if (!path.isNullOrBlank()) {
504+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
482 copyToClipboard(context, "path", path)
505 copyToClipboard(context, "path", path)
483 showToast(context, context.getString(R.string.path_copied))
506 showToast(context, context.getString(R.string.path_copied))
484 }
507 }
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
76import soy.iko.opencode.di.AppContainer
76import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
77import soy.iko.opencode.R
78import soy.iko.opencode.ui.components.DiffView
78import soy.iko.opencode.ui.components.DiffView
79+import soy.iko.opencode.ui.components.LocalCodeWrap
79import soy.iko.opencode.ui.components.copyToClipboard
80import soy.iko.opencode.ui.components.copyToClipboard
80import soy.iko.opencode.ui.components.highlightLine
81import soy.iko.opencode.ui.components.highlightLine
81import soy.iko.opencode.ui.components.syntaxFor
82import soy.iko.opencode.ui.components.syntaxFor
@@ -114,7 +115,10 @@ fun FileViewScreen(
114 var findActive by rememberSaveable(path) { mutableStateOf(false) }
115 var findActive by rememberSaveable(path) { mutableStateOf(false) }
115 var findQuery by rememberSaveable(path) { mutableStateOf("") }
116 var findQuery by rememberSaveable(path) { mutableStateOf("") }
116 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
117 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
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
117− var wrap by rememberSaveable(path) { mutableStateOf(false) }
121+ var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
118 val listState = rememberLazyListState()
122 val listState = rememberLazyListState()
119 val rawText = state.content?.content.orEmpty()
123 val rawText = state.content?.content.orEmpty()
120 val lines = remember(rawText) { rawText.split("\n") }
124 val lines = remember(rawText) { rawText.split("\n") }
@@ -161,10 +165,19 @@ fun FileViewScreen(
161 loading = state.loading,
165 loading = state.loading,
162 content = rawText,
166 content = rawText,
163 wrap = wrap,
167 wrap = wrap,
164− inDiffMode = showToggle && showDiff,
165 onBack = onBack,
168 onBack = onBack,
166 onReload = { vm.reload() },
169 onReload = { vm.reload() },
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
167− onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
178+ if (!findActive) findQuery = ""
179+ }
180+ },
168 onToggleWrap = { wrap = !wrap },
181 onToggleWrap = { wrap = !wrap },
169 onCopy = { copyToClipboard(context, filename, rawText) },
182 onCopy = { copyToClipboard(context, filename, rawText) },
170 onShare = {
183 onShare = {
@@ -231,7 +244,6 @@ private fun FileViewTopBar(
231 loading: Boolean,
244 loading: Boolean,
232 content: String,
245 content: String,
233 wrap: Boolean,
246 wrap: Boolean,
234− inDiffMode: Boolean,
235 onBack: () -> Unit,
247 onBack: () -> Unit,
236 onReload: () -> Unit,
248 onReload: () -> Unit,
237 onToggleFind: () -> Unit,
249 onToggleFind: () -> Unit,
@@ -253,12 +265,11 @@ private fun FileViewTopBar(
253 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
265 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
254 }
266 }
255 if (content.isNotEmpty()) {
267 if (content.isNotEmpty()) {
256− // Find-in-file: toggles a search bar over the raw content. Disabled only when
268+ // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
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
269+ // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
259− // diff, so find would silently no-op there. A plain file with no diff (the common
270+ // neither of which is visible under a diff, so tapping it there surfaces a hint to
260− // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
271+ // switch to Raw rather than silently no-opping behind a greyed-out icon.
261− IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
272+ IconButton(onClick = onToggleFind) {
262 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
273 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
263 }
274 }
264 // Wrap long lines instead of horizontal-scrolling, useful for prose.
275 // 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
14import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.items
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.filled.CloudQueue
17import androidx.compose.material.icons.filled.CloudQueue
19import androidx.compose.material.icons.filled.Computer
18import androidx.compose.material.icons.filled.Computer
20import androidx.compose.material.icons.filled.Refresh
19import androidx.compose.material.icons.filled.Refresh
@@ -27,7 +26,6 @@ import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.Scaffold
27import androidx.compose.material3.Scaffold
29import androidx.compose.material3.Text
28import androidx.compose.material3.Text
30−import androidx.compose.material3.TopAppBar
31import androidx.compose.runtime.Composable
29import androidx.compose.runtime.Composable
32import androidx.compose.runtime.getValue
30import androidx.compose.runtime.getValue
33import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
@@ -39,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
39import androidx.lifecycle.viewmodel.compose.viewModel
37import androidx.lifecycle.viewmodel.compose.viewModel
40import soy.iko.opencode.R
38import soy.iko.opencode.R
41import soy.iko.opencode.di.AppContainer
39import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.ui.components.AppTopBar
42import soy.iko.opencode.ui.vmFactory
41import soy.iko.opencode.ui.vmFactory
43
42
44@OptIn(ExperimentalMaterial3Api::class)
43@OptIn(ExperimentalMaterial3Api::class)
@@ -49,13 +48,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
49
48
50 Scaffold(
49 Scaffold(
51 topBar = {
50 topBar = {
52− TopAppBar(
51+ AppTopBar(
53− title = { Text(stringResource(R.string.mcp_servers)) },
52+ title = stringResource(R.string.mcp_servers),
54− navigationIcon = {
53+ onBack = onBack,
55− IconButton(onClick = onBack) {
56− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
57− }
58− },
59 actions = {
54 actions = {
60 IconButton(onClick = { vm.load() }) {
55 IconButton(onClick = { vm.load() }) {
61 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
56 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
14import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardActions
15import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.filled.Close
17import androidx.compose.material.icons.filled.Close
19import androidx.compose.material.icons.filled.Search
18import androidx.compose.material.icons.filled.Search
20import androidx.compose.material3.Card
19import androidx.compose.material3.Card
@@ -26,7 +25,6 @@ import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.OutlinedTextField
25import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.Scaffold
26import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Text
27import androidx.compose.material3.Text
29−import androidx.compose.material3.TopAppBar
30import androidx.compose.runtime.Composable
28import androidx.compose.runtime.Composable
31import androidx.compose.runtime.LaunchedEffect
29import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.getValue
30import androidx.compose.runtime.getValue
@@ -43,6 +41,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
43import androidx.lifecycle.viewmodel.compose.viewModel
41import androidx.lifecycle.viewmodel.compose.viewModel
44import soy.iko.opencode.R
42import soy.iko.opencode.R
45import soy.iko.opencode.di.AppContainer
43import soy.iko.opencode.di.AppContainer
44+import soy.iko.opencode.ui.components.AppTopBar
46import soy.iko.opencode.ui.components.RelativeTimeText
45import soy.iko.opencode.ui.components.RelativeTimeText
47import soy.iko.opencode.ui.vmFactory
46import soy.iko.opencode.ui.vmFactory
48import soy.iko.opencode.util.runCatchingCancellable
47import soy.iko.opencode.util.runCatchingCancellable
@@ -66,14 +65,7 @@ fun GlobalSearchScreen(
66
65
67 Scaffold(
66 Scaffold(
68 topBar = {
67 topBar = {
69− TopAppBar(
70− title = { Text(stringResource(R.string.search_all_title)) },
68+ AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
71− navigationIcon = {
72− IconButton(onClick = onBack) {
73− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
74− }
75− },
76− )
77 },
69 },
78 ) { padding ->
70 ) { padding ->
79 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
71 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(
157 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
157 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
158 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
158 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
159 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
159 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) }
160 Column(
163 Column(
161 modifier = Modifier
164 modifier = Modifier
162 .padding(padding)
165 .padding(padding)
@@ -173,9 +176,9 @@ private fun ServerEditForm(
173 label = { Text(stringResource(R.string.base_url)) },
176 label = { Text(stringResource(R.string.base_url)) },
174 placeholder = { Text(stringResource(R.string.base_url_hint)) },
177 placeholder = { Text(stringResource(R.string.base_url_hint)) },
175 singleLine = true,
178 singleLine = true,
176− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
179+ isError = state.baseUrl.isNotBlank() && !urlValid,
177 supportingText = {
180 supportingText = {
178− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
181+ if (state.baseUrl.isNotBlank() && !urlValid) {
179 // Offer a one-tap "http://" fix for bare host:port input instead of a
182 // Offer a one-tap "http://" fix for bare host:port input instead of a
180 // generic error, so the user doesn't have to know the URL needs a scheme.
183 // generic error, so the user doesn't have to know the URL needs a scheme.
181 val suggestion = suggestUrlScheme(state.baseUrl)
184 val suggestion = suggestUrlScheme(state.baseUrl)
@@ -234,6 +237,16 @@ private fun ServerEditForm(
234 Text(stringResource(R.string.connect))
237 Text(stringResource(R.string.connect))
235 }
238 }
236 }
239 }
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+ }
237 // Secondary, low-emphasis: persist without connecting (offline setup).
250 // Secondary, low-emphasis: persist without connecting (offline setup).
238 TextButton(
251 TextButton(
239 onClick = {
252 onClick = {
@@ -245,14 +258,6 @@ private fun ServerEditForm(
245 ) {
258 ) {
246 Text(stringResource(R.string.save_without_connecting))
259 Text(stringResource(R.string.save_without_connecting))
247 }
260 }
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− }
256 // De-emphasized optional label at the bottom: displayLabel already falls back to the
261 // De-emphasized optional label at the bottom: displayLabel already falls back to the
257 // URL, so most servers don't need one.
262 // URL, so most servers don't need one.
258 OutlinedTextField(
263 OutlinedTextField(
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+122−30
@@ -21,8 +21,12 @@ import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.Logout
21import androidx.compose.material.icons.automirrored.filled.Logout
22import androidx.compose.material.icons.automirrored.filled.Sort
22import androidx.compose.material.icons.automirrored.filled.Sort
23import androidx.compose.material.icons.filled.Add
23import androidx.compose.material.icons.filled.Add
24+import androidx.compose.material.icons.filled.Archive
25+import androidx.compose.material.icons.filled.ArrowDownward
24import androidx.compose.material.icons.filled.ArrowDropDown
26import androidx.compose.material.icons.filled.ArrowDropDown
27+import androidx.compose.material.icons.filled.ArrowUpward
25import androidx.compose.material.icons.filled.ChatBubbleOutline
28import androidx.compose.material.icons.filled.ChatBubbleOutline
29+import androidx.compose.material.icons.filled.Check
26import androidx.compose.material.icons.filled.CheckCircle
30import androidx.compose.material.icons.filled.CheckCircle
27import androidx.compose.material.icons.filled.Close
31import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.Delete
32import androidx.compose.material.icons.filled.Delete
@@ -64,6 +68,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
64import androidx.compose.runtime.setValue
68import androidx.compose.runtime.setValue
65import androidx.compose.ui.Alignment
69import androidx.compose.ui.Alignment
66import androidx.compose.ui.Modifier
70import androidx.compose.ui.Modifier
71+import androidx.compose.ui.draw.alpha
67import androidx.compose.ui.draw.clip
72import androidx.compose.ui.draw.clip
68import androidx.compose.ui.platform.LocalHapticFeedback
73import androidx.compose.ui.platform.LocalHapticFeedback
69import androidx.compose.ui.platform.LocalSoftwareKeyboardController
74import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -200,6 +205,8 @@ fun SessionListScreen(
200 }
205 }
201 }
206 }
202
207
208+ SessionActionUndoEffect(vm, snackbar)
209+
203 Scaffold(
210 Scaffold(
204 topBar = {
211 topBar = {
205 TopAppBar(
212 TopAppBar(
@@ -226,36 +233,18 @@ fun SessionListScreen(
226 IconButton(onClick = onOpenSearch) {
233 IconButton(onClick = onOpenSearch) {
227 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
234 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
228 }
235 }
229− Box {
236+ SortMenu(
230− IconButton(onClick = { showSortMenu = true }) {
237+ sortMode = state.sortMode,
231− Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
238+ sortDescending = state.sortDescending,
232− }
239+ showArchived = state.showArchived,
240+ hiddenArchivedCount = state.hiddenArchivedCount,
233− DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
241+ expanded = showSortMenu,
234− DropdownMenuItem(
242+ onExpand = { showSortMenu = true },
235− text = { Text(stringResource(R.string.sort_recent)) },
236− onClick = { vm.setSortMode(SessionSortMode.RECENT); showSortMenu = false },
243+ onDismiss = { showSortMenu = false },
237− )
244+ onSetSortMode = { vm.setSortMode(it) },
238− DropdownMenuItem(
245+ onToggleDirection = { vm.toggleSortDirection() },
239− text = { Text(stringResource(R.string.sort_title)) },
246+ onSetShowArchived = { vm.setShowArchived(it) },
240− onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
247+ )
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− }
259 IconButton(onClick = { vm.refresh() }) {
248 IconButton(onClick = { vm.refresh() }) {
260 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
249 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
261 }
250 }
@@ -694,6 +683,9 @@ private fun SessionCard(
694 Card(
683 Card(
695 modifier = modifier
684 modifier = modifier
696 .fillMaxWidth()
685 .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)
697 .clickable { onClick() },
689 .clickable { onClick() },
698 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
690 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
699 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
691 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
@@ -745,6 +737,14 @@ private fun SessionCard(
745 tint = MaterialTheme.colorScheme.primary,
737 tint = MaterialTheme.colorScheme.primary,
746 )
738 )
747 }
739 }
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+ }
748 Text(
748 Text(
749 session.displayTitle,
749 session.displayTitle,
750 style = MaterialTheme.typography.titleMedium,
750 style = MaterialTheme.typography.titleMedium,
@@ -837,6 +837,98 @@ private fun SessionCard(
837 }
837 }
838}
838}
839
839
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+
840@Composable
932@Composable
841private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
933private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
842 Column(
934 Column(
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+90−26
@@ -45,6 +45,12 @@ import kotlinx.coroutines.sync.withPermit
45/** Sort order for the session list. */
45/** Sort order for the session list. */
46enum class SessionSortMode { RECENT, TITLE }
46enum class SessionSortMode { RECENT, TITLE }
47
47
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+
48/** Directory options for the new-session picker: worktree paths the server knows about
54/** Directory options for the new-session picker: worktree paths the server knows about
49 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
55 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
50 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
56 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
@@ -64,6 +70,10 @@ data class SessionListState(
64 val loading: Boolean = true,
70 val loading: Boolean = true,
65 val error: String? = null,
71 val error: String? = null,
66 val sortMode: SessionSortMode = SessionSortMode.RECENT,
72 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,
67 val pinnedIds: Set<String> = emptySet(),
77 val pinnedIds: Set<String> = emptySet(),
68 val archivedIds: Set<String> = emptySet(),
78 val archivedIds: Set<String> = emptySet(),
69 val showArchived: Boolean = false,
79 val showArchived: Boolean = false,
@@ -94,18 +104,24 @@ data class SessionListState(
94 get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
104 get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
95}
105}
96
106
107+/** Sort sessions by the given mode and direction. In each mode's *natural* direction,
97−/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
108+ * RECENT sorts by last activity time desc; TITLE by display title asc (case-insensitive),
98− * display title asc (case-insensitive), falling back to recency for ties. */
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. */
99−private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
111+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode, descending: Boolean): List<Session> {
112+ val natural = when (mode) {
100− SessionSortMode.RECENT ->
113+ SessionSortMode.RECENT ->
101− sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
114+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
102− SessionSortMode.TITLE ->
115+ SessionSortMode.TITLE ->
103− // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
116+ // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
104− // sort re-runs on every debounced list flush during streaming.
117+ // sort re-runs on every debounced list flush during streaming.
105− sortedWith(
118+ sortedWith(
106− compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
119+ compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
107− .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
120+ .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
108− )
121+ )
122+ }
123+ val naturalDescending = mode == SessionSortMode.RECENT
124+ return if (descending == naturalDescending) natural else natural.reversed()
109}
125}
110
126
111// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
127// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
@@ -175,6 +191,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
175 )
191 )
176 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
192 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
177
193
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+
178 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
203 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
179 * Session so Undo can restore it. */
204 * Session so Undo can restore it. */
180 // Keyed by session id, not a single field: the undo window lets several deletes be
205 // 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() {
233 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
258 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
234 fun togglePin(session: Session) {
259 fun togglePin(session: Session) {
235 val pin = session.id !in _state.value.pinnedIds
260 val pin = session.id !in _state.value.pinnedIds
236− // Optimistically update the displayed state so rapid taps toggle correctly instead
261+ applyPin(session.id, pin)
237− // of both reading the same stale set (pre-observer-round-trip) and double-pinning.
262+ _sessionActionEvents.tryEmit(
238− _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + session.id else it.pinnedIds - session.id) }
263+ SessionActionEvent(session.id, if (pin) SessionActionKind.PINNED else SessionActionKind.UNPINNED),
239− viewModelScope.launch {
264+ )
240− runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
241− .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
242− }
243 }
265 }
244
266
245 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
267 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
246 fun toggleArchive(session: Session) {
268 fun toggleArchive(session: Session) {
247 val archive = session.id !in _state.value.archivedIds
269 val archive = session.id !in _state.value.archivedIds
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) {
248− _state.update { it.copy(archivedIds = if (archive) it.archivedIds + session.id else it.archivedIds - session.id) }
288+ _state.update { it.copy(archivedIds = if (archive) it.archivedIds + sessionId else it.archivedIds - sessionId) }
249 viewModelScope.launch {
289 viewModelScope.launch {
250− runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
290+ runCatchingCancellable { container.sessionPrefsStore.setArchived(sessionId, archive) }
251 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
291 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
252 }
292 }
253 }
293 }
254
294
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+
255 /** Toggle whether archived sessions are shown in the list. */
305 /** Toggle whether archived sessions are shown in the list. */
256 fun setShowArchived(show: Boolean) {
306 fun setShowArchived(show: Boolean) {
257 _state.update { it.copy(showArchived = show) }
307 _state.update { it.copy(showArchived = show) }
@@ -391,7 +441,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
391 // new state (and trigger recomposition) when the batch held no real changes
441 // new state (and trigger recomposition) when the batch held no real changes
392 // (e.g. a duplicate SessionUpdated for an unchanged session).
442 // (e.g. a duplicate SessionUpdated for an unchanged session).
393 if (changedIds.isEmpty()) return@update s
443 if (changedIds.isEmpty()) return@update s
394− val sorted = byId.values.sortedByMode(s.sortMode)
444+ val sorted = byId.values.sortedByMode(s.sortMode, s.sortDescending)
395 // If the new ordering matches the existing one (e.g. only the already-newest
445 // If the new ordering matches the existing one (e.g. only the already-newest
396 // session updated its timestamp), reuse the same list instance so downstream
446 // session updated its timestamp), reuse the same list instance so downstream
397 // skips a recomposition for an identical reference.
447 // skips a recomposition for an identical reference.
@@ -453,7 +503,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
453 // REST delete hasn't fired yet — the server still returns them, and
503 // REST delete hasn't fired yet — the server still returns them, and
454 // re-adding them here would resurrect a just-hidden row (and later
504 // re-adding them here would resurrect a just-hidden row (and later
455 // collide on the LazyColumn id key when undo/restore re-appends it).
505 // 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)
457 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
508 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
458 loadPreviews(sorted)
509 loadPreviews(sorted)
459 }
510 }
@@ -640,7 +691,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
640 // we never produce two rows with the same id.
691 // we never produce two rows with the same id.
641 pendingDeletes.remove(session.id)
692 pendingDeletes.remove(session.id)
642 _state.update { s ->
693 _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))
644 }
695 }
645 _transientErrors.tryEmit(container.friendlyError(err))
696 _transientErrors.tryEmit(container.friendlyError(err))
646 }
697 }
@@ -660,13 +711,26 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
660 val pending = pendingDeletes.remove(sessionId) ?: return
711 val pending = pendingDeletes.remove(sessionId) ?: return
661 _state.update { s ->
712 _state.update { s ->
662 // Dedup by id in case a refresh during the undo window already re-added it.
713 // 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))
664 }
715 }
665 }
716 }
666
717
667 fun setSortMode(mode: SessionSortMode) {
718 fun setSortMode(mode: SessionSortMode) {
668 if (mode == _state.value.sortMode) return
719 if (mode == _state.value.sortMode) return
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 ->
669− _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
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+ }
670 }
734 }
671
735
672 fun renameSession(session: Session, newTitle: String) {
736 fun renameSession(session: Session, newTitle: String) {
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+85−56
@@ -1,18 +1,13 @@
1package soy.iko.opencode.ui.session
1package soy.iko.opencode.ui.session
2
2
3−import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
3import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
5import androidx.compose.foundation.layout.fillMaxSize
8−import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.widthIn
6import androidx.compose.foundation.layout.widthIn
10import androidx.activity.compose.BackHandler
7import androidx.activity.compose.BackHandler
11import androidx.compose.material.icons.Icons
8import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.automirrored.filled.Chat
9import androidx.compose.material.icons.automirrored.filled.Chat
13−import androidx.compose.material3.Icon
10+import androidx.compose.material.icons.filled.Add
14−import androidx.compose.material3.MaterialTheme
15−import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
11import androidx.compose.runtime.Composable
17import androidx.compose.runtime.LaunchedEffect
12import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
13import androidx.compose.runtime.getValue
@@ -21,16 +16,17 @@ import androidx.compose.runtime.mutableStateOf
21import androidx.compose.runtime.remember
16import androidx.compose.runtime.remember
22import androidx.compose.runtime.saveable.rememberSaveable
17import androidx.compose.runtime.saveable.rememberSaveable
23import androidx.compose.runtime.setValue
18import androidx.compose.runtime.setValue
24−import androidx.compose.ui.Alignment
25import androidx.compose.ui.Modifier
19import androidx.compose.ui.Modifier
26import androidx.compose.ui.res.stringResource
20import androidx.compose.ui.res.stringResource
27import androidx.compose.ui.unit.dp
21import androidx.compose.ui.unit.dp
28import androidx.lifecycle.compose.collectAsStateWithLifecycle
22import androidx.lifecycle.compose.collectAsStateWithLifecycle
29import androidx.lifecycle.viewmodel.compose.viewModel
23import androidx.lifecycle.viewmodel.compose.viewModel
30import soy.iko.opencode.R
24import soy.iko.opencode.R
25+import soy.iko.opencode.data.model.Session
31import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.data.network.NetworkConfig
32import soy.iko.opencode.di.AppContainer
27import soy.iko.opencode.di.AppContainer
33import soy.iko.opencode.ui.chat.ChatScreen
28import soy.iko.opencode.ui.chat.ChatScreen
29+import soy.iko.opencode.ui.components.EmptyState
34import soy.iko.opencode.ui.vmFactory
30import soy.iko.opencode.ui.vmFactory
35
31
36/**
32/**
@@ -87,38 +83,30 @@ fun TwoPaneSessionChat(
87 lastProfileId = currentId
83 lastProfileId = currentId
88 }
84 }
89
85
90− // Clear the selection only when the open session is *deleted* — i.e. it was present
86+ StaleSelectionCleanup(
91− // in the list and then dropped out. A freshly created session sets `selected` to the
87+ loading = sessionListState.loading,
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) {
88+ error = sessionListState.error,
99− val target = selected ?: return@LaunchedEffect
100− if (sessionListState.sessions.any { it.id == target }) {
89+ sessions = sessionListState.sessions,
101− hasAppeared = true
90+ selected = selected,
102− } else if (hasAppeared) {
103− selected = null
91+ onClear = { selected = null },
104− }
92+ )
105− }
106
93
107− // The hasAppeared latch above can't survive process death (plain remember), so a
94+ // Auto-select the most-recent session into the detail pane so a wide screen doesn't
108− // `selected` restored across process death whose session was deleted server-side while
95+ // open on a blank right pane. Runs at most once per composition (the `autoSelected`
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
96+ // latch, non-saveable so it re-arms after process death), and only once the first list
113− // during the initial load, and not for a freshly-created session whose refresh keeps
97+ // load has completed. It defers to any explicit selection already present — a restored
114− // `loading` false while its row is still pending.
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`.
115− var initialSelectionValidated by remember { mutableStateOf(false) }
101+ var autoSelected by remember { mutableStateOf(false) }
116− LaunchedEffect(sessionListState.loading, sessionListState.error, sessionListState.sessions) {
102+ LaunchedEffect(sessionListState.loading, sessionListState.sessions, pendingOpenSession, selected) {
117− if (initialSelectionValidated) return@LaunchedEffect
103+ if (autoSelected || sessionListState.loading) return@LaunchedEffect
118− if (sessionListState.loading || sessionListState.error != null) return@LaunchedEffect
104+ // A pending deep-link or an existing selection takes precedence; yield the pane to it.
119− initialSelectionValidated = true
105+ if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
106+ // Pick by recency regardless of the list's current sort mode.
120− val target = selected ?: return@LaunchedEffect
107+ val id = mostRecentSessionId(sessionListState.sessions) ?: return@LaunchedEffect
121− if (sessionListState.sessions.none { it.id == target }) selected = null
108+ selected = id
109+ autoSelected = true
122 }
110 }
123
111
124 // Inject a pending share into the currently selected session's draft (if any).
112 // Inject a pending share into the currently selected session's draft (if any).
@@ -157,7 +145,11 @@ fun TwoPaneSessionChat(
157 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
145 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
158 val sessionId = selected
146 val sessionId = selected
159 if (sessionId == null) {
147 if (sessionId == null) {
148+ // Reuse the same VM create path the session-list FAB uses; open the new
149+ // session straight into the detail pane.
160− EmptyDetail()
150+ EmptyDetail(onNewSession = {
151+ sessionListVm.createSession(directory = null) { id -> selected = id }
152+ })
161 } else {
153 } else {
162 BackHandler { selected = null }
154 BackHandler { selected = null }
163 // key() on the session id so switching conversations in two-pane mode
155 // key() on the session id so switching conversations in two-pane mode
@@ -181,23 +173,60 @@ fun TwoPaneSessionChat(
181 }
173 }
182}
174}
183
175
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+ */
184@Composable
197@Composable
185−private fun EmptyDetail() {
198+private fun StaleSelectionCleanup(
186− Column(
199+ loading: Boolean,
187− modifier = Modifier.fillMaxSize().padding(32.dp),
200+ error: String?,
188− verticalArrangement = Arrangement.Center,
201+ sessions: List<Session>,
189− horizontalAlignment = Alignment.CenterHorizontally,
202+ selected: String?,
190− ) {
203+ onClear: () -> Unit,
191− Icon(
204+) {
192− Icons.AutoMirrored.Filled.Chat,
205+ var hasAppeared by remember(selected) { mutableStateOf(false) }
193− contentDescription = null,
206+ LaunchedEffect(selected, sessions) {
194− tint = MaterialTheme.colorScheme.outline,
207+ val target = selected ?: return@LaunchedEffect
195− )
208+ if (containsSession(sessions, target)) hasAppeared = true
196− Text(
209+ else if (hasAppeared) onClear()
197− stringResource(R.string.empty_detail_pane),
210+ }
198− style = MaterialTheme.typography.bodyLarge,
211+ var initialSelectionValidated by remember { mutableStateOf(false) }
199− color = MaterialTheme.colorScheme.onSurfaceVariant,
212+ LaunchedEffect(loading, error, sessions) {
200− modifier = Modifier.padding(top = 12.dp),
213+ if (initialSelectionValidated) return@LaunchedEffect
201− )
214+ if (loading || error != null) return@LaunchedEffect
215+ initialSelectionValidated = true
216+ val target = selected ?: return@LaunchedEffect
217+ if (!containsSession(sessions, target)) onClear()
202 }
218 }
203}
219}
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
20import androidx.compose.foundation.rememberScrollState
20import androidx.compose.foundation.rememberScrollState
21import androidx.compose.foundation.verticalScroll
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.filled.BugReport
23import androidx.compose.material.icons.filled.BugReport
25import androidx.compose.material.icons.filled.DeleteSweep
24import androidx.compose.material.icons.filled.DeleteSweep
26import androidx.compose.material.icons.filled.Share
25import androidx.compose.material.icons.filled.Share
@@ -40,7 +39,6 @@ import androidx.compose.material3.SwipeToDismissBox
40import androidx.compose.material3.SwipeToDismissBoxValue
39import androidx.compose.material3.SwipeToDismissBoxValue
41import androidx.compose.material3.Text
40import androidx.compose.material3.Text
42import androidx.compose.material3.TextButton
41import androidx.compose.material3.TextButton
43−import androidx.compose.material3.TopAppBar
44import androidx.compose.material3.rememberSwipeToDismissBoxState
42import androidx.compose.material3.rememberSwipeToDismissBoxState
45import androidx.compose.runtime.Composable
43import androidx.compose.runtime.Composable
46import androidx.compose.runtime.CompositionLocalProvider
44import androidx.compose.runtime.CompositionLocalProvider
@@ -60,6 +58,7 @@ import kotlinx.coroutines.flow.collectLatest
60import kotlinx.coroutines.launch
58import kotlinx.coroutines.launch
61import kotlinx.coroutines.withContext
59import kotlinx.coroutines.withContext
62import soy.iko.opencode.util.runCatchingCancellable
60import soy.iko.opencode.util.runCatchingCancellable
61+import soy.iko.opencode.ui.components.AppTopBar
63import soy.iko.opencode.ui.components.showToast
62import soy.iko.opencode.ui.components.showToast
64import androidx.compose.ui.Alignment
63import androidx.compose.ui.Alignment
65import androidx.compose.ui.Modifier
64import androidx.compose.ui.Modifier
@@ -141,13 +140,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
141
140
142 Scaffold(
141 Scaffold(
143 topBar = {
142 topBar = {
144− TopAppBar(
143+ AppTopBar(
145− title = { Text(stringResource(R.string.diagnostics)) },
144+ title = stringResource(R.string.diagnostics),
146− navigationIcon = {
145+ onBack = onBack,
147− IconButton(onClick = onBack) {
148− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
149− }
150− },
151 actions = {
146 actions = {
152 if (reports.isNotEmpty()) {
147 if (reports.isNotEmpty()) {
153 IconButton(onClick = {
148 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
30import androidx.compose.material.icons.filled.Hub
30import androidx.compose.material.icons.filled.Hub
31import androidx.compose.material.icons.filled.QueryStats
31import androidx.compose.material.icons.filled.QueryStats
32import androidx.compose.material.icons.filled.Upload
32import androidx.compose.material.icons.filled.Upload
33+import androidx.compose.material3.AlertDialog
33import androidx.compose.material3.ExperimentalMaterial3Api
34import androidx.compose.material3.ExperimentalMaterial3Api
34import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.HorizontalDivider
36import androidx.compose.material3.HorizontalDivider
@@ -151,6 +152,11 @@ fun SettingsScreen(
151 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
152 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
152 // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
153 // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
153 var includePasswords by rememberSaveable { mutableStateOf(false) }
154 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) }
154 val exportedMsg = stringResource(R.string.backup_exported)
160 val exportedMsg = stringResource(R.string.backup_exported)
155 val exportFailedMsg = stringResource(R.string.backup_export_failed)
161 val exportFailedMsg = stringResource(R.string.backup_export_failed)
156 val importedMsg = stringResource(R.string.backup_imported)
162 val importedMsg = stringResource(R.string.backup_imported)
@@ -172,14 +178,8 @@ fun SettingsScreen(
172 ActivityResultContracts.OpenDocument(),
178 ActivityResultContracts.OpenDocument(),
173 ) { uri ->
179 ) { uri ->
174 if (uri == null) return@rememberLauncherForActivityResult
180 if (uri == null) return@rememberLauncherForActivityResult
175− scope.launch {
181+ // Stage the file; the actual destructive import waits on the confirm dialog.
176− val ok = runCatchingCancellable {
182+ pendingImportUri = uri
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− }
183 }
183 }
184
184
185 Scaffold(
185 Scaffold(
@@ -464,7 +464,12 @@ fun SettingsScreen(
464 icon = Icons.Filled.Upload,
464 icon = Icons.Filled.Upload,
465 label = stringResource(R.string.export_backup),
465 label = stringResource(R.string.export_backup),
466 enabled = true,
466 enabled = true,
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
467− onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
471+ else runCatching { exportLauncher.launch("opencode-backup.json") }
472+ },
468 )
473 )
469 NavRow(
474 NavRow(
470 icon = Icons.Filled.Download,
475 icon = Icons.Filled.Download,
@@ -518,6 +523,49 @@ fun SettingsScreen(
518 }
523 }
519 }
524 }
520 }
525 }
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+ }
521}
569}
522
570
523/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
571/** 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
13import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.items
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.filled.Refresh
16import androidx.compose.material.icons.filled.Refresh
18import androidx.compose.material3.Button
17import androidx.compose.material3.Button
19import androidx.compose.material3.Card
18import androidx.compose.material3.Card
@@ -24,14 +23,11 @@ import androidx.compose.material3.IconButton
24import androidx.compose.material3.MaterialTheme
23import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.Scaffold
24import androidx.compose.material3.Scaffold
26import androidx.compose.material3.Text
25import androidx.compose.material3.Text
27−import androidx.compose.material3.TopAppBar
28import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
29import androidx.compose.runtime.getValue
27import androidx.compose.runtime.getValue
30import androidx.compose.ui.Alignment
28import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
32import androidx.compose.ui.res.stringResource
30import androidx.compose.ui.res.stringResource
33−import androidx.compose.ui.semantics.heading
34−import androidx.compose.ui.semantics.semantics
35import androidx.compose.ui.text.style.TextOverflow
31import androidx.compose.ui.text.style.TextOverflow
36import androidx.compose.ui.unit.dp
32import androidx.compose.ui.unit.dp
37import androidx.lifecycle.compose.collectAsStateWithLifecycle
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -39,6 +35,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
39import soy.iko.opencode.R
35import soy.iko.opencode.R
40import soy.iko.opencode.data.model.Tokens
36import soy.iko.opencode.data.model.Tokens
41import soy.iko.opencode.di.AppContainer
37import soy.iko.opencode.di.AppContainer
38+import soy.iko.opencode.ui.components.AppTopBar
39+import soy.iko.opencode.ui.components.SectionHeader
42import soy.iko.opencode.ui.vmFactory
40import soy.iko.opencode.ui.vmFactory
43import java.text.NumberFormat
41import java.text.NumberFormat
44import java.util.Locale
42import java.util.Locale
@@ -51,13 +49,9 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
51
49
52 Scaffold(
50 Scaffold(
53 topBar = {
51 topBar = {
54− TopAppBar(
52+ AppTopBar(
55− title = { Text(stringResource(R.string.usage_title)) },
53+ title = stringResource(R.string.usage_title),
56− navigationIcon = {
54+ onBack = onBack,
57− IconButton(onClick = onBack) {
58− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
59− }
60− },
61 actions = {
55 actions = {
62 IconButton(onClick = { vm.load() }) {
56 IconButton(onClick = { vm.load() }) {
63 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
57 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
@@ -181,16 +175,6 @@ private fun StatRow(label: String, value: String) {
181 }
175 }
182}
176}
183
177
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−
194@Composable
178@Composable
195private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
179private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
196 Row(
180 Row(
app/src/main/res/values-es/strings.xml+25
@@ -469,4 +469,29 @@
469 <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
469 <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
470 <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
470 <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
471 <string name="cert_pin_invalid">El pin debe ser sha256/&lt;base64&gt;</string>
471 <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>
472</resources>
497</resources>
app/src/main/res/values/strings.xml+25
@@ -471,4 +471,29 @@
471 <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
471 <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
472 <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>
472 <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>
473 <string name="cert_pin_invalid">Pin must look like sha256/&lt;base64&gt;</string>
473 <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>
474</resources>
499</resources>