git.iko.soy/opencode-android
on master help sign in
Improve chat, navigation, search, files, and usage UI/UX across the app
commit1c81cc87f2a257ab8ef59f3db62d0542b0e520f0 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 10:15 (3 months ago) parent366f92e
24 files changed+727−100
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4−1 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+8−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+44−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+44−5 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+186−44 app/src/main/java/soy/iko/opencode/ui/components/LoadingSize.kt+12 app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.kt+31 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5−2 app/src/main/java/soy/iko/opencode/ui/components/QrCode.kt+4−3 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+1−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+41−11 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+18−3 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+62−11 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+16 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+4−4 app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt+28 app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+4−1 app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+43−3 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+42−3 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+34−2 app/src/main/res/values-es/strings.xml+35 app/src/main/res/values/strings.xml+35 app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.kt+22−3
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4−1
@@ -237,7 +237,7 @@ object NetworkConfig {
237237 /** Minimum number of sessions before the session list shows its search/filter field.
238238 * Matches the sibling screens' convention: a short list is faster to scan by eye than
239239 * type into, so the field earns its vertical space only once the list grows. */
240− const val sessionListSearchThreshold = 6
240+ const val sessionListSearchThreshold = 3
241241 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
242242 * jump-to-latest FAB so it never floats over the last message. */
243243 const val chatListFabInsetDp = 80
@@ -295,6 +295,9 @@ object NetworkConfig {
295295 /** Duration (ms) of the animated double-tap zoom transition in the fullscreen image viewer. */
296296 const val imageViewerZoomAnimMs = 220
297297
298+ /** Vertical drag distance (px) in the fullscreen image viewer that triggers swipe-to-dismiss. */
299+ const val imageViewerSwipeDismissThreshold = 200f
300+
298301 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
299302 const val streamingCaretPeriodMs = 550
300303
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+8−1
@@ -367,7 +367,14 @@ fun OpencodeApp(container: AppContainer) {
367367 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
368368 onBack = { navController.popBackStack() },
369369 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
370− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
370+ onOpenSession = { 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+ },
371378 )
372379 }
373380
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+44−2
@@ -1,11 +1,14 @@
11package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
45import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxSize
57import androidx.compose.foundation.layout.fillMaxWidth
68import androidx.compose.foundation.layout.padding
79import androidx.compose.foundation.layout.size
810import androidx.compose.foundation.layout.widthIn
11+import androidx.compose.foundation.clickable
912import androidx.compose.foundation.horizontalScroll
1013import androidx.compose.foundation.rememberScrollState
1114import androidx.compose.material.icons.Icons
@@ -18,15 +21,22 @@ import androidx.compose.material3.MaterialTheme
1821import androidx.compose.material3.Surface
1922import androidx.compose.material3.Text
2023import 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
2128import androidx.compose.ui.Alignment
2229import androidx.compose.ui.Modifier
2330import androidx.compose.ui.draw.clip
2431import androidx.compose.ui.layout.ContentScale
2532import androidx.compose.ui.res.stringResource
33+import androidx.compose.ui.semantics.Role
2634import androidx.compose.ui.semantics.contentDescription
2735import androidx.compose.ui.semantics.semantics
2836import androidx.compose.ui.text.style.TextOverflow
2937import androidx.compose.ui.unit.dp
38+import androidx.compose.ui.window.Dialog
39+import androidx.compose.ui.window.DialogProperties
3040import coil.compose.AsyncImage
3141import soy.iko.opencode.R
3242
@@ -88,9 +98,13 @@ private fun StagingChip() {
8898}
8999
90100/** 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). */
92104@Composable
93105private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
106+ var showPreview by remember { mutableStateOf(false) }
107+ val previewLabel = stringResource(R.string.preview_attachment)
94108 Surface(
95109 shape = MaterialTheme.shapes.small,
96110 color = MaterialTheme.colorScheme.surfaceVariant,
@@ -102,7 +116,11 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
102116 model = attachment.previewModel,
103117 contentDescription = attachment.name,
104118 contentScale = ContentScale.Crop,
105− modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
119+ modifier = Modifier
120+ .size(36.dp)
121+ .clip(MaterialTheme.shapes.extraSmall)
122+ .semantics { contentDescription = previewLabel }
123+ .clickable(role = Role.Image) { showPreview = true },
106124 )
107125 } else {
108126 Icon(
@@ -130,4 +148,28 @@ private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit)
130148 }
131149 }
132150 }
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+ }
133175}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+44−5
@@ -156,6 +156,9 @@ import soy.iko.opencode.data.model.ToolCompleted
156156import soy.iko.opencode.data.model.ToolError
157157import soy.iko.opencode.data.model.ToolPart
158158import 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
159162import soy.iko.opencode.data.network.EventStreamClient
160163import soy.iko.opencode.data.network.NetworkConfig
161164import soy.iko.opencode.di.AppContainer
@@ -847,7 +850,26 @@ fun ChatScreen(
847850 val searchMessages = remember(messages, searchQuery) {
848851 if (searchQuery.isEmpty()) messages
849852 else messages.filter { m ->
850− m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
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)) ||
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+ }
851873 }
852874 }
853875 // Keep the focused-match index in range as the query (and thus the match set) changes.
@@ -1789,6 +1811,7 @@ private fun ChatInputBar(
17891811) {
17901812 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
17911813 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1814+ val context = LocalContext.current
17921815 // Full-screen editor state: opened when the user wants more space for a long prompt.
17931816 var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
17941817 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
@@ -1863,9 +1886,18 @@ private fun ChatInputBar(
18631886 )
18641887 }
18651888 }
1866− // Voice dictation: appends recognized speech to the draft.
1867− IconButton(onClick = onVoice, enabled = enabled) {
1868− Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
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) {
1898+ IconButton(onClick = onVoice, enabled = enabled) {
1899+ Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1900+ }
18691901 }
18701902 // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
18711903 IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
@@ -2175,7 +2207,14 @@ private fun ComposerTrailingButton(
21752207 enabled = canSend,
21762208 modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
21772209 ) {
2178− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
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,
2214+ contentDescription = stringResource(R.string.send),
2215+ tint = if (canSend) MaterialTheme.colorScheme.primary
2216+ else MaterialTheme.colorScheme.onSurfaceVariant,
2217+ )
21792218 }
21802219 }
21812220}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+186−44
@@ -138,6 +138,10 @@ private fun MessageLongPressMenu(
138138 onBranch: ((String) -> Unit)?,
139139 onRevert: (() -> Unit)?,
140140 onShare: (() -> Unit)?,
141+ onEdit: (() -> Unit)? = null,
142+ onRegenerate: (() -> Unit)? = null,
143+ onSpeak: (() -> Unit)? = null,
144+ speakLabel: String? = null,
141145) {
142146 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
143147 if (text != null && onCopy != null) {
@@ -157,6 +161,33 @@ private fun MessageLongPressMenu(
157161 onClick = { onDismiss(); onShare() },
158162 )
159163 }
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+ }
160191 if (text != null && onQuote != null) {
161192 DropdownMenuItem(
162193 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
@@ -181,6 +212,44 @@ private fun MessageLongPressMenu(
181212 }
182213}
183214
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+
184253/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
185254 * Shown when at least one action is available and there's text to act on. Keeps the
186255 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -313,6 +382,8 @@ private fun UserBubble(
313382 val density = androidx.compose.ui.platform.LocalDensity.current
314383 val copyLabel = stringResource(R.string.copy)
315384 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 }
316387 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
317388 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
318389 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -341,6 +412,22 @@ private fun UserBubble(
341412 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
342413 .clip(MaterialTheme.shapes.medium)
343414 .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+ }
344431 .padding(12.dp),
345432 verticalArrangement = Arrangement.spacedBy(6.dp),
346433 ) {
@@ -348,26 +435,10 @@ private fun UserBubble(
348435 message.parts.forEachIndexed { index, part ->
349436 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
350437 }
351− // Footer row with timestamp + inline actions. Long-press opens a context menu
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.
438+ // Footer row with timestamp + inline actions.
357439 Box {
358440 Row(
359− modifier = Modifier
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− },
441+ modifier = Modifier.fillMaxWidth(),
371442 horizontalArrangement = Arrangement.SpaceBetween,
372443 verticalAlignment = Alignment.CenterVertically,
373444 ) {
@@ -460,8 +531,6 @@ private fun UserBubble(
460531 // conversation to before it so a Send replaces it. Only when there's text
461532 // to edit (an image-only prompt has nothing to reload).
462533 if (onEdit != null && textToCopy != null) {
463− val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
464− val hasAttachments = message.parts.any { it is FilePart }
465534 IconButton(onClick = {
466535 onEdit(textToCopy)
467536 // Editing reloads only the text; if this prompt carried images or
@@ -503,7 +572,7 @@ private fun UserBubble(
503572 }
504573 }
505574 }
506− MessageLongPressMenu(
575+ UserMessageLongPressMenu(
507576 expanded = longPressMenu,
508577 onDismiss = { longPressMenu = false },
509578 offset = longPressOffset,
@@ -513,6 +582,10 @@ private fun UserBubble(
513582 onBranch = onBranch,
514583 onRevert = onRevert,
515584 onShare = onShare,
585+ onEdit = onEdit,
586+ hasAttachments = hasAttachments,
587+ dropsAttachmentsMsg = dropsAttachmentsMsg,
588+ context = context,
516589 )
517590 }
518591 }
@@ -540,8 +613,26 @@ private fun AssistantBlock(
540613 isEdited: Boolean = false,
541614 onShare: (() -> Unit)? = null,
542615) {
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)) }
543623 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+ },
545636 verticalArrangement = Arrangement.spacedBy(8.dp),
546637 ) {
547638 val info = message.info
@@ -593,9 +684,8 @@ private fun AssistantBlock(
593684 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
594685 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
595686 }
596− // Long-press context menu state (see UserBubble for rationale).
597− var longPressMenu by remember { mutableStateOf(false) }
598− var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
687+ // Long-press context menu state is hoisted to the block scope (see the Column
688+ // above) so the whole bubble is the long-press target.
599689 // Collect text from all TextParts for copy/read-aloud. Memoized so a
600690 // scroll-induced recomposition doesn't re-scan the parts list.
601691 val textToCopy = remember(message.parts) {
@@ -604,9 +694,6 @@ private fun AssistantBlock(
604694 .joinToString("\n\n") { it.text }
605695 .takeIf { it.isNotBlank() }
606696 }
607− val assistantHaptics = LocalHapticFeedback.current
608− val assistantContext = LocalContext.current
609− val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
610697 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
611698 {
612699 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -615,18 +702,7 @@ private fun AssistantBlock(
615702 }
616703 Box {
617704 Row(
618− modifier = Modifier
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− },
705+ modifier = Modifier.fillMaxWidth(),
630706 horizontalArrangement = Arrangement.SpaceBetween,
631707 verticalAlignment = Alignment.CenterVertically,
632708 ) {
@@ -636,9 +712,10 @@ private fun AssistantBlock(
636712 it,
637713 style = MaterialTheme.typography.labelSmall,
638714 color = MaterialTheme.colorScheme.onSurfaceVariant,
639− // Cap to a single line so a long "1.2k in · 540 out • $0.012"
640− // summary doesn't wrap the "•" awkwardly at narrow widths.
641− maxLines = 1,
715+ // Allow up to 2 lines so a multi-segment summary (in/out • reasoning
716+ // • cache read • cost) wraps instead of ellipsizing away the cost —
717+ // the part users most want — on narrow screens.
718+ maxLines = 2,
642719 overflow = TextOverflow.Ellipsis,
643720 )
644721 }
@@ -664,7 +741,7 @@ private fun AssistantBlock(
664741 onRegenerate = onRegenerate,
665742 )
666743 }
667− MessageLongPressMenu(
744+ AssistantMessageLongPressMenu(
668745 expanded = longPressMenu,
669746 onDismiss = { longPressMenu = false },
670747 offset = longPressOffset,
@@ -674,6 +751,12 @@ private fun AssistantBlock(
674751 onBranch = onBranch,
675752 onRevert = onRevert,
676753 onShare = onShare,
754+ onRegenerate = onRegenerate,
755+ onSpeak = onSpeak,
756+ isSpeaking = isSpeaking,
757+ ttsState = ttsState,
758+ onPause = onPause,
759+ onResume = onResume,
677760 )
678761 }
679762 } else {
@@ -682,6 +765,65 @@ private fun AssistantBlock(
682765 }
683766}
684767
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+
685827/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
686828 * that function's cyclomatic complexity under the detekt threshold. */
687829@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)
548548 // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
549549 val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
550550 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)
552555 Text(
553− alt.ifBlank { "[$alt]" }.ifBlank { "[image]" },
556+ placeholder,
554557 style = MaterialTheme.typography.bodyMedium,
555558 color = MaterialTheme.colorScheme.onSurfaceVariant,
556559 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
124124 * background (QR scanners need the light module) with a small inner pad so the code isn't flush
125125 * with the dialog edges. */
126126@Composable
127−fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier) {
127+fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier, label: String? = null) {
128128 val bitmap = remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
129129 if (bitmap != null) {
130+ val description = label?.let { stringResource(R.string.qr_image_for, it) }
130131 Image(
131132 bitmap = bitmap.asImageBitmap(),
132− contentDescription = null,
133+ contentDescription = description,
133134 modifier = modifier
134135 .size(sizePx.dp)
135136 .background(Color.White)
@@ -153,7 +154,7 @@ fun QrShareDialog(profile: ServerProfile, onDismiss: () -> Unit) {
153154 horizontalAlignment = Alignment.CenterHorizontally,
154155 modifier = Modifier.fillMaxWidth(),
155156 ) {
156− QrImage(content = payload, sizePx = 256)
157+ QrImage(content = payload, sizePx = 256, label = profile.displayLabel)
157158 Spacer(Modifier.size(8.dp))
158159 Text(
159160 profile.baseUrl,
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+1−1
@@ -266,7 +266,7 @@ private fun FullscreenImageViewer(
266266 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
267267 // a continuous motion rather than a snap.
268268 var dragOffsetY by remember { mutableFloatStateOf(0f) }
269− val dismissThreshold = 200f
269+ val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
270270 Dialog(
271271 onDismissRequest = onDismiss,
272272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4
@@ -114,6 +114,10 @@ fun FileBrowserScreen(
114114 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
115115 val snackbar = remember { SnackbarHostState() }
116116
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+
117121 LaunchedEffect(Unit) {
118122 vm.transientErrors.collect { msg ->
119123 snackbar.showSnackbar(msg)
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+41−11
@@ -158,10 +158,14 @@ fun FileViewScreen(
158158 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
159159 // backed by the full list, which would keep every line (and the full rawText) in memory.
160160 val allLines = remember(rawText) { rawText.split("\n") }
161− val truncated = allLines.size > MAX_RENDERED_LINES
162− val lines = remember(rawText) {
163− if (allLines.size > MAX_RENDERED_LINES) allLines.take(MAX_RENDERED_LINES) else allLines
164− }
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) }
166+ val lines = remember(rawText, renderLimit) { allLines.take(renderLimit) }
167+ val truncated = allLines.size > lines.size
168+ val moreLines = (allLines.size - lines.size).coerceAtMost(MAX_RENDERED_LINES)
165169 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
166170 // or starts an in-file find so the landed line stands out. Null once cleared.
167171 val highlightLine = rememberJumpToLineHighlight(
@@ -320,6 +324,8 @@ fun FileViewScreen(
320324 wrap = wrap,
321325 lines = lines,
322326 truncated = truncated,
327+ moreLines = moreLines,
328+ onLoadMore = { renderLimit += MAX_RENDERED_LINES },
323329 highlightLineIndex = highlightLine.value,
324330 listState = listState,
325331 onRetry = { vm.reload() },
@@ -332,7 +338,19 @@ fun FileViewScreen(
332338 putExtra(Intent.EXTRA_TEXT, rawText)
333339 }
334340 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
335− .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
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+ }
352+ scope.launch { snackbar.showSnackbar(msg) }
353+ }
336354 },
337355 )
338356 }
@@ -366,7 +384,7 @@ private fun GoToLineLauncher(
366384private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
367385 var text by rememberSaveable { mutableStateOf("") }
368386 val parsed = text.trim().toIntOrNull()
369− val valid = parsed != null && parsed >= 1
387+ val valid = parsed != null && parsed in 1..maxLine
370388 AlertDialog(
371389 onDismissRequest = onDismiss,
372390 title = { Text(stringResource(R.string.go_to_line)) },
@@ -386,7 +404,7 @@ private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: ()
386404 keyboardType = KeyboardType.Number,
387405 imeAction = ImeAction.Done,
388406 ),
389− keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
407+ keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
390408 )
391409 },
392410 confirmButton = {
@@ -568,6 +586,8 @@ private fun BoxScope.FileViewStateContent(
568586 wrap: Boolean,
569587 lines: List<String>,
570588 truncated: Boolean,
589+ moreLines: Int,
590+ onLoadMore: () -> Unit,
571591 highlightLineIndex: Int?,
572592 listState: LazyListState,
573593 onRetry: () -> Unit,
@@ -614,6 +634,8 @@ private fun BoxScope.FileViewStateContent(
614634 wrap = wrap,
615635 lines = lines,
616636 truncated = truncated,
637+ moreLines = moreLines,
638+ onLoadMore = onLoadMore,
617639 highlightLineIndex = highlightLineIndex,
618640 listState = listState,
619641 onShareFull = onShareFull,
@@ -641,6 +663,8 @@ private fun BoxScope.FileViewContentBody(
641663 wrap: Boolean,
642664 lines: List<String>,
643665 truncated: Boolean,
666+ moreLines: Int,
667+ onLoadMore: () -> Unit,
644668 highlightLineIndex: Int?,
645669 listState: LazyListState,
646670 onShareFull: () -> Unit,
@@ -733,6 +757,8 @@ private fun BoxScope.FileViewContentBody(
733757 filename = filename,
734758 topInset = topInset,
735759 truncated = truncated,
760+ moreLines = moreLines,
761+ onLoadMore = onLoadMore,
736762 wrap = wrap,
737763 findQuery = findQuery,
738764 matchIndices = matchIndices,
@@ -819,6 +845,8 @@ private fun FileTextContent(
819845 filename: String,
820846 topInset: androidx.compose.ui.unit.Dp,
821847 truncated: Boolean,
848+ moreLines: Int,
849+ onLoadMore: () -> Unit,
822850 wrap: Boolean,
823851 findQuery: String,
824852 matchIndices: List<Int>,
@@ -859,14 +887,16 @@ private fun FileTextContent(
859887 horizontalArrangement = Arrangement.SpaceBetween,
860888 ) {
861889 Text(
862− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
890+ stringResource(R.string.file_truncated, lines.size),
863891 style = MaterialTheme.typography.labelSmall,
864892 color = MaterialTheme.colorScheme.onSurfaceVariant,
865893 modifier = Modifier.weight(1f),
866894 )
867− // Escape hatch: when only the first N lines render, offer the (already
868− // full-content) Share action inline so the user isn't stranded without the
869− // rest of the file. The overflow Share shares the same full content.
895+ // Load the next chunk in-place so the user can keep reading the file without
896+ // leaving the app. Falls back to Share full file for the whole content.
897+ TextButton(onClick = onLoadMore) {
898+ Text(stringResource(R.string.load_more_lines, moreLines))
899+ }
870900 TextButton(onClick = onShareFull) {
871901 Text(stringResource(R.string.share_full_file))
872902 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+18−3
@@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
44import androidx.compose.foundation.layout.Arrangement
55import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Spacer
78import androidx.compose.foundation.layout.PaddingValues
89import androidx.compose.foundation.layout.Row
910import androidx.compose.foundation.layout.fillMaxSize
@@ -109,9 +110,23 @@ fun GlobalSearchScreen(
109110 ConnectionBannerFor(container)
110111 val searchingLabel = stringResource(R.string.searching)
111112 when {
112− state.searching -> CircularProgressIndicator(
113− Modifier.align(Alignment.Center).semantics { contentDescription = searchingLabel },
114− )
113+ state.searching -> Column(
114+ modifier = Modifier.align(Alignment.Center),
115+ horizontalAlignment = Alignment.CenterHorizontally,
116+ ) {
117+ CircularProgressIndicator(
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,
127+ )
128+ }
129+ }
115130 state.error != null -> EmptyState(
116131 icon = Icons.Filled.ErrorOutline,
117132 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
55import androidx.lifecycle.ViewModel
66import androidx.lifecycle.viewModelScope
77import soy.iko.opencode.R
8+import soy.iko.opencode.data.model.FilePart
89import soy.iko.opencode.data.model.MessageWithParts
10+import soy.iko.opencode.data.model.ReasoningPart
911import soy.iko.opencode.data.model.Session
1012import 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
1119import soy.iko.opencode.data.network.NetworkConfig
1220import soy.iko.opencode.di.AppContainer
1321import soy.iko.opencode.util.runCatchingCancellable
@@ -47,6 +55,12 @@ data class GlobalSearchState(
4755 /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
4856 * search only covered the most recent ones. */
4957 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,
5064)
5165
5266/**
@@ -113,8 +127,10 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
113127 }.also { sessionsCache = it }
114128 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
115129 val truncated = sessions.size > toSearch.size
130+ _state.update { it.copy(totalCount = toSearch.size, searchedCount = 0) }
116131 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
117132 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
133+ val searched = java.util.concurrent.atomic.AtomicInteger(0)
118134 coroutineScope {
119135 toSearch.forEach { session ->
120136 launch {
@@ -141,6 +157,8 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
141157 matchedTitle = titleMatched,
142158 )
143159 }
160+ // Publish progress so the spinner can show "Searched N / M sessions".
161+ _state.update { it.copy(searchedCount = searched.incrementAndGet()) }
144162 }
145163 }
146164 }
@@ -156,22 +174,56 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
156174 }
157175 }
158176
159− /** Find the first text part containing [query] across [messages] and return a short
160− * snippet centered on the match, or null if nothing matches. */
177+ /** Find the first searchable text containing [query] across [messages] and return a short
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. */
161181 private fun matchSnippet(
162182 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
163183 query: String,
164184 ): String? {
165185 for (message in messages) {
166− for (part in message.parts) {
167− if (part !is TextPart) continue
168− val idx = part.text.indexOf(query, ignoreCase = true)
169− if (idx >= 0) return buildSnippet(part.text, idx, query.length)
186+ for (candidate in searchableTexts(message.parts)) {
187+ val idx = candidate.indexOf(query, ignoreCase = true)
188+ if (idx >= 0) return buildSnippet(candidate, idx, query.length)
170189 }
171190 }
172191 return null
173192 }
174193
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+
175227 private fun buildSnippet(text: String, matchStart: Int, matchLength: Int): String {
176228 val window = NetworkConfig.searchSnippetLength
177229 val start = (matchStart - window / 2).coerceAtLeast(0)
@@ -181,17 +233,16 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
181233 return prefix + text.substring(start, end).trim() + suffix
182234 }
183235
184− /** Count all occurrences of [query] across the text parts of [messages] (case-insensitive,
185− * non-overlapping) so a result card can show "N matches" instead of just one snippet. */
236+ /** Count all occurrences of [query] across the searchable text of [messages] (text,
237+ * reasoning, tool calls, file names) so a result card can show "N matches". */
186238 private fun countMatches(
187239 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
188240 query: String,
189241 ): Int {
190242 var count = 0
191243 for (message in messages) {
192− for (part in message.parts) {
193− if (part !is TextPart) continue
194− count += countIn(part.text, query)
244+ for (text in searchableTexts(message.parts)) {
245+ count += countIn(text, query)
195246 }
196247 }
197248 return count
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+16
@@ -463,6 +463,17 @@ private fun ServerCardContent(
463463 maxLines = 1,
464464 overflow = TextOverflow.Ellipsis,
465465 )
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+ }
466477 Text(
467478 profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
468479 style = MaterialTheme.typography.bodySmall,
@@ -470,6 +481,11 @@ private fun ServerCardContent(
470481 maxLines = 1,
471482 overflow = TextOverflow.Ellipsis,
472483 )
484+ Text(
485+ schemeLabel,
486+ style = MaterialTheme.typography.labelSmall,
487+ color = MaterialTheme.colorScheme.onSurfaceVariant,
488+ )
473489 if (isActive) {
474490 // Reflect the live SSE state instead of a static "Connected": a dropped or failed
475491 // 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(
618618 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
619619 }
620620 // 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
622− // shows no options (a no-op), which is acceptable for these optional links.
621+ // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
622+ // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
623623 NavRow(
624624 icon = Icons.Filled.Code,
625625 label = stringResource(R.string.source_code),
@@ -629,7 +629,7 @@ fun SettingsScreen(
629629 context.startActivity(
630630 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
631631 )
632− }
632+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
633633 },
634634 )
635635 NavRow(
@@ -641,7 +641,7 @@ fun SettingsScreen(
641641 context.startActivity(
642642 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
643643 )
644− }
644+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
645645 },
646646 )
647647 }
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(
126126 labelSmall = TextStyle(
127127 fontFamily = FontFamily.Default,
128128 fontWeight = FontWeight.Medium,
129− fontSize = 11.sp,
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.
132+ fontSize = 12.sp,
130133 lineHeight = 16.sp,
131134 letterSpacing = 0.4.sp,
132135 ),
app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+43−3
@@ -25,6 +25,33 @@ data class SessionUsage(
2525 val tokens: Tokens,
2626)
2727
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+
2855/** A full usage report: overall totals plus per-model and per-session breakdowns. */
2956@Immutable
3057data class UsageReport(
@@ -56,19 +83,32 @@ private class MutableAgg(val provider: String, val model: String) {
5683}
5784
5885/**
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+ *
6091 * Only [AssistantMessage]s that reported cost or tokens contribute — user prompts and
6192 * zero-usage messages are ignored so the counts reflect real model spend. Pure and
6293 * synchronous so it's unit-testable and can run off the main thread.
6394 */
64−fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>>): UsageReport {
95+fun aggregateUsage(
96+ sessions: List<UsageSessionInput>,
97+ cutoffMs: Long? = null,
98+): UsageReport {
6599 var totalCost = 0.0
66100 var totalTokens = Tokens()
67101 var messageCount = 0
68102 val byModel = LinkedHashMap<String, MutableAgg>()
69103 val bySession = ArrayList<SessionUsage>()
70104
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
72112 var sessionCost = 0.0
73113 var sessionTokens = Tokens()
74114 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
55import androidx.compose.foundation.layout.Arrangement
66import androidx.compose.foundation.layout.Box
77import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.ExperimentalLayoutApi
9+import androidx.compose.foundation.layout.FlowRow
810import androidx.compose.foundation.layout.PaddingValues
911import androidx.compose.foundation.layout.Row
1012import androidx.compose.foundation.layout.Spacer
@@ -27,6 +29,7 @@ import androidx.compose.material.icons.filled.SearchOff
2729import androidx.compose.material3.Card
2830import androidx.compose.material3.CircularProgressIndicator
2931import androidx.compose.material3.ExperimentalMaterial3Api
32+import androidx.compose.material3.FilterChip
3033import androidx.compose.material3.Icon
3134import androidx.compose.material3.IconButton
3235import androidx.compose.material3.MaterialTheme
@@ -68,7 +71,7 @@ import soy.iko.opencode.ui.vmFactory
6871import java.text.NumberFormat
6972import java.util.Locale
7073
71−@OptIn(ExperimentalMaterial3Api::class)
74+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
7275@Composable
7376fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
7477 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
@@ -128,7 +131,13 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
128131 modifier = Modifier.align(Alignment.Center),
129132 )
130133 } else {
131− UsageContent(s.report, onOpenSession)
134+ val timeRange by vm.timeRange.collectAsStateWithLifecycle()
135+ UsageContent(
136+ report = s.report,
137+ onOpenSession = onOpenSession,
138+ timeRange = timeRange,
139+ onTimeRangeChange = vm::setTimeRange,
140+ )
132141 }
133142 }
134143 }
@@ -143,8 +152,14 @@ private fun Centered(content: @Composable androidx.compose.foundation.layout.Col
143152 }
144153}
145154
155+@OptIn(ExperimentalLayoutApi::class)
146156@Composable
147−private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
157+private fun UsageContent(
158+ report: UsageReport,
159+ onOpenSession: (String) -> Unit,
160+ timeRange: UsageTimeRange,
161+ onTimeRangeChange: (UsageTimeRange) -> Unit,
162+) {
148163 // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
149164 // the whole history, but a user with many sessions can narrow the per-session list to find
150165 // a specific conversation's spend without scrolling through all of it.
@@ -154,11 +169,35 @@ private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
154169 if (q.isEmpty()) report.bySession
155170 else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
156171 }
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+ }
157180 LazyColumn(
158181 modifier = Modifier.fillMaxSize(),
159182 contentPadding = PaddingValues(16.dp),
160183 verticalArrangement = Arrangement.spacedBy(12.dp),
161184 ) {
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+ }
162201 item { TotalsCard(report) }
163202 item { TokenBreakdownCard(report.totalTokens) }
164203 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() {
4949 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
5050 private val _reload = MutableStateFlow(0)
5151
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+
5261 init {
5362 // Observe the active connection so the report loads (or reloads) once a connection is
5463 // available — including when the screen opens during a reconnect window where
@@ -58,10 +67,26 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
5867 viewModelScope.launch {
5968 merge(container.activeConnection, _reload).collectLatest { doLoad() }
6069 }
70+ // Re-aggregate from the cache when only the time range changes (no network round-trip).
71+ viewModelScope.launch {
72+ _timeRange.collectLatest { reaggregate() }
73+ }
6174 }
6275
6376 fun load() { _reload.value++ }
6477
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+
6590 private suspend fun doLoad() {
6691 val conn = container.activeConnection.value ?: run {
6792 _state.value = State.Disconnected
@@ -85,12 +110,19 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
85110 limiter.withPermit {
86111 val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
87112 .getOrDefault(emptyList())
88− Triple(session.id, session.displayTitle, messages)
113+ UsageSessionInput(
114+ sessionId = session.id,
115+ title = session.displayTitle,
116+ modifiedAt = session.time?.updated ?: session.time?.created,
117+ messages = messages,
118+ )
89119 }
90120 }
91121 }.awaitAll()
92122 }
93− withContext(Dispatchers.Default) { aggregateUsage(perSession) }
123+ cachedInputs = perSession
124+ val cutoff = _timeRange.value.cutoffMs()
125+ withContext(Dispatchers.Default) { aggregateUsage(perSession, cutoff) }
94126 }
95127 .onSuccess { _state.value = State.Ready(it) }
96128 .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
app/src/main/res/values-es/strings.xml+35
@@ -740,4 +740,39 @@
740740 <string name="diff_context_line">Línea %1$d: %2$s</string>
741741 <string name="tts_stopped_unexpectedly">La lectura en voz alta se detuvo inesperadamente</string>
742742 <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>
743778</resources>
app/src/main/res/values/strings.xml+35
@@ -740,4 +740,39 @@
740740 <string name="diff_removed_line">Line %1$d removed: %2$s</string>
741741 <string name="diff_context_line">Line %1$d: %2$s</string>
742742 <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>
743778</resources>
app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.kt+22−3
@@ -26,8 +26,8 @@ class UsageAggregationTest {
2626 @Test
2727 fun aggregatesTotalsAndByModel() {
2828 val sessions = listOf(
29− Triple("s1", "One", 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")))),
29+ UsageSessionInput("s1", "One", null, listOf(assistant("gpt", 0.10, 100, 50), assistant("gpt", 0.20, 200, 100))),
30+ UsageSessionInput("s2", "Two", null, listOf(assistant("claude", 0.05, 10, 5), MessageWithParts(UserMessage("u", "s2")))),
3131 )
3232 val report = aggregateUsage(sessions)
3333 assertEquals(0.35, report.totalCost, 1e-9)
@@ -42,7 +42,26 @@ class UsageAggregationTest {
4242
4343 @Test
4444 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))))
4646 assertTrue(aggregateUsage(sessions).isEmpty)
4747 }
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+ }
4867}