@@ -133,6 +133,7 @@ import androidx.compose.ui.platform.testTag
133133import androidx.compose.ui.res.stringResource
134134import androidx.compose.ui.semantics.Role
135135import androidx.compose.ui.semantics.contentDescription
136+import androidx.compose.ui.semantics.heading
136137import androidx.compose.ui.semantics.semantics
137138import androidx.compose.ui.text.input.ImeAction
138139import androidx.compose.ui.text.style.TextOverflow
@@ -268,6 +269,11 @@ fun ChatScreen(
268269 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269270 var searchActive by rememberSaveable { mutableStateOf(false) }
270271 var chatSearch by rememberSaveable { mutableStateOf("") }
272+ // Index of the currently-focused in-conversation search match (into the filtered list).
273+ // Drives the up/down navigation: the search bar's arrows step through matches and scroll
274+ // the list to each one, mirroring desktop chat apps (the bare filtered list previously
275+ // required reading each message to find the term).
276+ var searchPos by rememberSaveable { mutableIntStateOf(0) }
271277 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
272278 // a confirmation dialog (from both the overflow menu and the command palette).
273279 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -490,7 +496,11 @@ fun ChatScreen(
490496 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
491497 when {
492498 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
499+ // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
500+ // the field so the user can type immediately.
501+ ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
493502 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
503+ ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
494504 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
495505 showStopConfirm = true; true
496506 }
@@ -824,6 +834,11 @@ fun ChatScreen(
824834 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
825835 }
826836 }
837+ // Keep the focused-match index in range as the query (and thus the match set) changes.
838+ // A new query resets to the first match; a shrunk set clamps to the last valid index.
839+ LaunchedEffect(searchQuery, searchMessages.size) {
840+ if (searchPos >= searchMessages.size) searchPos = 0
841+ }
827842 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
828843 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
829844 }
@@ -887,6 +902,18 @@ fun ChatScreen(
887902 }
888903 }
889904
905+ // Scroll the focused search match into view whenever the user steps through results with
906+ // the up/down arrows. Resolves the filtered message to its slot in listItems (which
907+ // includes date separators) and animates there.
908+ LaunchedEffect(searchPos, searchQuery, listItems) {
909+ if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
910+ val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
911+ val index = listItems.indexOfFirst {
912+ it is MessageListItem.Message && it.message.info.id == target.info.id
913+ }
914+ if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
915+ }
916+
890917 LaunchedEffect(Unit) {
891918 var prevSize = 0
892919 var prevLen = 0
@@ -1114,11 +1141,33 @@ fun ChatScreen(
11141141 key = { it.key },
11151142 contentType = { it.contentType },
11161143 ) { item ->
1144+ // When in-conversation search is stepping through matches, highlight the
1145+ // focused one's row so the user can spot it at a glance among the filtered
1146+ // results (full in-text term highlighting would require threading the query
1147+ // through the markdown renderer; the row highlight + scroll-to-match covers
1148+ // the navigation use case desktop chat apps solve with next/prev).
1149+ val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1150+ searchMessages.getOrNull(searchPos)?.info?.id
1151+ } else null
1152+ val isFocusedMatch = focusedMatchId != null &&
1153+ item is MessageListItem.Message &&
1154+ item.message.info.id == focusedMatchId
11171155 // Default placement animation so inserted/moved rows glide in. Skipped
11181156 // entirely under reduced motion so a streaming message growing in place
11191157 // doesn't animate on every token (and respects the a11y preference).
11201158 Box(
1121− if (LocalReducedMotion.current) Modifier else Modifier.animateItem(),
1159+ Modifier
1160+ .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1161+ .then(
1162+ if (isFocusedMatch) {
1163+ Modifier.background(
1164+ MaterialTheme.colorScheme.secondaryContainer,
1165+ MaterialTheme.shapes.medium,
1166+ )
1167+ } else {
1168+ Modifier
1169+ },
1170+ ),
11221171 ) {
11231172 when (item) {
11241173 is MessageListItem.Separator -> DateSeparator(item.label)
@@ -1256,30 +1305,48 @@ fun ChatScreen(
12561305 }
12571306 val elapsedText = remember(elapsedMs) {
12581307 val total = elapsedMs / 1000
1259− "%d:%02d".format(total / 60, total % 60)
1308+ // Locale.US for stable digit formatting: on locales with
1309+ // non-ASCII digits (ar-/fa-) the default locale would render
1310+ // localized digits inside the Monospace block, breaking the
1311+ // fixed-width alignment the monospace family was chosen for.
1312+ String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
12601313 }
12611314 Row(
12621315 verticalAlignment = Alignment.CenterVertically,
1263− modifier = Modifier.semantics {
1264− contentDescription = workingText
1265− },
1316+ // Animate this slot in/out so the working row doesn't pop in and
1317+ // jolt the message list when a run starts/stops — matching the
1318+ // AnimatedVisibility used by every other transient banner/FAB here.
1319+ modifier = Modifier
1320+ .animateItem()
1321+ .padding(vertical = 4.dp),
12661322 ) {
1267− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1268− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1269− Spacer(Modifier.size(8.dp))
1270− Icon(
1271− Icons.Filled.Schedule,
1272− contentDescription = null,
1273− modifier = Modifier.size(14.dp),
1274− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1275− )
1276− Text(
1277− elapsedText,
1278− style = MaterialTheme.typography.labelMedium,
1279− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1280− color = MaterialTheme.colorScheme.onSurfaceVariant,
1281− modifier = Modifier.padding(start = 4.dp),
1282− )
1323+ // Merge the indicator + label + clock into one TalkBack node so
1324+ // it's announced once (the working label, then the elapsed time)
1325+ // instead of "working… / working… / 0:14" with the label read
1326+ // twice. The Stop IconButton below stays separately focusable.
1327+ Row(
1328+ verticalAlignment = Alignment.CenterVertically,
1329+ modifier = Modifier.semantics(mergeDescendants = true) {
1330+ contentDescription = "$workingText $elapsedText"
1331+ },
1332+ ) {
1333+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1334+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1335+ Spacer(Modifier.size(8.dp))
1336+ Icon(
1337+ Icons.Filled.Schedule,
1338+ contentDescription = null,
1339+ modifier = Modifier.size(14.dp),
1340+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1341+ )
1342+ Text(
1343+ elapsedText,
1344+ style = MaterialTheme.typography.labelMedium,
1345+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1346+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1347+ modifier = Modifier.padding(start = 4.dp),
1348+ )
1349+ }
12831350 // A trailing Stop on the working row so the run can be aborted
12841351 // without scrolling back to the composer (which may be off-screen
12851352 // while the user reads the streaming output up-thread).
@@ -1387,14 +1454,44 @@ fun ChatScreen(
13871454 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
13881455 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
13891456 )
1457+ // Match counter: "N of M" where N is the focused match (1-based) and
1458+ // M is the total matching messages. Only shown when there's a query.
1459+ val matchCount = searchMessages.size
13901460 Text(
1391− if (searchQuery.isNotEmpty()) {
1392− stringResource(R.string.search_match_count, searchMessages.size, messages.size)
1461+ if (searchQuery.isNotEmpty() && matchCount > 0) {
1462+ stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1463+ } else if (searchQuery.isNotEmpty()) {
1464+ stringResource(R.string.search_match_count, 0, messages.size)
13931465 } else "",
13941466 style = MaterialTheme.typography.labelSmall,
13951467 color = MaterialTheme.colorScheme.onSurfaceVariant,
13961468 modifier = Modifier.padding(horizontal = 4.dp),
13971469 )
1470+ // Step through matches (Previous / Next), disabled when there are
1471+ // fewer than two to navigate. Wrap around at the ends so repeated
1472+ // taps cycle through every hit.
1473+ val prevLabel = stringResource(R.string.find_previous)
1474+ val nextLabel = stringResource(R.string.find_next)
1475+ IconButton(
1476+ onClick = {
1477+ if (matchCount > 0) {
1478+ searchPos = (searchPos - 1 + matchCount) % matchCount
1479+ }
1480+ },
1481+ enabled = matchCount > 1,
1482+ ) {
1483+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1484+ }
1485+ IconButton(
1486+ onClick = {
1487+ if (matchCount > 0) {
1488+ searchPos = (searchPos + 1) % matchCount
1489+ }
1490+ },
1491+ enabled = matchCount > 1,
1492+ ) {
1493+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1494+ }
13981495 IconButton(onClick = {
13991496 searchActive = false
14001497 chatSearch = ""
@@ -1845,6 +1942,7 @@ private fun ChatInputBar(
18451942 },
18461943 canSend = enabled && hasContent,
18471944 running = running,
1945+ sendOnEnter = sendOnEnter,
18481946 attachments = attachments,
18491947 onRemoveAttachment = onRemoveAttachment,
18501948 staging = staging,
@@ -1864,6 +1962,7 @@ private fun FullScreenEditor(
18641962 onSend: () -> Unit,
18651963 canSend: Boolean,
18661964 running: Boolean = false,
1965+ sendOnEnter: Boolean = false,
18671966 attachments: List<PendingAttachment> = emptyList(),
18681967 onRemoveAttachment: (String) -> Unit = {},
18691968 staging: Boolean = false,
@@ -1904,10 +2003,35 @@ private fun FullScreenEditor(
19042003 OutlinedTextField(
19052004 value = value,
19062005 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1907− modifier = Modifier.fillMaxSize().weight(1f),
2006+ modifier = Modifier
2007+ .fillMaxSize()
2008+ .weight(1f)
2009+ // Honor the "Send on Enter" setting here too, so the full-screen
2010+ // editor matches the inline composer: Enter sends (Shift+Enter
2011+ // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2012+ // when off. Previously this hard-coded ImeAction.Default and had no
2013+ // key handler, so a hardware-keyboard user got a different behaviour
2014+ // from the inline composer depending on which they happened to open.
2015+ .onPreviewKeyEvent { event ->
2016+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2017+ val send = enterShouldSend(
2018+ enabled = canSend,
2019+ hasContent = canSend,
2020+ sendOnEnter = sendOnEnter,
2021+ shift = event.isShiftPressed,
2022+ ctrl = event.isCtrlPressed,
2023+ )
2024+ if (send) { onSend(); true } else false
2025+ },
19082026 placeholder = { Text(stringResource(R.string.message_placeholder)) },
19092027 maxLines = Int.MAX_VALUE,
1910− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
2028+ keyboardOptions = KeyboardOptions(
2029+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2030+ capitalization = KeyboardCapitalization.Sentences,
2031+ ),
2032+ keyboardActions = KeyboardActions(
2033+ onSend = { if (canSend) onSend() },
2034+ ),
19112035 )
19122036 }
19132037 }
@@ -2054,6 +2178,7 @@ private fun EmptyConversation(
20542178/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
20552179@Composable
20562180private fun RevertBanner(onUndo: () -> Unit) {
2181+ val haptics = LocalHapticFeedback.current
20572182 Surface(
20582183 color = MaterialTheme.colorScheme.tertiaryContainer,
20592184 tonalElevation = 2.dp,
@@ -2078,7 +2203,12 @@ private fun RevertBanner(onUndo: () -> Unit) {
20782203 modifier = Modifier.padding(start = 8.dp),
20792204 )
20802205 }
2081− TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
2206+ TextButton(onClick = {
2207+ // Haptic on undo to match the other destructive/important confirmations: a
2208+ // revert restores potentially hundreds of hidden messages.
2209+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2210+ onUndo()
2211+ }) { Text(stringResource(R.string.undo)) }
20822212 }
20832213 }
20842214}
@@ -2123,8 +2253,14 @@ private fun OutboxBanner(
21232253 )
21242254 }
21252255 if (!sending) {
2256+ val haptics = LocalHapticFeedback.current
21262257 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2127− TextButton(onClick = onDiscard) {
2258+ TextButton(onClick = {
2259+ // Haptic on the irreversible "Discard queued" (drops every queued message
2260+ // for this session), matching the error-colored destructive actions.
2261+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2262+ onDiscard()
2263+ }) {
21282264 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
21292265 }
21302266 }
@@ -2377,6 +2513,9 @@ private fun DateSeparator(label: String) {
23772513 Surface(
23782514 color = MaterialTheme.colorScheme.surfaceVariant,
23792515 shape = MaterialTheme.shapes.small,
2516+ // Marked as a heading so TalkBack users can navigate day-by-day with the
2517+ // heading-skip gesture — the canonical affordance for date dividers in a list.
2518+ modifier = Modifier.semantics(mergeDescendants = true) { heading() },
23802519 ) {
23812520 Text(
23822521 label,