@@ -18,6 +18,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
1818import androidx.compose.animation.core.tween
1919import androidx.compose.foundation.background
2020import androidx.compose.foundation.clickable
21+import androidx.compose.foundation.content.contentReceiver
2122import androidx.compose.foundation.layout.Arrangement
2223import androidx.compose.foundation.layout.Box
2324import androidx.compose.foundation.layout.Column
@@ -48,8 +49,10 @@ import androidx.compose.material.icons.filled.Add
4849import androidx.compose.material.icons.filled.AutoAwesome
4950import androidx.compose.material.icons.filled.Delete
5051import androidx.compose.material.icons.filled.Description
52+import androidx.compose.material.icons.filled.Close
5153import androidx.compose.material.icons.filled.Edit
5254import androidx.compose.material.icons.filled.Expand
55+import androidx.compose.material.icons.filled.Search
5356import androidx.compose.material.icons.filled.KeyboardArrowDown
5457import androidx.compose.material.icons.filled.KeyboardArrowUp
5558import androidx.compose.material.icons.filled.Mic
@@ -262,6 +265,9 @@ fun ChatScreen(
262265 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
263266 var showShellDialog by rememberSaveable { mutableStateOf(false) }
264267 var showPalette by rememberSaveable { mutableStateOf(false) }
268+ var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269+ var searchActive by rememberSaveable { mutableStateOf(false) }
270+ var chatSearch by rememberSaveable { mutableStateOf("") }
265271 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
266272 // a confirmation dialog (from both the overflow menu and the command palette).
267273 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -347,6 +353,7 @@ fun ChatScreen(
347353 val noVoiceMsg = stringResource(R.string.no_voice_app)
348354 val voicePrompt = stringResource(R.string.voice_prompt)
349355 val linkCopiedMsg = stringResource(R.string.link_copied)
356+ val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
350357
351358 // Count of picks currently being read + base64-encoded off the main thread, so the
352359 // composer can show a staging placeholder immediately (chips only materialize once done).
@@ -643,6 +650,18 @@ fun ChatScreen(
643650 enabled = !commandsLoading || commands.isNotEmpty(),
644651 onClick = { showOverflowMenu = false; showCommandPicker = true },
645652 )
653+ DropdownMenuItem(
654+ text = { Text(stringResource(R.string.keyboard_shortcuts)) },
655+ onClick = { showOverflowMenu = false; showShortcutsDialog = true },
656+ )
657+ DropdownMenuItem(
658+ text = { Text(stringResource(R.string.find_in_conversation)) },
659+ onClick = {
660+ showOverflowMenu = false
661+ searchActive = true
662+ runCatching { inputFocusRequester.requestFocus() }
663+ },
664+ )
646665 androidx.compose.material3.HorizontalDivider()
647666 // Session sharing: create a public link, copy an existing one, or revoke it.
648667 if (shareUrl == null) {
@@ -767,6 +786,7 @@ fun ChatScreen(
767786 onPickFile = { filePicker.launch("*/*") },
768787 onCamera = { launchCamera() },
769788 onVoice = { launchVoice() },
789+ onPasteImage = { stageUris(it) },
770790 )
771791 }
772792 },
@@ -795,8 +815,17 @@ fun ChatScreen(
795815 // raw messages list, so scroll targets must be in listItems space).
796816 val todayLabel = stringResource(R.string.today)
797817 val yesterdayLabel = stringResource(R.string.yesterday)
798− val listItems = remember(messages, todayLabel, yesterdayLabel) {
799− buildMessageListItems(messages, todayLabel, yesterdayLabel)
818+ // In-conversation search: when active, narrow to messages whose text contains the query.
819+ // Computed on the filtered set so day separators still align with the visible messages.
820+ val searchQuery = chatSearch.trim()
821+ val searchMessages = remember(messages, searchQuery) {
822+ if (searchQuery.isEmpty()) messages
823+ else messages.filter { m ->
824+ m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
825+ }
826+ }
827+ val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
828+ buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
800829 }
801830 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
802831 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
@@ -808,9 +837,10 @@ fun ChatScreen(
808837 // whose value tracks the latest list.
809838 val currentListItems by rememberUpdatedState(listItems)
810839
811− // New-content signal for the jump-to-latest FAB: set when content grows while the user
812− // is scrolled away from the bottom, cleared once they return (see the collector below).
813− var hasNewContent by remember { mutableStateOf(false) }
840+ // New-content signal for the jump-to-latest FAB: counts how many new items arrived
841+ // while the user was scrolled away from the bottom, cleared once they return. A count
842+ // (vs. a bare dot) tells the user how much they missed at a glance.
843+ var newContentCount by remember { mutableIntStateOf(0) }
814844
815845 val isPinnedToBottom by remember {
816846 derivedStateOf {
@@ -873,7 +903,7 @@ fun ChatScreen(
873903 }.collect { signal ->
874904 if (signal.pinned) {
875905 // Back at the bottom: clear the new-content badge.
876− hasNewContent = false
906+ newContentCount = 0
877907 if (signal.size > 0) {
878908 // Scroll to the effective last index, including the trailing
879909 // "__typing" row when a run is active so the working indicator
@@ -883,8 +913,11 @@ fun ChatScreen(
883913 listState.scrollToItem(target)
884914 }
885915 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
886− // Content arrived while scrolled up — badge the jump-to-latest FAB.
887− hasNewContent = true
916+ // Content arrived while scrolled up — increment the jump-to-latest badge.
917+ // Only count whole new items (not per-token growth) so the number reflects
918+ // new messages, not a token count.
919+ if (signal.size > prevSize) newContentCount += signal.size - prevSize
920+ else if (newContentCount == 0) newContentCount = 1
888921 }
889922 prevSize = signal.size
890923 prevLen = signal.lastTextLength
@@ -1159,13 +1192,26 @@ fun ChatScreen(
11591192 }
11601193 }
11611194 },
1162− onSpeak = { text -> tts.toggle(message.info.id, text) },
1195+ onSpeak = { text ->
1196+ if (!tts.toggle(message.info.id, text)) {
1197+ // TTS engine unavailable (init failed / not yet ready
1198+ // / blank text): surface feedback so the button doesn't
1199+ // appear dead. Throttled so a rapid double-tap (stop
1200+ // then immediate re-tap before ready) doesn't spam.
1201+ scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1202+ }
1203+ },
11631204 isSpeaking = message.info.id == speakingMessageId,
11641205 onQuote = { text ->
11651206 vm.quoteReply(text)
11661207 runCatching { inputFocusRequester.requestFocus() }
11671208 },
11681209 onBranch = { text -> vm.branchFrom(text) },
1210+ // Regenerate is only meaningful for a finished assistant reply
1211+ // (not the actively-streaming one), so gate it on !running.
1212+ onRegenerate = if (!running) {
1213+ { vm.regenerate(message.info.id) }
1214+ } else null,
11691215 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
11701216 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
11711217 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1234,6 +1280,17 @@ fun ChatScreen(
12341280 color = MaterialTheme.colorScheme.onSurfaceVariant,
12351281 modifier = Modifier.padding(start = 4.dp),
12361282 )
1283+ // 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
1285+ // while the user reads the streaming output up-thread).
1286+ Spacer(Modifier.weight(1f))
1287+ val stopLabel = stringResource(R.string.stop)
1288+ IconButton(
1289+ onClick = { showStopConfirm = true },
1290+ modifier = Modifier.semantics { contentDescription = stopLabel },
1291+ ) {
1292+ Icon(Icons.Filled.Stop, contentDescription = null)
1293+ }
12371294 }
12381295 }
12391296 }
@@ -1252,16 +1309,18 @@ fun ChatScreen(
12521309 // they know there's something new to jump to (cleared once back at bottom).
12531310 BadgedBox(
12541311 badge = {
1255− if (hasNewContent) {
1312+ if (newContentCount > 0) {
12561313 val newContentLabel = stringResource(R.string.new_messages)
1257− Badge(modifier = Modifier.semantics { contentDescription = newContentLabel })
1314+ Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1315+ Text(if (newContentCount > 99) "99+" else newContentCount.toString())
1316+ }
12581317 }
12591318 },
12601319 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
12611320 ) {
12621321 ExtendedFloatingActionButton(
12631322 onClick = {
1264− hasNewContent = false
1323+ newContentCount = 0
12651324 if (listItems.isNotEmpty()) {
12661325 // Scroll to the effective last index, including the
12671326 // trailing "__typing" row when a run is active.
@@ -1300,6 +1359,61 @@ fun ChatScreen(
13001359 )
13011360 }
13021361 }
1362+ // In-conversation search bar. Overlays the list so typing filters messages in
1363+ // place; the match count + a no-matches note give immediate feedback.
1364+ val searchMotion = rememberVisibilityTransitions()
1365+ AnimatedVisibility(
1366+ visible = searchActive,
1367+ enter = searchMotion.enter,
1368+ exit = searchMotion.exit,
1369+ modifier = Modifier.align(Alignment.TopCenter),
1370+ ) {
1371+ Surface(
1372+ tonalElevation = 3.dp,
1373+ modifier = Modifier
1374+ .padding(horizontal = 12.dp, vertical = 4.dp)
1375+ .fillMaxWidth(),
1376+ ) {
1377+ Row(
1378+ modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1379+ verticalAlignment = Alignment.CenterVertically,
1380+ ) {
1381+ OutlinedTextField(
1382+ value = chatSearch,
1383+ onValueChange = { chatSearch = it },
1384+ modifier = Modifier.weight(1f),
1385+ placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1386+ singleLine = true,
1387+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1388+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1389+ )
1390+ Text(
1391+ if (searchQuery.isNotEmpty()) {
1392+ stringResource(R.string.search_match_count, searchMessages.size, messages.size)
1393+ } else "",
1394+ style = MaterialTheme.typography.labelSmall,
1395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1396+ modifier = Modifier.padding(horizontal = 4.dp),
1397+ )
1398+ IconButton(onClick = {
1399+ searchActive = false
1400+ chatSearch = ""
1401+ }) {
1402+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1403+ }
1404+ }
1405+ }
1406+ }
1407+ // When a search yields no matches (but the conversation isn't empty), say so
1408+ // instead of showing a blank list.
1409+ if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1410+ Text(
1411+ stringResource(R.string.search_no_matches),
1412+ style = MaterialTheme.typography.bodyMedium,
1413+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1414+ modifier = Modifier.align(Alignment.Center),
1415+ )
1416+ }
13031417 }
13041418 }
13051419 }
@@ -1462,8 +1576,48 @@ fun ChatScreen(
14621576 if (showPalette) {
14631577 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
14641578 }
1579+
1580+ if (showShortcutsDialog) {
1581+ // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
1582+ // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
1583+ // depends on the "Send on Enter" setting, noted inline.
1584+ AlertDialog(
1585+ onDismissRequest = { showShortcutsDialog = false },
1586+ title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
1587+ text = {
1588+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
1589+ ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
1590+ ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
1591+ ShortcutRow("Esc", stringResource(R.string.shortcut_close))
1592+ ShortcutRow("Enter", stringResource(R.string.shortcut_send))
1593+ ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
1594+ }
1595+ },
1596+ confirmButton = {
1597+ TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
1598+ },
1599+ )
1600+ }
14651601}
14661602
1603+@Composable
1604+private fun ShortcutRow(keys: String, desc: String) {
1605+ Row(verticalAlignment = Alignment.CenterVertically) {
1606+ Text(
1607+ keys,
1608+ style = MaterialTheme.typography.labelLarge,
1609+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1610+ modifier = Modifier.widthIn(min = 160.dp),
1611+ )
1612+ Text(
1613+ desc,
1614+ style = MaterialTheme.typography.bodyMedium,
1615+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1616+ )
1617+ }
1618+}
1619+
1620+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
14671621@Composable
14681622private fun ChatInputBar(
14691623 value: String,
@@ -1485,6 +1639,7 @@ private fun ChatInputBar(
14851639 onPickFile: () -> Unit,
14861640 onCamera: () -> Unit,
14871641 onVoice: () -> Unit,
1642+ onPasteImage: (List<Uri>) -> Unit,
14881643) {
14891644 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
14901645 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1495,13 +1650,25 @@ private fun ChatInputBar(
14951650 // A queued follow-up replaces the Stop button with a "queued" chip so the
14961651 // user sees their message will be sent when the run finishes, and can cancel.
14971652 if (queuedFollowUp != null) {
1653+ val editLabel = stringResource(R.string.edit)
14981654 Row(
14991655 modifier = Modifier
15001656 .fillMaxWidth()
15011657 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
15021658 verticalAlignment = Alignment.CenterVertically,
15031659 ) {
1504− Column(modifier = Modifier.weight(1f)) {
1660+ // Tapping the chip reloads the queued text into the draft (and clears the
1661+ // queue) so a typo spotted while queuing can be fixed without retyping.
1662+ Column(
1663+ modifier = Modifier
1664+ .weight(1f)
1665+ .clip(MaterialTheme.shapes.small)
1666+ .clickable(role = Role.Button, onClickLabel = editLabel) {
1667+ onValueChange(queuedFollowUp)
1668+ onCancelQueue()
1669+ }
1670+ .padding(vertical = 4.dp),
1671+ ) {
15051672 Text(
15061673 stringResource(R.string.queued),
15071674 style = MaterialTheme.typography.labelSmall,
@@ -1569,6 +1736,31 @@ private fun ChatInputBar(
15691736 .weight(1f)
15701737 .focusRequester(focusRequester)
15711738 .testTag("chat_input")
1739+ // Intercept image content pasted/dropped into the field (keyboard paste
1740+ // of a copied screenshot, drag from Files, etc.) and route it through the
1741+ // same staging path as the attach menu. Non-image content (text) falls
1742+ // through unchanged so normal typing/paste works.
1743+ .contentReceiver(
1744+ remember {
1745+ androidx.compose.foundation.content.ReceiveContentListener { transferable ->
1746+ val clipData = transferable.clipEntry?.clipData
1747+ if (clipData != null) {
1748+ val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
1749+ val item = clipData.getItemAt(i)
1750+ item.uri?.takeIf { uri ->
1751+ item.text == null && uri.toString().let { s ->
1752+ s.startsWith("content:") || s.startsWith("file:")
1753+ }
1754+ }
1755+ }
1756+ if (imageUris.isNotEmpty()) {
1757+ onPasteImage(imageUris)
1758+ }
1759+ }
1760+ transferable
1761+ }
1762+ },
1763+ )
15721764 .onPreviewKeyEvent { event ->
15731765 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
15741766 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
@@ -1643,8 +1835,19 @@ private fun ChatInputBar(
16431835 value = value,
16441836 onValueChange = onValueChange,
16451837 onDismiss = { showFullScreenEditor = false },
1646− onSend = { showFullScreenEditor = false; onSend() },
1838+ onSend = {
1839+ showFullScreenEditor = false
1840+ // Mirror the inline composer's behavior: while a run is active a send
1841+ // can't go immediately, so queue the follow-up instead of silently
1842+ // dropping it (vm.send() returns false during a run, which previously
1843+ // lost the typed message with no feedback).
1844+ if (running) onQueueFollowUp(value) else onSend()
1845+ },
16471846 canSend = enabled && hasContent,
1847+ running = running,
1848+ attachments = attachments,
1849+ onRemoveAttachment = onRemoveAttachment,
1850+ staging = staging,
16481851 )
16491852 }
16501853 }
@@ -1660,6 +1863,10 @@ private fun FullScreenEditor(
16601863 onDismiss: () -> Unit,
16611864 onSend: () -> Unit,
16621865 canSend: Boolean,
1866+ running: Boolean = false,
1867+ attachments: List<PendingAttachment> = emptyList(),
1868+ onRemoveAttachment: (String) -> Unit = {},
1869+ staging: Boolean = false,
16631870) {
16641871 androidx.compose.ui.window.Dialog(
16651872 onDismissRequest = onDismiss,
@@ -1683,20 +1890,26 @@ private fun FullScreenEditor(
16831890 horizontalArrangement = Arrangement.End,
16841891 ) {
16851892 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
1686− Text(stringResource(R.string.send))
1893+ Text(stringResource(if (running) R.string.queue else R.string.send))
16871894 }
16881895 }
16891896 }
16901897 },
16911898 ) { padding ->
1692− OutlinedTextField(
1693− value = value,
1694− onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1695− modifier = Modifier.fillMaxSize().padding(padding),
1696− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1697− maxLines = Int.MAX_VALUE,
1698− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1699− )
1899+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
1900+ // Show staged attachments in the full-screen editor too, so a user who staged
1901+ // images then expanded for a long prompt can still see and remove them without
1902+ // collapsing first (mirrors the inline composer's AttachmentStrip).
1903+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1904+ OutlinedTextField(
1905+ value = value,
1906+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1907+ modifier = Modifier.fillMaxSize().weight(1f),
1908+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1909+ maxLines = Int.MAX_VALUE,
1910+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1911+ )
1912+ }
17001913 }
17011914 }
17021915}