@@ -133,6 +133,7 @@ import androidx.compose.ui.platform.testTag
133import androidx.compose.ui.res.stringResource
133import androidx.compose.ui.res.stringResource
134import androidx.compose.ui.semantics.Role
134import androidx.compose.ui.semantics.Role
135import androidx.compose.ui.semantics.contentDescription
135import androidx.compose.ui.semantics.contentDescription
136+import androidx.compose.ui.semantics.heading
136import androidx.compose.ui.semantics.semantics
137import androidx.compose.ui.semantics.semantics
137import androidx.compose.ui.text.input.ImeAction
138import androidx.compose.ui.text.input.ImeAction
138import androidx.compose.ui.text.style.TextOverflow
139import androidx.compose.ui.text.style.TextOverflow
@@ -268,6 +269,11 @@ fun ChatScreen(
268 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269 var searchActive by rememberSaveable { mutableStateOf(false) }
270 var searchActive by rememberSaveable { mutableStateOf(false) }
270 var chatSearch by rememberSaveable { mutableStateOf("") }
271 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) }
271 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
277 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
272 // a confirmation dialog (from both the overflow menu and the command palette).
278 // a confirmation dialog (from both the overflow menu and the command palette).
273 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
279 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -490,7 +496,11 @@ fun ChatScreen(
490 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
496 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
492 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
498 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 }
493 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
502 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
503+ ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
494 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
504 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
495 showStopConfirm = true; true
505 showStopConfirm = true; true
@@ -824,6 +834,11 @@ fun ChatScreen(
824 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
834 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
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
827 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
842 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
828 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
843 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
@@ -887,6 +902,18 @@ fun ChatScreen(
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
914+ if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
890 LaunchedEffect(Unit) {
917 LaunchedEffect(Unit) {
891 var prevSize = 0
918 var prevSize = 0
892 var prevLen = 0
919 var prevLen = 0
@@ -1114,11 +1141,33 @@ fun ChatScreen(
1114 key = { it.key },
1141 key = { it.key },
1115 contentType = { it.contentType },
1142 contentType = { it.contentType },
1116 ) { item ->
1143 ) { 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
1152+ val isFocusedMatch = focusedMatchId != null &&
1153+ item is MessageListItem.Message &&
1154+ item.message.info.id == focusedMatchId
1117 // Default placement animation so inserted/moved rows glide in. Skipped
1155 // Default placement animation so inserted/moved rows glide in. Skipped
1118 // entirely under reduced motion so a streaming message growing in place
1156 // entirely under reduced motion so a streaming message growing in place
1119 // doesn't animate on every token (and respects the a11y preference).
1157 // doesn't animate on every token (and respects the a11y preference).
1121− if (LocalReducedMotion.current) Modifier else Modifier.animateItem(),
1160+ .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1162+ if (isFocusedMatch) {
1163+ Modifier.background(
1164+ MaterialTheme.colorScheme.secondaryContainer,
1165+ MaterialTheme.shapes.medium,
1123 when (item) {
1172 when (item) {
1124 is MessageListItem.Separator -> DateSeparator(item.label)
1173 is MessageListItem.Separator -> DateSeparator(item.label)
@@ -1256,30 +1305,48 @@ fun ChatScreen(
1257 val elapsedText = remember(elapsedMs) {
1306 val elapsedText = remember(elapsedMs) {
1258 val total = elapsedMs / 1000
1307 val total = elapsedMs / 1000
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.
1259− "%d:%02d".format(total / 60, total % 60)
1312+ String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1262 verticalAlignment = Alignment.CenterVertically,
1315 verticalAlignment = Alignment.CenterVertically,
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.
1263− modifier = Modifier.semantics {
1319+ modifier = Modifier
1264− contentDescription = workingText
1320+ .animateItem()
1265− },
1321+ .padding(vertical = 4.dp),
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.
1328+ verticalAlignment = Alignment.CenterVertically,
1329+ modifier = Modifier.semantics(mergeDescendants = true) {
1330+ contentDescription = "$workingText $elapsedText"
1267− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1333+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1268− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1334+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1269− Spacer(Modifier.size(8.dp))
1335+ Spacer(Modifier.size(8.dp))
1271− Icons.Filled.Schedule,
1337+ Icons.Filled.Schedule,
1272− contentDescription = null,
1338+ contentDescription = null,
1273− modifier = Modifier.size(14.dp),
1339+ modifier = Modifier.size(14.dp),
1274− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1340+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1277− elapsedText,
1343+ elapsedText,
1278− style = MaterialTheme.typography.labelMedium,
1344+ style = MaterialTheme.typography.labelMedium,
1279− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1345+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1280− color = MaterialTheme.colorScheme.onSurfaceVariant,
1346+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1281− modifier = Modifier.padding(start = 4.dp),
1347+ modifier = Modifier.padding(start = 4.dp),
1283 // A trailing Stop on the working row so the run can be aborted
1350 // A trailing Stop on the working row so the run can be aborted
1284 // without scrolling back to the composer (which may be off-screen
1351 // without scrolling back to the composer (which may be off-screen
1285 // while the user reads the streaming output up-thread).
1352 // while the user reads the streaming output up-thread).
@@ -1387,14 +1454,44 @@ fun ChatScreen(
1387 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1454 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1388 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1455 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
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
1391− if (searchQuery.isNotEmpty()) {
1461+ if (searchQuery.isNotEmpty() && matchCount > 0) {
1392− stringResource(R.string.search_match_count, searchMessages.size, messages.size)
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)
1393 } else "",
1465 } else "",
1394 style = MaterialTheme.typography.labelSmall,
1466 style = MaterialTheme.typography.labelSmall,
1395 color = MaterialTheme.colorScheme.onSurfaceVariant,
1467 color = MaterialTheme.colorScheme.onSurfaceVariant,
1396 modifier = Modifier.padding(horizontal = 4.dp),
1468 modifier = Modifier.padding(horizontal = 4.dp),
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)
1477+ if (matchCount > 0) {
1478+ searchPos = (searchPos - 1 + matchCount) % matchCount
1481+ enabled = matchCount > 1,
1483+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1487+ if (matchCount > 0) {
1488+ searchPos = (searchPos + 1) % matchCount
1491+ enabled = matchCount > 1,
1493+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1398 IconButton(onClick = {
1495 IconButton(onClick = {
1399 searchActive = false
1496 searchActive = false
1400 chatSearch = ""
1497 chatSearch = ""
@@ -1845,6 +1942,7 @@ private fun ChatInputBar(
1846 canSend = enabled && hasContent,
1943 canSend = enabled && hasContent,
1847 running = running,
1944 running = running,
1945+ sendOnEnter = sendOnEnter,
1848 attachments = attachments,
1946 attachments = attachments,
1849 onRemoveAttachment = onRemoveAttachment,
1947 onRemoveAttachment = onRemoveAttachment,
1850 staging = staging,
1948 staging = staging,
@@ -1864,6 +1962,7 @@ private fun FullScreenEditor(
1864 onSend: () -> Unit,
1962 onSend: () -> Unit,
1865 canSend: Boolean,
1963 canSend: Boolean,
1866 running: Boolean = false,
1964 running: Boolean = false,
1965+ sendOnEnter: Boolean = false,
1867 attachments: List<PendingAttachment> = emptyList(),
1966 attachments: List<PendingAttachment> = emptyList(),
1868 onRemoveAttachment: (String) -> Unit = {},
1967 onRemoveAttachment: (String) -> Unit = {},
1869 staging: Boolean = false,
1968 staging: Boolean = false,
@@ -1904,10 +2003,35 @@ private fun FullScreenEditor(
1904 OutlinedTextField(
2003 OutlinedTextField(
1905 value = value,
2004 value = value,
1906 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2005 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1907− modifier = Modifier.fillMaxSize().weight(1f),
2006+ modifier = Modifier
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(
2019+ hasContent = canSend,
2020+ sendOnEnter = sendOnEnter,
2021+ shift = event.isShiftPressed,
2022+ ctrl = event.isCtrlPressed,
2024+ if (send) { onSend(); true } else false
1908 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2026 placeholder = { Text(stringResource(R.string.message_placeholder)) },
1909 maxLines = Int.MAX_VALUE,
2027 maxLines = Int.MAX_VALUE,
2028+ keyboardOptions = KeyboardOptions(
2029+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1910− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
2030+ capitalization = KeyboardCapitalization.Sentences,
2032+ keyboardActions = KeyboardActions(
2033+ onSend = { if (canSend) onSend() },
@@ -2054,6 +2178,7 @@ private fun EmptyConversation(
2054/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
2178/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
2055@Composable
2179@Composable
2056private fun RevertBanner(onUndo: () -> Unit) {
2180private fun RevertBanner(onUndo: () -> Unit) {
2181+ val haptics = LocalHapticFeedback.current
2057 Surface(
2182 Surface(
2058 color = MaterialTheme.colorScheme.tertiaryContainer,
2183 color = MaterialTheme.colorScheme.tertiaryContainer,
2059 tonalElevation = 2.dp,
2184 tonalElevation = 2.dp,
@@ -2078,7 +2203,12 @@ private fun RevertBanner(onUndo: () -> Unit) {
2078 modifier = Modifier.padding(start = 8.dp),
2203 modifier = Modifier.padding(start = 8.dp),
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)
2081− TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
2211+ }) { Text(stringResource(R.string.undo)) }
@@ -2123,8 +2253,14 @@ private fun OutboxBanner(
2125 if (!sending) {
2255 if (!sending) {
2256+ val haptics = LocalHapticFeedback.current
2126 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2257 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)
2128 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2264 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
@@ -2377,6 +2513,9 @@ private fun DateSeparator(label: String) {
2377 Surface(
2513 Surface(
2378 color = MaterialTheme.colorScheme.surfaceVariant,
2514 color = MaterialTheme.colorScheme.surfaceVariant,
2379 shape = MaterialTheme.shapes.small,
2515 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() },