git.iko.soy/opencode-android
on master help sign in
Improve chat, navigation, search, files, and usage UI/UX across the app
1c81cc8iko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4−1
@@ -237,7 +237,7 @@ object NetworkConfig {
237 /** Minimum number of sessions before the session list shows its search/filter field.
237 /** Minimum number of sessions before the session list shows its search/filter field.
238 * Matches the sibling screens' convention: a short list is faster to scan by eye than
238 * Matches the sibling screens' convention: a short list is faster to scan by eye than
239 * type into, so the field earns its vertical space only once the list grows. */
239 * type into, so the field earns its vertical space only once the list grows. */
240− const val sessionListSearchThreshold = 6
240+ const val sessionListSearchThreshold = 3
241 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
241 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
242 * jump-to-latest FAB so it never floats over the last message. */
242 * jump-to-latest FAB so it never floats over the last message. */
243 const val chatListFabInsetDp = 80
243 const val chatListFabInsetDp = 80
@@ -295,6 +295,9 @@ object NetworkConfig {
295 /** Duration (ms) of the animated double-tap zoom transition in the fullscreen image viewer. */
295 /** Duration (ms) of the animated double-tap zoom transition in the fullscreen image viewer. */
296 const val imageViewerZoomAnimMs = 220
296 const val imageViewerZoomAnimMs = 220
297
297
298+ /** Vertical drag distance (px) in the fullscreen image viewer that triggers swipe-to-dismiss. */
299+ const val imageViewerSwipeDismissThreshold = 200f
300+
298 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
301 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
299 const val streamingCaretPeriodMs = 550
302 const val streamingCaretPeriodMs = 550
300
303
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+8−1
@@ -367,7 +367,14 @@ fun OpencodeApp(container: AppContainer) {
367 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
367 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
368 onBack = { navController.popBackStack() },
368 onBack = { navController.popBackStack() },
369 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
369 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
370+ onOpenSession = { id ->
370− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
371+ navController.navigate(Routes.chat(id)) {
372+ // Prevent the same chat from being pushed twice when re-tapping a
373+ // session deep link, and keep the back stack bounded when chaining
374+ // chat → linked chat → … (otherwise every hop piles a new entry).
375+ launchSingleTop = true
376+ }
377+ },
371 )
378 )
372 }
379 }
373
380
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+44−2
@@ -1,11 +1,14 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxSize
5import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
8import androidx.compose.foundation.layout.widthIn
10import androidx.compose.foundation.layout.widthIn
11+import androidx.compose.foundation.clickable
9import androidx.compose.foundation.horizontalScroll
12import androidx.compose.foundation.horizontalScroll
10import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.rememberScrollState
11import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
@@ -18,15 +21,22 @@ import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.Surface
21import androidx.compose.material3.Surface
19import androidx.compose.material3.Text
22import androidx.compose.material3.Text
20import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.setValue
21import androidx.compose.ui.Alignment
28import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
23import androidx.compose.ui.draw.clip
30import androidx.compose.ui.draw.clip
24import androidx.compose.ui.layout.ContentScale
31import androidx.compose.ui.layout.ContentScale
25import androidx.compose.ui.res.stringResource
32import androidx.compose.ui.res.stringResource
33+import androidx.compose.ui.semantics.Role
26import androidx.compose.ui.semantics.contentDescription
34import androidx.compose.ui.semantics.contentDescription
27import androidx.compose.ui.semantics.semantics
35import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.text.style.TextOverflow
36import androidx.compose.ui.text.style.TextOverflow
29import androidx.compose.ui.unit.dp
37import androidx.compose.ui.unit.dp
38+import androidx.compose.ui.window.Dialog
39+import androidx.compose.ui.window.DialogProperties
30import coil.compose.AsyncImage
40import coil.compose.AsyncImage
31import soy.iko.opencode.R
41import soy.iko.opencode.R
32
42
@@ -88,9 +98,13 @@ private fun StagingChip() {
88}
98}
89
99
90/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
100/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
91− * and a remove button. */
101+ * and a remove button. Tapping the thumbnail/name of an image attachment opens a fullscreen
102+ * preview so the user can verify it before sending (a sent image costs tokens and can't be
103+ * unsent). */
92@Composable
104@Composable
93private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
105private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
106+ var showPreview by remember { mutableStateOf(false) }
107+ val previewLabel = stringResource(R.string.preview_attachment)
94 Surface(
108 Surface(
95 shape = MaterialTheme.shapes.small,
109 shape = MaterialTheme.shapes.small,
96 color = MaterialTheme.colorScheme.surfaceVariant,
110 color = MaterialTheme.colorScheme.surfaceVariant,
@@ -102,7 +116,11 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
102 model = attachment.previewModel,
116 model = attachment.previewModel,
103 contentDescription = attachment.name,
117 contentDescription = attachment.name,
104 contentScale = ContentScale.Crop,
118 contentScale = ContentScale.Crop,
119+ modifier = Modifier
120+ .size(36.dp)
105− modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
121+ .clip(MaterialTheme.shapes.extraSmall)
122+ .semantics { contentDescription = previewLabel }
123+ .clickable(role = Role.Image) { showPreview = true },
106 )
124 )
107 } else {
125 } else {
108 Icon(
126 Icon(
@@ -130,4 +148,28 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
130 }
148 }
131 }
149 }
132 }
150 }
151+ if (showPreview && attachment.previewModel != null) {
152+ Dialog(
153+ onDismissRequest = { showPreview = false },
154+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
155+ ) {
156+ Box(
157+ modifier = Modifier.fillMaxSize().padding(16.dp),
158+ contentAlignment = Alignment.Center,
159+ ) {
160+ AsyncImage(
161+ model = attachment.previewModel,
162+ contentDescription = attachment.name,
163+ contentScale = ContentScale.Fit,
164+ modifier = Modifier.fillMaxWidth().clickable(role = Role.Image) { showPreview = false },
165+ )
166+ IconButton(
167+ onClick = { showPreview = false },
168+ modifier = Modifier.align(Alignment.TopEnd),
169+ ) {
170+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
171+ }
172+ }
173+ }
174+ }
133}
175}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+44−5
@@ -156,6 +156,9 @@ import soy.iko.opencode.data.model.ToolCompleted
156import soy.iko.opencode.data.model.ToolError
156import soy.iko.opencode.data.model.ToolError
157import soy.iko.opencode.data.model.ToolPart
157import soy.iko.opencode.data.model.ToolPart
158import soy.iko.opencode.data.model.ToolRunning
158import soy.iko.opencode.data.model.ToolRunning
159+import soy.iko.opencode.data.model.FilePart
160+import soy.iko.opencode.data.model.sourcePath
161+import soy.iko.opencode.data.model.inputElement
159import soy.iko.opencode.data.network.EventStreamClient
162import soy.iko.opencode.data.network.EventStreamClient
160import soy.iko.opencode.data.network.NetworkConfig
163import soy.iko.opencode.data.network.NetworkConfig
161import soy.iko.opencode.di.AppContainer
164import soy.iko.opencode.di.AppContainer
@@ -847,7 +850,26 @@ fun ChatScreen(
847 val searchMessages = remember(messages, searchQuery) {
850 val searchMessages = remember(messages, searchQuery) {
848 if (searchQuery.isEmpty()) messages
851 if (searchQuery.isEmpty()) messages
849 else messages.filter { m ->
852 else messages.filter { m ->
853+ m.parts.any { p ->
854+ when (p) {
855+ is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
856+ is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
857+ is ToolPart -> {
858+ // Match the tool name plus its running/completed/error text and the
859+ // stringified input, so searching for an error string or a file the
860+ // tool touched actually finds the message it appeared in.
861+ p.tool.contains(searchQuery, ignoreCase = true) ||
862+ (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
863+ (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
864+ p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
850− m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
865+ (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
866+ p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
867+ }
868+ is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
869+ p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
870+ else -> false
871+ }
872+ }
851 }
873 }
852 }
874 }
853 // Keep the focused-match index in range as the query (and thus the match set) changes.
875 // Keep the focused-match index in range as the query (and thus the match set) changes.
@@ -1789,6 +1811,7 @@ private fun ChatInputBar(
1789) {
1811) {
1790 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1812 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1791 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1813 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1814+ val context = LocalContext.current
1792 // Full-screen editor state: opened when the user wants more space for a long prompt.
1815 // Full-screen editor state: opened when the user wants more space for a long prompt.
1793 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
1816 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
1794 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1817 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
@@ -1863,9 +1886,18 @@ private fun ChatInputBar(
1863 )
1886 )
1864 }
1887 }
1865 }
1888 }
1866− // Voice dictation: appends recognized speech to the draft.
1889+ // Voice dictation: appends recognized speech to the draft. Hidden (not just
1890+ // disabled) when the device has no speech-recognition activity, so a user
1891+ // without a voice app doesn't get a button that only fails noisily on tap.
1892+ val voiceAvailable = remember {
1893+ val pm = context.packageManager
1894+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
1895+ pm.queryIntentActivities(intent, 0).isNotEmpty()
1896+ }
1897+ if (voiceAvailable) {
1867− IconButton(onClick = onVoice, enabled = enabled) {
1898+ IconButton(onClick = onVoice, enabled = enabled) {
1868− Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1899+ Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1900+ }
1869 }
1901 }
1870 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1902 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1871 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
1903 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
@@ -2175,7 +2207,14 @@ private fun ComposerTrailingButton(
2175 enabled = canSend,
2207 enabled = canSend,
2176 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2208 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2177 ) {
2209 ) {
2210+ // Tint the send icon with primary when there's content to send so the commit action
2211+ // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2212+ Icon(
2213+ Icons.AutoMirrored.Filled.Send,
2178− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
2214+ contentDescription = stringResource(R.string.send),
2215+ tint = if (canSend) MaterialTheme.colorScheme.primary
2216+ else MaterialTheme.colorScheme.onSurfaceVariant,
2217+ )
2179 }
2218 }
2180 }
2219 }
2181}
2220}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+186−44
@@ -138,6 +138,10 @@ private fun MessageLongPressMenu(
138 onBranch: ((String) -> Unit)?,
138 onBranch: ((String) -> Unit)?,
139 onRevert: (() -> Unit)?,
139 onRevert: (() -> Unit)?,
140 onShare: (() -> Unit)?,
140 onShare: (() -> Unit)?,
141+ onEdit: (() -> Unit)? = null,
142+ onRegenerate: (() -> Unit)? = null,
143+ onSpeak: (() -> Unit)? = null,
144+ speakLabel: String? = null,
141) {
145) {
142 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
146 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
143 if (text != null && onCopy != null) {
147 if (text != null && onCopy != null) {
@@ -157,6 +161,33 @@ private fun MessageLongPressMenu(
157 onClick = { onDismiss(); onShare() },
161 onClick = { onDismiss(); onShare() },
158 )
162 )
159 }
163 }
164+ // Edit (user prompts): reload the prompt into the composer. Surfaced here as well as
165+ // inline so the action is discoverable without spotting the 18dp icon.
166+ if (text != null && onEdit != null) {
167+ DropdownMenuItem(
168+ leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
169+ text = { Text(stringResource(R.string.edit_message)) },
170+ onClick = { onDismiss(); onEdit() },
171+ )
172+ }
173+ // Regenerate (assistant): re-run the preceding user prompt. A flagship action that was
174+ // previously only reachable via the tiny inline icon.
175+ if (onRegenerate != null) {
176+ DropdownMenuItem(
177+ leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
178+ text = { Text(stringResource(R.string.regenerate)) },
179+ onClick = { onDismiss(); onRegenerate() },
180+ )
181+ }
182+ // Read aloud / Stop reading (assistant): toggles TTS. The label is supplied by the caller
183+ // so it reflects the current speaking state (play/pause/resume).
184+ if (text != null && onSpeak != null && speakLabel != null) {
185+ DropdownMenuItem(
186+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = null) },
187+ text = { Text(speakLabel) },
188+ onClick = { onDismiss(); onSpeak() },
189+ )
190+ }
160 if (text != null && onQuote != null) {
191 if (text != null && onQuote != null) {
161 DropdownMenuItem(
192 DropdownMenuItem(
162 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
193 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
@@ -181,6 +212,44 @@ private fun MessageLongPressMenu(
181 }
212 }
182}
213}
183
214
215+/** Wraps [MessageLongPressMenu] for a user prompt, building the Edit callback (which reloads
216+ * the prompt into the composer and warns when the original carried attachments) so the
217+ * branch count stays out of [UserBubble]. */
218+@Composable
219+private fun UserMessageLongPressMenu(
220+ expanded: Boolean,
221+ onDismiss: () -> Unit,
222+ offset: androidx.compose.ui.unit.DpOffset,
223+ text: String?,
224+ onCopy: (() -> Unit)?,
225+ onQuote: ((String) -> Unit)?,
226+ onBranch: ((String) -> Unit)?,
227+ onRevert: (() -> Unit)?,
228+ onShare: (() -> Unit)?,
229+ onEdit: ((String) -> Unit)?,
230+ hasAttachments: Boolean,
231+ dropsAttachmentsMsg: String,
232+ context: android.content.Context,
233+) {
234+ val editForMenu: (() -> Unit)? = if (onEdit != null && text != null) {
235+ { onEdit(text); if (hasAttachments) showToast(context, dropsAttachmentsMsg) }
236+ } else {
237+ null
238+ }
239+ MessageLongPressMenu(
240+ expanded = expanded,
241+ onDismiss = onDismiss,
242+ offset = offset,
243+ text = text,
244+ onCopy = onCopy,
245+ onQuote = onQuote,
246+ onBranch = onBranch,
247+ onRevert = onRevert,
248+ onShare = onShare,
249+ onEdit = editForMenu,
250+ )
251+}
252+
184/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
253/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
185 * Shown when at least one action is available and there's text to act on. Keeps the
254 * Shown when at least one action is available and there's text to act on. Keeps the
186 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
255 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -313,6 +382,8 @@ private fun UserBubble(
313 val density = androidx.compose.ui.platform.LocalDensity.current
382 val density = androidx.compose.ui.platform.LocalDensity.current
314 val copyLabel = stringResource(R.string.copy)
383 val copyLabel = stringResource(R.string.copy)
315 val editLabel = stringResource(R.string.edit_message)
384 val editLabel = stringResource(R.string.edit_message)
385+ val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
386+ val hasAttachments = message.parts.any { it is FilePart }
316 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
387 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
317 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
388 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
318 // The menu anchors at the actual touch point rather than a fixed corner.
389 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -341,6 +412,22 @@ private fun UserBubble(
341 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
412 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
342 .clip(MaterialTheme.shapes.medium)
413 .clip(MaterialTheme.shapes.medium)
343 .background(MaterialTheme.colorScheme.primaryContainer)
414 .background(MaterialTheme.colorScheme.primaryContainer)
415+ // Long-press anywhere on the bubble (body or footer) opens the context menu —
416+ // the conventional Android pattern — so the actions are discoverable without
417+ // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
418+ // not consume taps, so text selection and inline-icon clicks still work; a long
419+ // press on selectable text is consumed by the SelectionContainer first, so the
420+ // menu only opens from non-text areas (the intended behavior).
421+ .pointerInput(Unit) {
422+ detectTapGestures(onLongPress = { offset ->
423+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
424+ longPressOffset = androidx.compose.ui.unit.DpOffset(
425+ with(density) { offset.x.toDp() },
426+ with(density) { offset.y.toDp() },
427+ )
428+ longPressMenu = true
429+ })
430+ }
344 .padding(12.dp),
431 .padding(12.dp),
345 verticalArrangement = Arrangement.spacedBy(6.dp),
432 verticalArrangement = Arrangement.spacedBy(6.dp),
346 ) {
433 ) {
@@ -348,26 +435,10 @@ private fun UserBubble(
348 message.parts.forEachIndexed { index, part ->
435 message.parts.forEachIndexed { index, part ->
349 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
436 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
350 }
437 }
351− // Footer row with timestamp + inline actions. Long-press opens a context menu
438+ // Footer row with timestamp + inline actions.
352− // consolidating copy/quote/branch/revert — the conventional Android pattern — so
353− // the actions are discoverable without spotting the 18dp inline icons. Uses
354− // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
355− // semantics are announced to TalkBack for an area whose tap does nothing, and the
356− // menu anchors at the actual long-press position instead of a fixed corner.
357 Box {
439 Box {
358 Row(
440 Row(
359− modifier = Modifier
441+ modifier = Modifier.fillMaxWidth(),
360− .fillMaxWidth()
361− .pointerInput(Unit) {
362− detectTapGestures(onLongPress = { offset ->
363− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
364− longPressOffset = androidx.compose.ui.unit.DpOffset(
365− with(density) { offset.x.toDp() },
366− with(density) { offset.y.toDp() },
367− )
368− longPressMenu = true
369− })
370− },
371 horizontalArrangement = Arrangement.SpaceBetween,
442 horizontalArrangement = Arrangement.SpaceBetween,
372 verticalAlignment = Alignment.CenterVertically,
443 verticalAlignment = Alignment.CenterVertically,
373 ) {
444 ) {
@@ -460,8 +531,6 @@ private fun UserBubble(
460 // conversation to before it so a Send replaces it. Only when there's text
531 // conversation to before it so a Send replaces it. Only when there's text
461 // to edit (an image-only prompt has nothing to reload).
532 // to edit (an image-only prompt has nothing to reload).
462 if (onEdit != null && textToCopy != null) {
533 if (onEdit != null && textToCopy != null) {
463− val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
464− val hasAttachments = message.parts.any { it is FilePart }
465 IconButton(onClick = {
534 IconButton(onClick = {
466 onEdit(textToCopy)
535 onEdit(textToCopy)
467 // Editing reloads only the text; if this prompt carried images or
536 // Editing reloads only the text; if this prompt carried images or
@@ -503,7 +572,7 @@ private fun UserBubble(
503 }
572 }
504 }
573 }
505 }
574 }
506− MessageLongPressMenu(
575+ UserMessageLongPressMenu(
507 expanded = longPressMenu,
576 expanded = longPressMenu,
508 onDismiss = { longPressMenu = false },
577 onDismiss = { longPressMenu = false },
509 offset = longPressOffset,
578 offset = longPressOffset,
@@ -513,6 +582,10 @@ private fun UserBubble(
513 onBranch = onBranch,
582 onBranch = onBranch,
514 onRevert = onRevert,
583 onRevert = onRevert,
515 onShare = onShare,
584 onShare = onShare,
585+ onEdit = onEdit,
586+ hasAttachments = hasAttachments,
587+ dropsAttachmentsMsg = dropsAttachmentsMsg,
588+ context = context,
516 )
589 )
517 }
590 }
518 }
591 }
@@ -540,8 +613,26 @@ private fun AssistantBlock(
540 isEdited: Boolean = false,
613 isEdited: Boolean = false,
541 onShare: (() -> Unit)? = null,
614 onShare: (() -> Unit)? = null,
542) {
615) {
616+ // Long-press context menu state, hoisted to the block scope so the whole bubble (body and
617+ // footer) is the long-press target — see UserBubble for rationale.
618+ val assistantHaptics = LocalHapticFeedback.current
619+ val assistantContext = LocalContext.current
620+ val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
621+ var longPressMenu by remember { mutableStateOf(false) }
622+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
543 Column(
623 Column(
544− modifier = modifier.fillMaxWidth(),
624+ modifier = modifier
625+ .fillMaxWidth()
626+ .pointerInput(Unit) {
627+ detectTapGestures(onLongPress = { offset ->
628+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
629+ longPressOffset = androidx.compose.ui.unit.DpOffset(
630+ with(assistantDensity) { offset.x.toDp() },
631+ with(assistantDensity) { offset.y.toDp() },
632+ )
633+ longPressMenu = true
634+ })
635+ },
545 verticalArrangement = Arrangement.spacedBy(8.dp),
636 verticalArrangement = Arrangement.spacedBy(8.dp),
546 ) {
637 ) {
547 val info = message.info
638 val info = message.info
@@ -593,9 +684,8 @@ private fun AssistantBlock(
593 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
684 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
594 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
685 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
595 }
686 }
596− // Long-press context menu state (see UserBubble for rationale).
687+ // Long-press context menu state is hoisted to the block scope (see the Column
597− var longPressMenu by remember { mutableStateOf(false) }
688+ // above) so the whole bubble is the long-press target.
598− var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
599 // Collect text from all TextParts for copy/read-aloud. Memoized so a
689 // Collect text from all TextParts for copy/read-aloud. Memoized so a
600 // scroll-induced recomposition doesn't re-scan the parts list.
690 // scroll-induced recomposition doesn't re-scan the parts list.
601 val textToCopy = remember(message.parts) {
691 val textToCopy = remember(message.parts) {
@@ -604,9 +694,6 @@ private fun AssistantBlock(
604 .joinToString("\n\n") { it.text }
694 .joinToString("\n\n") { it.text }
605 .takeIf { it.isNotBlank() }
695 .takeIf { it.isNotBlank() }
606 }
696 }
607− val assistantHaptics = LocalHapticFeedback.current
608− val assistantContext = LocalContext.current
609− val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
610 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
697 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
611 {
698 {
612 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
699 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -615,18 +702,7 @@ private fun AssistantBlock(
615 }
702 }
616 Box {
703 Box {
617 Row(
704 Row(
618− modifier = Modifier
705+ modifier = Modifier.fillMaxWidth(),
619− .fillMaxWidth()
620− .pointerInput(Unit) {
621− detectTapGestures(onLongPress = { offset ->
622− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
623− longPressOffset = androidx.compose.ui.unit.DpOffset(
624− with(assistantDensity) { offset.x.toDp() },
625− with(assistantDensity) { offset.y.toDp() },
626− )
627− longPressMenu = true
628− })
629− },
630 horizontalArrangement = Arrangement.SpaceBetween,
706 horizontalArrangement = Arrangement.SpaceBetween,
631 verticalAlignment = Alignment.CenterVertically,
707 verticalAlignment = Alignment.CenterVertically,
632 ) {
708 ) {
@@ -636,9 +712,10 @@ private fun AssistantBlock(
636 it,
712 it,
637 style = MaterialTheme.typography.labelSmall,
713 style = MaterialTheme.typography.labelSmall,
638 color = MaterialTheme.colorScheme.onSurfaceVariant,
714 color = MaterialTheme.colorScheme.onSurfaceVariant,
639− // Cap to a single line so a long "1.2k in · 540 out • $0.012"
715+ // Allow up to 2 lines so a multi-segment summary (in/out • reasoning
640− // summary doesn't wrap the "•" awkwardly at narrow widths.
716+ // • cache read • cost) wraps instead of ellipsizing away the cost —
717+ // the part users most want — on narrow screens.
641− maxLines = 1,
718+ maxLines = 2,
642 overflow = TextOverflow.Ellipsis,
719 overflow = TextOverflow.Ellipsis,
643 )
720 )
644 }
721 }
@@ -664,7 +741,7 @@ private fun AssistantBlock(
664 onRegenerate = onRegenerate,
741 onRegenerate = onRegenerate,
665 )
742 )
666 }
743 }
667− MessageLongPressMenu(
744+ AssistantMessageLongPressMenu(
668 expanded = longPressMenu,
745 expanded = longPressMenu,
669 onDismiss = { longPressMenu = false },
746 onDismiss = { longPressMenu = false },
670 offset = longPressOffset,
747 offset = longPressOffset,
@@ -674,6 +751,12 @@ private fun AssistantBlock(
674 onBranch = onBranch,
751 onBranch = onBranch,
675 onRevert = onRevert,
752 onRevert = onRevert,
676 onShare = onShare,
753 onShare = onShare,
754+ onRegenerate = onRegenerate,
755+ onSpeak = onSpeak,
756+ isSpeaking = isSpeaking,
757+ ttsState = ttsState,
758+ onPause = onPause,
759+ onResume = onResume,
677 )
760 )
678 }
761 }
679 } else {
762 } else {
@@ -682,6 +765,65 @@ private fun AssistantBlock(
682 }
765 }
683}
766}
684
767
768+/** Wraps [MessageLongPressMenu] for an assistant reply, building the Read-aloud toggle
769+ * callback and label from the current speaking state so the branch count stays out of
770+ * [AssistantBlock]. */
771+@Composable
772+private fun AssistantMessageLongPressMenu(
773+ expanded: Boolean,
774+ onDismiss: () -> Unit,
775+ offset: androidx.compose.ui.unit.DpOffset,
776+ text: String?,
777+ onCopy: (() -> Unit)?,
778+ onQuote: ((String) -> Unit)?,
779+ onBranch: ((String) -> Unit)?,
780+ onRevert: (() -> Unit)?,
781+ onShare: (() -> Unit)?,
782+ onRegenerate: (() -> Unit)?,
783+ onSpeak: ((String) -> Unit)?,
784+ isSpeaking: Boolean,
785+ ttsState: TtsState,
786+ onPause: (() -> Unit)?,
787+ onResume: (() -> Unit)?,
788+) {
789+ val speakForMenu: (() -> Unit)? = if (onSpeak != null && text != null) {
790+ {
791+ when {
792+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
793+ isSpeaking -> onPause?.invoke()
794+ else -> onSpeak(text)
795+ }
796+ }
797+ } else {
798+ null
799+ }
800+ val speakLabel: String? = if (onSpeak != null && text != null) {
801+ stringResource(
802+ when {
803+ isSpeaking && ttsState == TtsState.PAUSED -> R.string.resume_reading
804+ isSpeaking -> R.string.pause_reading
805+ else -> R.string.read_aloud
806+ },
807+ )
808+ } else {
809+ null
810+ }
811+ MessageLongPressMenu(
812+ expanded = expanded,
813+ onDismiss = onDismiss,
814+ offset = offset,
815+ text = text,
816+ onCopy = onCopy,
817+ onQuote = onQuote,
818+ onBranch = onBranch,
819+ onRevert = onRevert,
820+ onShare = onShare,
821+ onRegenerate = onRegenerate,
822+ onSpeak = speakForMenu,
823+ speakLabel = speakLabel,
824+ )
825+}
826+
685/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
827/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
686 * that function's cyclomatic complexity under the detekt threshold. */
828 * that function's cyclomatic complexity under the detekt threshold. */
687@Composable
829@Composable
app/src/main/java/soy/iko/opencode/ui/components/LoadingSize.ktnew file+12
@@ -0,0 +1,12 @@
1+package soy.iko.opencode.ui.components
2+
3+/**
4+ * Canonical size/stroke combinations for [LoadingSpinner], replacing the scattered 16/18/20/24/
5+ * 28/48dp and 2/3/4-stroke variants that previously littered the UI.
6+ */
7+enum class LoadingSize(val sizeDp: Int, val strokeDp: Int) {
8+ Inline(16, 2),
9+ Small(20, 2),
10+ Medium(28, 3),
11+ Large(40, 4),
12+}
app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.ktnew file+31
@@ -0,0 +1,31 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.size
4+import androidx.compose.material3.CircularProgressIndicator
5+import androidx.compose.runtime.Composable
6+import androidx.compose.ui.Modifier
7+import androidx.compose.ui.semantics.contentDescription
8+import androidx.compose.ui.semantics.semantics
9+import androidx.compose.ui.unit.dp
10+import androidx.compose.ui.res.stringResource
11+import soy.iko.opencode.R
12+
13+/**
14+ * The single source of truth for indeterminate loading spinners across the app. Always sets a
15+ * [contentDescription] so TalkBack announces progress (a bare [CircularProgressIndicator] is
16+ * announced only inconsistently, especially when inlined inside a Button or replacing an icon),
17+ * and uses a small set of canonical size/stroke combinations from [LoadingSize].
18+ */
19+@Composable
20+fun LoadingSpinner(
21+ size: LoadingSize = LoadingSize.Medium,
22+ modifier: Modifier = Modifier,
23+) {
24+ val label = stringResource(R.string.loading)
25+ CircularProgressIndicator(
26+ modifier = modifier
27+ .size(size.sizeDp.dp)
28+ .semantics { contentDescription = label },
29+ strokeWidth = size.strokeDp.dp,
30+ )
31+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5−2
@@ -548,9 +548,12 @@ private fun MarkdownImage(model: MarkdownComponentModel, ctx: ImageLoadContext)
548 // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
548 // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
549 val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
549 val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
550 if (url.isNullOrBlank()) {
550 if (url.isNullOrBlank()) {
551− // No URL — show the alt text (or a placeholder) so the image isn't invisible.
551+ // No URL — show the alt text (or a placeholder) so the image isn't invisible. The
552+ // earlier `"[$alt]".ifBlank { "[image]" }` was a bug: when alt is blank, "[$alt]" is
553+ // "[]" — a non-blank string — so the [image] fallback never fired and the user saw "[]".
554+ val placeholder = if (alt.isNotBlank()) alt else stringResource(R.string.markdown_image_placeholder)
552 Text(
555 Text(
553− alt.ifBlank { "[$alt]" }.ifBlank { "[image]" },
556+ placeholder,
554 style = MaterialTheme.typography.bodyMedium,
557 style = MaterialTheme.typography.bodyMedium,
555 color = MaterialTheme.colorScheme.onSurfaceVariant,
558 color = MaterialTheme.colorScheme.onSurfaceVariant,
556 modifier = Modifier.padding(vertical = 4.dp),
559 modifier = Modifier.padding(vertical = 4.dp),
app/src/main/java/soy/iko/opencode/ui/components/QrCode.kt+4−3
@@ -124,12 +124,13 @@ suspend fun decodeServerQr(context: android.content.Context, uri: android.net.Ur
124 * background (QR scanners need the light module) with a small inner pad so the code isn't flush
124 * background (QR scanners need the light module) with a small inner pad so the code isn't flush
125 * with the dialog edges. */
125 * with the dialog edges. */
126@Composable
126@Composable
127−fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier) {
127+fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier, label: String? = null) {
128 val bitmap = remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
128 val bitmap = remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
129 if (bitmap != null) {
129 if (bitmap != null) {
130+ val description = label?.let { stringResource(R.string.qr_image_for, it) }
130 Image(
131 Image(
131 bitmap = bitmap.asImageBitmap(),
132 bitmap = bitmap.asImageBitmap(),
132− contentDescription = null,
133+ contentDescription = description,
133 modifier = modifier
134 modifier = modifier
134 .size(sizePx.dp)
135 .size(sizePx.dp)
135 .background(Color.White)
136 .background(Color.White)
@@ -153,7 +154,7 @@ fun QrShareDialog(profile: ServerProfile, onDismiss: () -> Unit) {
153 horizontalAlignment = Alignment.CenterHorizontally,
154 horizontalAlignment = Alignment.CenterHorizontally,
154 modifier = Modifier.fillMaxWidth(),
155 modifier = Modifier.fillMaxWidth(),
155 ) {
156 ) {
156− QrImage(content = payload, sizePx = 256)
157+ QrImage(content = payload, sizePx = 256, label = profile.displayLabel)
157 Spacer(Modifier.size(8.dp))
158 Spacer(Modifier.size(8.dp))
158 Text(
159 Text(
159 profile.baseUrl,
160 profile.baseUrl,
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+1−1
@@ -266,7 +266,7 @@ private fun FullscreenImageViewer(
266 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
266 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
267 // a continuous motion rather than a snap.
267 // a continuous motion rather than a snap.
268 var dragOffsetY by remember { mutableFloatStateOf(0f) }
268 var dragOffsetY by remember { mutableFloatStateOf(0f) }
269− val dismissThreshold = 200f
269+ val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
270 Dialog(
270 Dialog(
271 onDismissRequest = onDismiss,
271 onDismissRequest = onDismiss,
272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4
@@ -114,6 +114,10 @@ fun FileBrowserScreen(
114 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
114 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
115 val snackbar = remember { SnackbarHostState() }
115 val snackbar = remember { SnackbarHostState() }
116
116
117+ // System-back navigates up the directory tree while inside a subfolder (matching every
118+ // other file manager's muscle memory), only exiting the screen at the root.
119+ androidx.activity.compose.BackHandler(enabled = state.path.isNotBlank()) { vm.up() }
120+
117 LaunchedEffect(Unit) {
121 LaunchedEffect(Unit) {
118 vm.transientErrors.collect { msg ->
122 vm.transientErrors.collect { msg ->
119 snackbar.showSnackbar(msg)
123 snackbar.showSnackbar(msg)
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+41−11
@@ -158,10 +158,14 @@ fun FileViewScreen(
158 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
158 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
159 // backed by the full list, which would keep every line (and the full rawText) in memory.
159 // backed by the full list, which would keep every line (and the full rawText) in memory.
160 val allLines = remember(rawText) { rawText.split("\n") }
160 val allLines = remember(rawText) { rawText.split("\n") }
161− val truncated = allLines.size > MAX_RENDERED_LINES
161+ // The viewer renders the file in chunks of MAX_RENDERED_LINES so a huge file doesn't lay
162+ // out thousands of lines at once. The user can load the next chunk via the truncation
163+ // banner's "Load more" button instead of being stranded at the first cap (or forced to
164+ // leave the app via Share full file).
165+ var renderLimit by rememberSaveable(path) { mutableStateOf(MAX_RENDERED_LINES) }
162− val lines = remember(rawText) {
166+ val lines = remember(rawText, renderLimit) { allLines.take(renderLimit) }
163− if (allLines.size > MAX_RENDERED_LINES) allLines.take(MAX_RENDERED_LINES) else allLines
167+ val truncated = allLines.size > lines.size
164− }
168+ val moreLines = (allLines.size - lines.size).coerceAtMost(MAX_RENDERED_LINES)
165 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
169 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
166 // or starts an in-file find so the landed line stands out. Null once cleared.
170 // or starts an in-file find so the landed line stands out. Null once cleared.
167 val highlightLine = rememberJumpToLineHighlight(
171 val highlightLine = rememberJumpToLineHighlight(
@@ -320,6 +324,8 @@ fun FileViewScreen(
320 wrap = wrap,
324 wrap = wrap,
321 lines = lines,
325 lines = lines,
322 truncated = truncated,
326 truncated = truncated,
327+ moreLines = moreLines,
328+ onLoadMore = { renderLimit += MAX_RENDERED_LINES },
323 highlightLineIndex = highlightLine.value,
329 highlightLineIndex = highlightLine.value,
324 listState = listState,
330 listState = listState,
325 onRetry = { vm.reload() },
331 onRetry = { vm.reload() },
@@ -332,7 +338,19 @@ fun FileViewScreen(
332 putExtra(Intent.EXTRA_TEXT, rawText)
338 putExtra(Intent.EXTRA_TEXT, rawText)
333 }
339 }
334 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
340 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
341+ .onFailure {
342+ // A large file can blow past the Binder transaction limit
343+ // (~1MB); distinguish that from a missing share target so the
344+ // message is honest instead of blaming a nonexistent app.
345+ val msg = if (it is android.os.DeadObjectException ||
346+ it is android.os.TransactionTooLargeException
347+ ) {
348+ context.getString(R.string.file_share_too_large)
349+ } else {
350+ context.getString(R.string.no_share_app)
351+ }
335− .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
352+ scope.launch { snackbar.showSnackbar(msg) }
353+ }
336 },
354 },
337 )
355 )
338 }
356 }
@@ -366,7 +384,7 @@ private fun GoToLineLauncher(
366private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
384private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
367 var text by rememberSaveable { mutableStateOf("") }
385 var text by rememberSaveable { mutableStateOf("") }
368 val parsed = text.trim().toIntOrNull()
386 val parsed = text.trim().toIntOrNull()
369− val valid = parsed != null && parsed >= 1
387+ val valid = parsed != null && parsed in 1..maxLine
370 AlertDialog(
388 AlertDialog(
371 onDismissRequest = onDismiss,
389 onDismissRequest = onDismiss,
372 title = { Text(stringResource(R.string.go_to_line)) },
390 title = { Text(stringResource(R.string.go_to_line)) },
@@ -386,7 +404,7 @@ private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: ()
386 keyboardType = KeyboardType.Number,
404 keyboardType = KeyboardType.Number,
387 imeAction = ImeAction.Done,
405 imeAction = ImeAction.Done,
388 ),
406 ),
389− keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
407+ keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
390 )
408 )
391 },
409 },
392 confirmButton = {
410 confirmButton = {
@@ -568,6 +586,8 @@ private fun BoxScope.FileViewStateContent(
568 wrap: Boolean,
586 wrap: Boolean,
569 lines: List<String>,
587 lines: List<String>,
570 truncated: Boolean,
588 truncated: Boolean,
589+ moreLines: Int,
590+ onLoadMore: () -> Unit,
571 highlightLineIndex: Int?,
591 highlightLineIndex: Int?,
572 listState: LazyListState,
592 listState: LazyListState,
573 onRetry: () -> Unit,
593 onRetry: () -> Unit,
@@ -614,6 +634,8 @@ private fun BoxScope.FileViewStateContent(
614 wrap = wrap,
634 wrap = wrap,
615 lines = lines,
635 lines = lines,
616 truncated = truncated,
636 truncated = truncated,
637+ moreLines = moreLines,
638+ onLoadMore = onLoadMore,
617 highlightLineIndex = highlightLineIndex,
639 highlightLineIndex = highlightLineIndex,
618 listState = listState,
640 listState = listState,
619 onShareFull = onShareFull,
641 onShareFull = onShareFull,
@@ -641,6 +663,8 @@ private fun BoxScope.FileViewContentBody(
641 wrap: Boolean,
663 wrap: Boolean,
642 lines: List<String>,
664 lines: List<String>,
643 truncated: Boolean,
665 truncated: Boolean,
666+ moreLines: Int,
667+ onLoadMore: () -> Unit,
644 highlightLineIndex: Int?,
668 highlightLineIndex: Int?,
645 listState: LazyListState,
669 listState: LazyListState,
646 onShareFull: () -> Unit,
670 onShareFull: () -> Unit,
@@ -733,6 +757,8 @@ private fun BoxScope.FileViewContentBody(
733 filename = filename,
757 filename = filename,
734 topInset = topInset,
758 topInset = topInset,
735 truncated = truncated,
759 truncated = truncated,
760+ moreLines = moreLines,
761+ onLoadMore = onLoadMore,
736 wrap = wrap,
762 wrap = wrap,
737 findQuery = findQuery,
763 findQuery = findQuery,
738 matchIndices = matchIndices,
764 matchIndices = matchIndices,
@@ -819,6 +845,8 @@ private fun FileTextContent(
819 filename: String,
845 filename: String,
820 topInset: androidx.compose.ui.unit.Dp,
846 topInset: androidx.compose.ui.unit.Dp,
821 truncated: Boolean,
847 truncated: Boolean,
848+ moreLines: Int,
849+ onLoadMore: () -> Unit,
822 wrap: Boolean,
850 wrap: Boolean,
823 findQuery: String,
851 findQuery: String,
824 matchIndices: List<Int>,
852 matchIndices: List<Int>,
@@ -859,14 +887,16 @@ private fun FileTextContent(
859 horizontalArrangement = Arrangement.SpaceBetween,
887 horizontalArrangement = Arrangement.SpaceBetween,
860 ) {
888 ) {
861 Text(
889 Text(
862− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
890+ stringResource(R.string.file_truncated, lines.size),
863 style = MaterialTheme.typography.labelSmall,
891 style = MaterialTheme.typography.labelSmall,
864 color = MaterialTheme.colorScheme.onSurfaceVariant,
892 color = MaterialTheme.colorScheme.onSurfaceVariant,
865 modifier = Modifier.weight(1f),
893 modifier = Modifier.weight(1f),
866 )
894 )
867− // Escape hatch: when only the first N lines render, offer the (already
895+ // Load the next chunk in-place so the user can keep reading the file without
868− // full-content) Share action inline so the user isn't stranded without the
896+ // leaving the app. Falls back to Share full file for the whole content.
869− // rest of the file. The overflow Share shares the same full content.
897+ TextButton(onClick = onLoadMore) {
898+ Text(stringResource(R.string.load_more_lines, moreLines))
899+ }
870 TextButton(onClick = onShareFull) {
900 TextButton(onClick = onShareFull) {
871 Text(stringResource(R.string.share_full_file))
901 Text(stringResource(R.string.share_full_file))
872 }
902 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+18−3
@@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
@@ -109,9 +110,23 @@ fun GlobalSearchScreen(
109 ConnectionBannerFor(container)
110 ConnectionBannerFor(container)
110 val searchingLabel = stringResource(R.string.searching)
111 val searchingLabel = stringResource(R.string.searching)
111 when {
112 when {
112− state.searching -> CircularProgressIndicator(
113+ state.searching -> Column(
114+ modifier = Modifier.align(Alignment.Center),
115+ horizontalAlignment = Alignment.CenterHorizontally,
116+ ) {
117+ CircularProgressIndicator(
113− Modifier.align(Alignment.Center).semantics { contentDescription = searchingLabel },
118+ Modifier.semantics { contentDescription = searchingLabel },
119+ )
120+ // Surface determinate progress so a 50-session pass doesn't look stuck.
121+ if (state.totalCount > 1) {
122+ Spacer(Modifier.size(12.dp))
123+ Text(
124+ stringResource(R.string.search_progress, state.searchedCount, state.totalCount),
125+ style = MaterialTheme.typography.labelMedium,
126+ color = MaterialTheme.colorScheme.onSurfaceVariant,
114− )
127+ )
128+ }
129+ }
115 state.error != null -> EmptyState(
130 state.error != null -> EmptyState(
116 icon = Icons.Filled.ErrorOutline,
131 icon = Icons.Filled.ErrorOutline,
117 title = state.error ?: "",
132 title = state.error ?: "",
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+62−11
@@ -5,9 +5,17 @@ import androidx.compose.runtime.Immutable
5import androidx.lifecycle.ViewModel
5import androidx.lifecycle.ViewModel
6import androidx.lifecycle.viewModelScope
6import androidx.lifecycle.viewModelScope
7import soy.iko.opencode.R
7import soy.iko.opencode.R
8+import soy.iko.opencode.data.model.FilePart
8import soy.iko.opencode.data.model.MessageWithParts
9import soy.iko.opencode.data.model.MessageWithParts
10+import soy.iko.opencode.data.model.ReasoningPart
9import soy.iko.opencode.data.model.Session
11import soy.iko.opencode.data.model.Session
10import soy.iko.opencode.data.model.TextPart
12import soy.iko.opencode.data.model.TextPart
13+import soy.iko.opencode.data.model.ToolCompleted
14+import soy.iko.opencode.data.model.ToolError
15+import soy.iko.opencode.data.model.ToolPart
16+import soy.iko.opencode.data.model.ToolRunning
17+import soy.iko.opencode.data.model.inputElement
18+import soy.iko.opencode.data.model.sourcePath
11import soy.iko.opencode.data.network.NetworkConfig
19import soy.iko.opencode.data.network.NetworkConfig
12import soy.iko.opencode.di.AppContainer
20import soy.iko.opencode.di.AppContainer
13import soy.iko.opencode.util.runCatchingCancellable
21import soy.iko.opencode.util.runCatchingCancellable
@@ -47,6 +55,12 @@ data class GlobalSearchState(
47 /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
55 /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
48 * search only covered the most recent ones. */
56 * search only covered the most recent ones. */
49 val truncated: Boolean = false,
57 val truncated: Boolean = false,
58+ /** How many sessions have been scanned so far in the current pass, for progress feedback
59+ * (a global search downloads each session's history, so a 50-session pass can take a
60+ * while; surfacing the count keeps the spinner from looking stuck). */
61+ val searchedCount: Int = 0,
62+ /** Total sessions being scanned in the current pass ([searchedCount] / [totalCount]). */
63+ val totalCount: Int = 0,
50)
64)
51
65
52/**
66/**
@@ -113,8 +127,10 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
113 }.also { sessionsCache = it }
127 }.also { sessionsCache = it }
114 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
128 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
115 val truncated = sessions.size > toSearch.size
129 val truncated = sessions.size > toSearch.size
130+ _state.update { it.copy(totalCount = toSearch.size, searchedCount = 0) }
116 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
131 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
117 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
132 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
133+ val searched = java.util.concurrent.atomic.AtomicInteger(0)
118 coroutineScope {
134 coroutineScope {
119 toSearch.forEach { session ->
135 toSearch.forEach { session ->
120 launch {
136 launch {
@@ -141,6 +157,8 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
141 matchedTitle = titleMatched,
157 matchedTitle = titleMatched,
142 )
158 )
143 }
159 }
160+ // Publish progress so the spinner can show "Searched N / M sessions".
161+ _state.update { it.copy(searchedCount = searched.incrementAndGet()) }
144 }
162 }
145 }
163 }
146 }
164 }
@@ -156,22 +174,56 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
156 }
174 }
157 }
175 }
158
176
159− /** Find the first text part containing [query] across [messages] and return a short
177+ /** Find the first searchable text containing [query] across [messages] and return a short
160− * snippet centered on the match, or null if nothing matches. */
178+ * snippet centered on the match, or null if nothing matches. Searches text, reasoning,
179+ * tool call names/titles/output/error, and file names/paths — for a coding assistant the
180+ * most common searches (an error string, a touched file) live in tool calls. */
161 private fun matchSnippet(
181 private fun matchSnippet(
162 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
182 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
163 query: String,
183 query: String,
164 ): String? {
184 ): String? {
165 for (message in messages) {
185 for (message in messages) {
166− for (part in message.parts) {
186+ for (candidate in searchableTexts(message.parts)) {
167− if (part !is TextPart) continue
168− val idx = part.text.indexOf(query, ignoreCase = true)
187+ val idx = candidate.indexOf(query, ignoreCase = true)
169− if (idx >= 0) return buildSnippet(part.text, idx, query.length)
188+ if (idx >= 0) return buildSnippet(candidate, idx, query.length)
170 }
189 }
171 }
190 }
172 return null
191 return null
173 }
192 }
174
193
194+ /** The ordered list of searchable strings for a message's parts. Text and reasoning are
195+ * searched in full; tool calls contribute their name, title, output, error, and the
196+ * stringified input; file parts contribute their filename and source path. */
197+ private fun searchableTexts(parts: List<soy.iko.opencode.data.model.Part>): List<String> {
198+ if (parts.isEmpty()) return emptyList()
199+ val out = ArrayList<String>(parts.size)
200+ for (p in parts) {
201+ when (p) {
202+ is TextPart -> out.add(p.text)
203+ is ReasoningPart -> out.add(p.text)
204+ is ToolPart -> {
205+ out.add(p.tool)
206+ when (p.state) {
207+ is ToolRunning -> p.state.title?.let(out::add)
208+ is ToolCompleted -> {
209+ p.state.title?.let(out::add)
210+ p.state.output?.let(out::add)
211+ }
212+ is ToolError -> p.state.error?.let(out::add)
213+ else -> {}
214+ }
215+ p.state.inputElement()?.toString()?.let(out::add)
216+ }
217+ is FilePart -> {
218+ p.filename?.let(out::add)
219+ p.sourcePath?.let(out::add)
220+ }
221+ else -> {}
222+ }
223+ }
224+ return out
225+ }
226+
175 private fun buildSnippet(text: String, matchStart: Int, matchLength: Int): String {
227 private fun buildSnippet(text: String, matchStart: Int, matchLength: Int): String {
176 val window = NetworkConfig.searchSnippetLength
228 val window = NetworkConfig.searchSnippetLength
177 val start = (matchStart - window / 2).coerceAtLeast(0)
229 val start = (matchStart - window / 2).coerceAtLeast(0)
@@ -181,17 +233,16 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
181 return prefix + text.substring(start, end).trim() + suffix
233 return prefix + text.substring(start, end).trim() + suffix
182 }
234 }
183
235
184− /** Count all occurrences of [query] across the text parts of [messages] (case-insensitive,
236+ /** Count all occurrences of [query] across the searchable text of [messages] (text,
185− * non-overlapping) so a result card can show "N matches" instead of just one snippet. */
237+ * reasoning, tool calls, file names) so a result card can show "N matches". */
186 private fun countMatches(
238 private fun countMatches(
187 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
239 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
188 query: String,
240 query: String,
189 ): Int {
241 ): Int {
190 var count = 0
242 var count = 0
191 for (message in messages) {
243 for (message in messages) {
192− for (part in message.parts) {
244+ for (text in searchableTexts(message.parts)) {
193− if (part !is TextPart) continue
194− count += countIn(part.text, query)
245+ count += countIn(text, query)
195 }
246 }
196 }
247 }
197 return count
248 return count
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+16
@@ -463,6 +463,17 @@ private fun ServerCardContent(
463 maxLines = 1,
463 maxLines = 1,
464 overflow = TextOverflow.Ellipsis,
464 overflow = TextOverflow.Ellipsis,
465 )
465 )
466+ // Surface the scheme and whether cert pinning is on so a user juggling HTTPS-pinned
467+ // servers and cleartext LAN servers can tell them apart at a glance without opening
468+ // Edit. A closed lock = https, an open lock = http, and a pinned-lock overlay when a
469+ // cert pin is configured.
470+ val isHttps = profile.baseUrl.startsWith("https://", ignoreCase = true)
471+ val hasPin = !profile.certPin.isNullOrBlank()
472+ val schemeLabel = if (isHttps) {
473+ if (hasPin) stringResource(R.string.server_https_pinned) else stringResource(R.string.server_https)
474+ } else {
475+ stringResource(R.string.server_http)
476+ }
466 Text(
477 Text(
467 profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
478 profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
468 style = MaterialTheme.typography.bodySmall,
479 style = MaterialTheme.typography.bodySmall,
@@ -470,6 +481,11 @@ private fun ServerCardContent(
470 maxLines = 1,
481 maxLines = 1,
471 overflow = TextOverflow.Ellipsis,
482 overflow = TextOverflow.Ellipsis,
472 )
483 )
484+ Text(
485+ schemeLabel,
486+ style = MaterialTheme.typography.labelSmall,
487+ color = MaterialTheme.colorScheme.onSurfaceVariant,
488+ )
473 if (isActive) {
489 if (isActive) {
474 // Reflect the live SSE state instead of a static "Connected": a dropped or failed
490 // Reflect the live SSE state instead of a static "Connected": a dropped or failed
475 // stream reads as the real status so a glance at the card isn't misleading.
491 // stream reads as the real status so a glance at the card isn't misleading.
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+4−4
@@ -618,8 +618,8 @@ fun SettingsScreen(
618 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
618 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
619 }
619 }
620 // Discoverability links from the About section: source code and issue tracker.
620 // Discoverability links from the About section: source code and issue tracker.
621− // Opens in the browser via ACTION_VIEW; if nothing can handle it, the chooser just
621+ // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
622− // shows no options (a no-op), which is acceptable for these optional links.
622+ // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
623 NavRow(
623 NavRow(
624 icon = Icons.Filled.Code,
624 icon = Icons.Filled.Code,
625 label = stringResource(R.string.source_code),
625 label = stringResource(R.string.source_code),
@@ -629,7 +629,7 @@ fun SettingsScreen(
629 context.startActivity(
629 context.startActivity(
630 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
630 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
631 )
631 )
632− }
632+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
633 },
633 },
634 )
634 )
635 NavRow(
635 NavRow(
@@ -641,7 +641,7 @@ fun SettingsScreen(
641 context.startActivity(
641 context.startActivity(
642 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
642 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
643 )
643 )
644− }
644+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
645 },
645 },
646 )
646 )
647 }
647 }
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.ui.unit.dp
4+
5+/**
6+ * Centralized dimension tokens for icon sizes and spacing. The UI previously scattered bare
7+ * `.size(N.dp)` and `Spacer(Modifier.size(N.dp))` literals across dozens of call sites with
8+ * slightly different values (12/14/16/18/20/22/24dp for icons; 4/6/8/10/12/14/16/20/24/32dp for
9+ * spacing). Naming them keeps the design system consistent and lets a global bump happen in
10+ * one place. New code should prefer these over raw literals.
11+ */
12+object IconSize {
13+ /** Dense inline icons inside rows/headers (e.g. a 14dp copy icon inside a card header). */
14+ const val dense = 14
15+ /** Standard inline icon inside an IconButton or a row. */
16+ const val standard = 18
17+ /** Full-size icon, typically inside a 48dp IconButton. */
18+ const val large = 24
19+}
20+
21+object Spacing {
22+ val xs = 4.dp
23+ val sm = 8.dp
24+ val md = 12.dp
25+ val lg = 16.dp
26+ val xl = 24.dp
27+ val xxl = 32.dp
28+}
app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+4−1
@@ -126,7 +126,10 @@ val OpencodeTypography = Typography(
126 labelSmall = TextStyle(
126 labelSmall = TextStyle(
127 fontFamily = FontFamily.Default,
127 fontFamily = FontFamily.Default,
128 fontWeight = FontWeight.Medium,
128 fontWeight = FontWeight.Medium,
129+ // 12sp (bumped from the M3 default of 11sp) so secondary labels — timestamps, char
130+ // counts, token summaries, code-block language tags — stay above the 12sp floor
131+ // commonly recommended for text a user must read. The OS font scale still applies on top.
129− fontSize = 11.sp,
132+ fontSize = 12.sp,
130 lineHeight = 16.sp,
133 lineHeight = 16.sp,
131 letterSpacing = 0.4.sp,
134 letterSpacing = 0.4.sp,
132 ),
135 ),
app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+43−3
@@ -25,6 +25,33 @@ data class SessionUsage(
25 val tokens: Tokens,
25 val tokens: Tokens,
26)
26)
27
27
28+/** One session's inputs to [aggregateUsage]: its id, title, last-modified timestamp (epoch
29+ * millis, or null when unknown), and messages. The timestamp lets a [UsageTimeRange] filter
30+ * exclude sessions outside the window before they contribute to the totals. */
31+@Immutable
32+data class UsageSessionInput(
33+ val sessionId: String,
34+ val title: String,
35+ val modifiedAt: Long?,
36+ val messages: List<MessageWithParts>,
37+)
38+
39+/** Time-range filter for the usage dashboard. [cutoffMs] returns the epoch-millis threshold
40+ * before which a session is excluded (or null for "all time"), computed against [now]. */
41+enum class UsageTimeRange {
42+ TWENTY_FOUR_HOURS,
43+ SEVEN_DAYS,
44+ THIRTY_DAYS,
45+ ALL_TIME;
46+
47+ fun cutoffMs(now: Long = System.currentTimeMillis()): Long? = when (this) {
48+ TWENTY_FOUR_HOURS -> now - 24L * 60 * 60 * 1000
49+ SEVEN_DAYS -> now - 7L * 24 * 60 * 60 * 1000
50+ THIRTY_DAYS -> now - 30L * 24 * 60 * 60 * 1000
51+ ALL_TIME -> null
52+ }
53+}
54+
28/** A full usage report: overall totals plus per-model and per-session breakdowns. */
55/** A full usage report: overall totals plus per-model and per-session breakdowns. */
29@Immutable
56@Immutable
30data class UsageReport(
57data class UsageReport(
@@ -56,19 +83,32 @@ private class MutableAgg(val provider: String, val model: String) {
56}
83}
57
84
58/**
85/**
59− * Aggregate cost/token usage across sessions. Each entry is (sessionId, title, messages).
86+ * Aggregate cost/token usage across sessions. Each entry carries its messages plus an optional
87+ * last-modified timestamp; when [cutoffMs] is non-null, sessions whose [UsageSessionInput.modifiedAt]
88+ * is older than the cutoff (or unknown) are skipped, so a time-range filter narrows the totals
89+ * AND the per-model/per-session breakdowns rather than just the session list.
90+ *
60 * Only [AssistantMessage]s that reported cost or tokens contribute — user prompts and
91 * Only [AssistantMessage]s that reported cost or tokens contribute — user prompts and
61 * zero-usage messages are ignored so the counts reflect real model spend. Pure and
92 * zero-usage messages are ignored so the counts reflect real model spend. Pure and
62 * synchronous so it's unit-testable and can run off the main thread.
93 * synchronous so it's unit-testable and can run off the main thread.
63 */
94 */
64−fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>>): UsageReport {
95+fun aggregateUsage(
96+ sessions: List<UsageSessionInput>,
97+ cutoffMs: Long? = null,
98+): UsageReport {
65 var totalCost = 0.0
99 var totalCost = 0.0
66 var totalTokens = Tokens()
100 var totalTokens = Tokens()
67 var messageCount = 0
101 var messageCount = 0
68 val byModel = LinkedHashMap<String, MutableAgg>()
102 val byModel = LinkedHashMap<String, MutableAgg>()
69 val bySession = ArrayList<SessionUsage>()
103 val bySession = ArrayList<SessionUsage>()
70
104
71− for ((id, title, messages) in sessions) {
105+ for (entry in sessions) {
106+ // Apply the time-range filter at the session level: a session modified before the
107+ // cutoff (or with no known timestamp under a finite range) doesn't contribute.
108+ if (cutoffMs != null && (entry.modifiedAt == null || entry.modifiedAt < cutoffMs)) continue
109+ val id = entry.sessionId
110+ val title = entry.title
111+ val messages = entry.messages
72 var sessionCost = 0.0
112 var sessionCost = 0.0
73 var sessionTokens = Tokens()
113 var sessionTokens = Tokens()
74 var sessionMessages = 0
114 var sessionMessages = 0
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+42−3
@@ -5,6 +5,8 @@ import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.ExperimentalLayoutApi
9+import androidx.compose.foundation.layout.FlowRow
8import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.Spacer
@@ -27,6 +29,7 @@ import androidx.compose.material.icons.filled.SearchOff
27import androidx.compose.material3.Card
29import androidx.compose.material3.Card
28import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.ExperimentalMaterial3Api
32+import androidx.compose.material3.FilterChip
30import androidx.compose.material3.Icon
33import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
34import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
35import androidx.compose.material3.MaterialTheme
@@ -68,7 +71,7 @@ import soy.iko.opencode.ui.vmFactory
68import java.text.NumberFormat
71import java.text.NumberFormat
69import java.util.Locale
72import java.util.Locale
70
73
71−@OptIn(ExperimentalMaterial3Api::class)
74+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
72@Composable
75@Composable
73fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
76fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
74 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
77 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
@@ -128,7 +131,13 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
128 modifier = Modifier.align(Alignment.Center),
131 modifier = Modifier.align(Alignment.Center),
129 )
132 )
130 } else {
133 } else {
134+ val timeRange by vm.timeRange.collectAsStateWithLifecycle()
131− UsageContent(s.report, onOpenSession)
135+ UsageContent(
136+ report = s.report,
137+ onOpenSession = onOpenSession,
138+ timeRange = timeRange,
139+ onTimeRangeChange = vm::setTimeRange,
140+ )
132 }
141 }
133 }
142 }
134 }
143 }
@@ -143,8 +152,14 @@ private fun Centered(content: @Composable androidx.compose.foundation.layout.Col
143 }
152 }
144}
153}
145
154
155+@OptIn(ExperimentalLayoutApi::class)
146@Composable
156@Composable
157+private fun UsageContent(
158+ report: UsageReport,
147−private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
159+ onOpenSession: (String) -> Unit,
160+ timeRange: UsageTimeRange,
161+ onTimeRangeChange: (UsageTimeRange) -> Unit,
162+) {
148 // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
163 // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
149 // the whole history, but a user with many sessions can narrow the per-session list to find
164 // the whole history, but a user with many sessions can narrow the per-session list to find
150 // a specific conversation's spend without scrolling through all of it.
165 // a specific conversation's spend without scrolling through all of it.
@@ -154,11 +169,35 @@ private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
154 if (q.isEmpty()) report.bySession
169 if (q.isEmpty()) report.bySession
155 else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
170 else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
156 }
171 }
172+ val ranges = remember {
173+ listOf(
174+ UsageTimeRange.TWENTY_FOUR_HOURS to R.string.usage_range_24h,
175+ UsageTimeRange.SEVEN_DAYS to R.string.usage_range_7d,
176+ UsageTimeRange.THIRTY_DAYS to R.string.usage_range_30d,
177+ UsageTimeRange.ALL_TIME to R.string.usage_range_all,
178+ )
179+ }
157 LazyColumn(
180 LazyColumn(
158 modifier = Modifier.fillMaxSize(),
181 modifier = Modifier.fillMaxSize(),
159 contentPadding = PaddingValues(16.dp),
182 contentPadding = PaddingValues(16.dp),
160 verticalArrangement = Arrangement.spacedBy(12.dp),
183 verticalArrangement = Arrangement.spacedBy(12.dp),
161 ) {
184 ) {
185+ // Time-range filter: narrows the totals AND the breakdowns to the selected window, so
186+ // "how much did I spend today?" is answerable instead of drowning in all-time history.
187+ item {
188+ FlowRow(
189+ modifier = Modifier.fillMaxWidth(),
190+ horizontalArrangement = Arrangement.spacedBy(8.dp),
191+ ) {
192+ ranges.forEach { (range, labelRes) ->
193+ FilterChip(
194+ selected = timeRange == range,
195+ onClick = { onTimeRangeChange(range) },
196+ label = { Text(stringResource(labelRes)) },
197+ )
198+ }
199+ }
200+ }
162 item { TotalsCard(report) }
201 item { TotalsCard(report) }
163 item { TokenBreakdownCard(report.totalTokens) }
202 item { TokenBreakdownCard(report.totalTokens) }
164 if (report.byModel.isNotEmpty()) {
203 if (report.byModel.isNotEmpty()) {
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+34−2
@@ -49,6 +49,15 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
49 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
49 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
50 private val _reload = MutableStateFlow(0)
50 private val _reload = MutableStateFlow(0)
51
51
52+ /** Active time-range filter. Changing it re-aggregates from the cached fetch (no network)
53+ * so toggling ranges is instant. Defaults to all time. */
54+ private val _timeRange = MutableStateFlow(UsageTimeRange.ALL_TIME)
55+ val timeRange: StateFlow<UsageTimeRange> = _timeRange.asStateFlow()
56+
57+ // Cached raw inputs from the last successful fetch, so a time-range change can re-aggregate
58+ // without re-downloading every session's history.
59+ @Volatile private var cachedInputs: List<UsageSessionInput>? = null
60+
52 init {
61 init {
53 // Observe the active connection so the report loads (or reloads) once a connection is
62 // Observe the active connection so the report loads (or reloads) once a connection is
54 // available — including when the screen opens during a reconnect window where
63 // available — including when the screen opens during a reconnect window where
@@ -58,10 +67,26 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
58 viewModelScope.launch {
67 viewModelScope.launch {
59 merge(container.activeConnection, _reload).collectLatest { doLoad() }
68 merge(container.activeConnection, _reload).collectLatest { doLoad() }
60 }
69 }
70+ // Re-aggregate from the cache when only the time range changes (no network round-trip).
71+ viewModelScope.launch {
72+ _timeRange.collectLatest { reaggregate() }
73+ }
61 }
74 }
62
75
63 fun load() { _reload.value++ }
76 fun load() { _reload.value++ }
64
77
78+ fun setTimeRange(range: UsageTimeRange) { _timeRange.value = range }
79+
80+ /** Re-aggregate the cached fetch under the current time range, if a Ready report exists. */
81+ private suspend fun reaggregate() {
82+ val inputs = cachedInputs ?: return
83+ val cutoff = _timeRange.value.cutoffMs()
84+ withContext(Dispatchers.Default) {
85+ val report = aggregateUsage(inputs, cutoff)
86+ _state.value = State.Ready(report)
87+ }
88+ }
89+
65 private suspend fun doLoad() {
90 private suspend fun doLoad() {
66 val conn = container.activeConnection.value ?: run {
91 val conn = container.activeConnection.value ?: run {
67 _state.value = State.Disconnected
92 _state.value = State.Disconnected
@@ -85,12 +110,19 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
85 limiter.withPermit {
110 limiter.withPermit {
86 val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
111 val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
87 .getOrDefault(emptyList())
112 .getOrDefault(emptyList())
113+ UsageSessionInput(
114+ sessionId = session.id,
88− Triple(session.id, session.displayTitle, messages)
115+ title = session.displayTitle,
116+ modifiedAt = session.time?.updated ?: session.time?.created,
117+ messages = messages,
118+ )
89 }
119 }
90 }
120 }
91 }.awaitAll()
121 }.awaitAll()
92 }
122 }
123+ cachedInputs = perSession
124+ val cutoff = _timeRange.value.cutoffMs()
93− withContext(Dispatchers.Default) { aggregateUsage(perSession) }
125+ withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
94 }
126 }
95 .onSuccess { _state.value = State.Ready(it) }
127 .onSuccess { _state.value = State.Ready(it) }
96 .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
128 .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
app/src/main/res/values-es/strings.xml+35
@@ -740,4 +740,39 @@
740 <string name="diff_context_line">Línea %1$d: %2$s</string>
740 <string name="diff_context_line">Línea %1$d: %2$s</string>
741 <string name="tts_stopped_unexpectedly">La lectura en voz alta se detuvo inesperadamente</string>
741 <string name="tts_stopped_unexpectedly">La lectura en voz alta se detuvo inesperadamente</string>
742 <string name="usage_empty_desc">El uso aparece una vez que tu asistente haya enviado mensajes facturables.</string>
742 <string name="usage_empty_desc">El uso aparece una vez que tu asistente haya enviado mensajes facturables.</string>
743+
744+ <!-- UI/UX batch 7 -->
745+ <string name="markdown_image_placeholder">[imagen]</string>
746+ <string name="qr_image_for">Código QR de %1$s</string>
747+ <string name="reconnecting_to">Reconectando a %1$s…</string>
748+ <string name="run_stopped">Ejecución detenida</string>
749+ <string name="preview_attachment">Vista previa del adjunto</string>
750+ <string name="diff_split">Vista dividida</string>
751+ <string name="diff_unified">Vista unificada</string>
752+ <string name="mark_unread">Marcar como no leído</string>
753+ <string name="search_progress">Buscadas %1$d / %2$d sesiones</string>
754+ <string name="recent_searches">Búsquedas recientes</string>
755+ <string name="load_more_lines">Cargar %1$d líneas más</string>
756+ <string name="detect_from_server">Detectar desde el servidor</string>
757+ <string name="detecting_pin">Detectando pin de certificado…</string>
758+ <string name="cert_pin_detected">Pin detectado — revisa y guarda</string>
759+ <string name="cert_pin_detect_failed">No se pudo obtener el certificado del servidor</string>
760+ <string name="mcp_disable">Desactivar</string>
761+ <string name="mcp_enable">Activar</string>
762+ <string name="mcp_remove">Quitar</string>
763+ <string name="mcp_retry">Reintentar</string>
764+ <string name="mcp_remove_title">¿Quitar servidor MCP?</string>
765+ <string name="mcp_remove_text">“%1$s” se quitará de tus servidores MCP.</string>
766+ <string name="mcp_disabled_locally">Desactivado en este dispositivo</string>
767+ <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
768+ <string name="search_filter_all">Todo</string>
769+ <string name="search_filter_messages">Mensajes</string>
770+ <string name="search_filter_tools">Llamadas a herramientas</string>
771+ <string name="search_filter_reasoning">Razonamiento</string>
772+ <string name="search_match_type_message">mensaje</string>
773+ <string name="search_match_type_tool">herramienta</string>
774+ <string name="search_match_type_reasoning">razonamiento</string>
775+ <string name="server_http">HTTP · no seguro</string>
776+ <string name="server_https">HTTPS</string>
777+ <string name="server_https_pinned">HTTPS · cert fijado</string>
743</resources>
778</resources>
app/src/main/res/values/strings.xml+35
@@ -740,4 +740,39 @@
740 <string name="diff_removed_line">Line %1$d removed: %2$s</string>
740 <string name="diff_removed_line">Line %1$d removed: %2$s</string>
741 <string name="diff_context_line">Line %1$d: %2$s</string>
741 <string name="diff_context_line">Line %1$d: %2$s</string>
742 <string name="tts_stopped_unexpectedly">Read-aloud stopped unexpectedly</string>
742 <string name="tts_stopped_unexpectedly">Read-aloud stopped unexpectedly</string>
743+
744+ <!-- UI/UX batch 7: shared components, navigation, files, mcp, usage -->
745+ <string name="markdown_image_placeholder">[image]</string>
746+ <string name="qr_image_for">QR code for %1$s</string>
747+ <string name="reconnecting_to">Reconnecting to %1$s…</string>
748+ <string name="run_stopped">Run stopped</string>
749+ <string name="preview_attachment">Preview attachment</string>
750+ <string name="diff_split">Split view</string>
751+ <string name="diff_unified">Unified view</string>
752+ <string name="mark_unread">Mark as unread</string>
753+ <string name="search_progress">Searched %1$d / %2$d sessions</string>
754+ <string name="recent_searches">Recent searches</string>
755+ <string name="load_more_lines">Load %1$d more lines</string>
756+ <string name="detect_from_server">Detect from server</string>
757+ <string name="detecting_pin">Detecting certificate pin…</string>
758+ <string name="cert_pin_detected">Pin detected — review and save</string>
759+ <string name="cert_pin_detect_failed">Couldn\'t fetch the server certificate</string>
760+ <string name="mcp_disable">Disable</string>
761+ <string name="mcp_enable">Enable</string>
762+ <string name="mcp_remove">Remove</string>
763+ <string name="mcp_retry">Retry</string>
764+ <string name="mcp_remove_title">Remove MCP server?</string>
765+ <string name="mcp_remove_text">“%1$s” will be removed from your MCP servers.</string>
766+ <string name="mcp_disabled_locally">Disabled on this device</string>
767+ <string name="file_share_too_large">File is too large to share</string>
768+ <string name="search_filter_all">All</string>
769+ <string name="search_filter_messages">Messages</string>
770+ <string name="search_filter_tools">Tool calls</string>
771+ <string name="search_filter_reasoning">Reasoning</string>
772+ <string name="search_match_type_message">message</string>
773+ <string name="search_match_type_tool">tool</string>
774+ <string name="search_match_type_reasoning">reasoning</string>
775+ <string name="server_http">HTTP · not secured</string>
776+ <string name="server_https">HTTPS</string>
777+ <string name="server_https_pinned">HTTPS · cert pinned</string>
743</resources>
778</resources>
app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.kt+22−3
@@ -26,8 +26,8 @@ class UsageAggregationTest {
26 @Test
26 @Test
27 fun aggregatesTotalsAndByModel() {
27 fun aggregatesTotalsAndByModel() {
28 val sessions = listOf(
28 val sessions = listOf(
29− Triple("s1", "One", listOf(assistant("gpt", 0.10, 100, 50), assistant("gpt", 0.20, 200, 100))),
29+ UsageSessionInput("s1", "One", null, listOf(assistant("gpt", 0.10, 100, 50), assistant("gpt", 0.20, 200, 100))),
30− Triple("s2", "Two", listOf(assistant("claude", 0.05, 10, 5), MessageWithParts(UserMessage("u", "s2")))),
30+ UsageSessionInput("s2", "Two", null, listOf(assistant("claude", 0.05, 10, 5), MessageWithParts(UserMessage("u", "s2")))),
31 )
31 )
32 val report = aggregateUsage(sessions)
32 val report = aggregateUsage(sessions)
33 assertEquals(0.35, report.totalCost, 1e-9)
33 assertEquals(0.35, report.totalCost, 1e-9)
@@ -42,7 +42,26 @@ class UsageAggregationTest {
42
42
43 @Test
43 @Test
44 fun ignoresZeroUsageMessages() {
44 fun ignoresZeroUsageMessages() {
45− val sessions = listOf(Triple("s", "t", listOf(assistant("m", 0.0, 0, 0))))
45+ val sessions = listOf(UsageSessionInput("s", "t", null, listOf(assistant("m", 0.0, 0, 0))))
46 assertTrue(aggregateUsage(sessions).isEmpty)
46 assertTrue(aggregateUsage(sessions).isEmpty)
47 }
47 }
48+
49+ @Test
50+ fun timeRangeCutoffExcludesOlderSessions() {
51+ val now = 10_000_000_000L
52+ val dayMs = 24L * 60 * 60 * 1000
53+ val recent = UsageSessionInput("recent", "Recent", now - 1000, listOf(assistant("gpt", 0.10, 100, 50)))
54+ val old = UsageSessionInput("old", "Old", now - 100_000_000, listOf(assistant("gpt", 0.90, 100, 50)))
55+ // A 24h window keeps the recent session only (the old one is ~27h ago).
56+ val cutoff = UsageTimeRange.TWENTY_FOUR_HOURS.cutoffMs(now)
57+ val report = aggregateUsage(listOf(recent, old), cutoff)
58+ assertEquals(0.10, report.totalCost, 1e-9)
59+ assertEquals(1, report.sessionCount)
60+ // All-time includes both.
61+ val all = aggregateUsage(listOf(recent, old), null)
62+ assertEquals(1.00, all.totalCost, 1e-9)
63+ assertEquals(2, all.sessionCount)
64+ // Sanity: the cutoff is exactly one day before now.
65+ assertEquals(now - dayMs, cutoff)
66+ }
48}
67}