@@ -18,6 +18,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
18import androidx.compose.animation.core.tween
18import androidx.compose.animation.core.tween
19import androidx.compose.foundation.background
19import androidx.compose.foundation.background
20import androidx.compose.foundation.clickable
20import androidx.compose.foundation.clickable
21+import androidx.compose.foundation.content.contentReceiver
21import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Box
23import androidx.compose.foundation.layout.Box
23import androidx.compose.foundation.layout.Column
24import androidx.compose.foundation.layout.Column
@@ -48,8 +49,10 @@ import androidx.compose.material.icons.filled.Add
48import androidx.compose.material.icons.filled.AutoAwesome
49import androidx.compose.material.icons.filled.AutoAwesome
49import androidx.compose.material.icons.filled.Delete
50import androidx.compose.material.icons.filled.Delete
50import androidx.compose.material.icons.filled.Description
51import androidx.compose.material.icons.filled.Description
52+import androidx.compose.material.icons.filled.Close
51import androidx.compose.material.icons.filled.Edit
53import androidx.compose.material.icons.filled.Edit
52import androidx.compose.material.icons.filled.Expand
54import androidx.compose.material.icons.filled.Expand
55+import androidx.compose.material.icons.filled.Search
53import androidx.compose.material.icons.filled.KeyboardArrowDown
56import androidx.compose.material.icons.filled.KeyboardArrowDown
54import androidx.compose.material.icons.filled.KeyboardArrowUp
57import androidx.compose.material.icons.filled.KeyboardArrowUp
55import androidx.compose.material.icons.filled.Mic
58import androidx.compose.material.icons.filled.Mic
@@ -262,6 +265,9 @@ fun ChatScreen(
262 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
265 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
263 var showShellDialog by rememberSaveable { mutableStateOf(false) }
266 var showShellDialog by rememberSaveable { mutableStateOf(false) }
264 var showPalette by rememberSaveable { mutableStateOf(false) }
267 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("") }
265 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
271 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
266 // a confirmation dialog (from both the overflow menu and the command palette).
272 // a confirmation dialog (from both the overflow menu and the command palette).
267 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
273 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -347,6 +353,7 @@ fun ChatScreen(
347 val noVoiceMsg = stringResource(R.string.no_voice_app)
353 val noVoiceMsg = stringResource(R.string.no_voice_app)
348 val voicePrompt = stringResource(R.string.voice_prompt)
354 val voicePrompt = stringResource(R.string.voice_prompt)
349 val linkCopiedMsg = stringResource(R.string.link_copied)
355 val linkCopiedMsg = stringResource(R.string.link_copied)
356+ val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
351 // Count of picks currently being read + base64-encoded off the main thread, so the
358 // Count of picks currently being read + base64-encoded off the main thread, so the
352 // composer can show a staging placeholder immediately (chips only materialize once done).
359 // composer can show a staging placeholder immediately (chips only materialize once done).
@@ -643,6 +650,18 @@ fun ChatScreen(
643 enabled = !commandsLoading || commands.isNotEmpty(),
650 enabled = !commandsLoading || commands.isNotEmpty(),
644 onClick = { showOverflowMenu = false; showCommandPicker = true },
651 onClick = { showOverflowMenu = false; showCommandPicker = true },
654+ text = { Text(stringResource(R.string.keyboard_shortcuts)) },
655+ onClick = { showOverflowMenu = false; showShortcutsDialog = true },
658+ text = { Text(stringResource(R.string.find_in_conversation)) },
660+ showOverflowMenu = false
662+ runCatching { inputFocusRequester.requestFocus() }
646 androidx.compose.material3.HorizontalDivider()
665 androidx.compose.material3.HorizontalDivider()
647 // Session sharing: create a public link, copy an existing one, or revoke it.
666 // Session sharing: create a public link, copy an existing one, or revoke it.
648 if (shareUrl == null) {
667 if (shareUrl == null) {
@@ -767,6 +786,7 @@ fun ChatScreen(
767 onPickFile = { filePicker.launch("*/*") },
786 onPickFile = { filePicker.launch("*/*") },
768 onCamera = { launchCamera() },
787 onCamera = { launchCamera() },
769 onVoice = { launchVoice() },
788 onVoice = { launchVoice() },
789+ onPasteImage = { stageUris(it) },
@@ -795,8 +815,17 @@ fun ChatScreen(
795 // raw messages list, so scroll targets must be in listItems space).
815 // raw messages list, so scroll targets must be in listItems space).
796 val todayLabel = stringResource(R.string.today)
816 val todayLabel = stringResource(R.string.today)
797 val yesterdayLabel = stringResource(R.string.yesterday)
817 val yesterdayLabel = stringResource(R.string.yesterday)
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 }
798− val listItems = remember(messages, todayLabel, yesterdayLabel) {
827+ val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
799− buildMessageListItems(messages, todayLabel, yesterdayLabel)
828+ buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
801 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
830 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
802 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
831 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
@@ -808,9 +837,10 @@ fun ChatScreen(
808 // whose value tracks the latest list.
837 // whose value tracks the latest list.
809 val currentListItems by rememberUpdatedState(listItems)
838 val currentListItems by rememberUpdatedState(listItems)
811− // New-content signal for the jump-to-latest FAB: set when content grows while the user
840+ // New-content signal for the jump-to-latest FAB: counts how many new items arrived
812− // is scrolled away from the bottom, cleared once they return (see the collector below).
841+ // while the user was scrolled away from the bottom, cleared once they return. A count
813− var hasNewContent by remember { mutableStateOf(false) }
842+ // (vs. a bare dot) tells the user how much they missed at a glance.
843+ var newContentCount by remember { mutableIntStateOf(0) }
815 val isPinnedToBottom by remember {
845 val isPinnedToBottom by remember {
816 derivedStateOf {
846 derivedStateOf {
@@ -873,7 +903,7 @@ fun ChatScreen(
873 }.collect { signal ->
903 }.collect { signal ->
874 if (signal.pinned) {
904 if (signal.pinned) {
875 // Back at the bottom: clear the new-content badge.
905 // Back at the bottom: clear the new-content badge.
876− hasNewContent = false
906+ newContentCount = 0
877 if (signal.size > 0) {
907 if (signal.size > 0) {
878 // Scroll to the effective last index, including the trailing
908 // Scroll to the effective last index, including the trailing
879 // "__typing" row when a run is active so the working indicator
909 // "__typing" row when a run is active so the working indicator
@@ -883,8 +913,11 @@ fun ChatScreen(
883 listState.scrollToItem(target)
913 listState.scrollToItem(target)
885 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
915 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
886− // Content arrived while scrolled up — badge the jump-to-latest FAB.
916+ // Content arrived while scrolled up — increment the jump-to-latest badge.
887− hasNewContent = true
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
889 prevSize = signal.size
922 prevSize = signal.size
890 prevLen = signal.lastTextLength
923 prevLen = signal.lastTextLength
@@ -1159,13 +1192,26 @@ fun ChatScreen(
1195+ onSpeak = { text ->
1162− onSpeak = { text -> tts.toggle(message.info.id, 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) }
1163 isSpeaking = message.info.id == speakingMessageId,
1204 isSpeaking = message.info.id == speakingMessageId,
1164 onQuote = { text ->
1205 onQuote = { text ->
1165 vm.quoteReply(text)
1206 vm.quoteReply(text)
1166 runCatching { inputFocusRequester.requestFocus() }
1207 runCatching { inputFocusRequester.requestFocus() }
1168 onBranch = { text -> vm.branchFrom(text) },
1209 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) }
1169 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1215 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1170 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1216 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1171 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1217 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1234,6 +1280,17 @@ fun ChatScreen(
1234 color = MaterialTheme.colorScheme.onSurfaceVariant,
1280 color = MaterialTheme.colorScheme.onSurfaceVariant,
1235 modifier = Modifier.padding(start = 4.dp),
1281 modifier = Modifier.padding(start = 4.dp),
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)
1289+ onClick = { showStopConfirm = true },
1290+ modifier = Modifier.semantics { contentDescription = stopLabel },
1292+ Icon(Icons.Filled.Stop, contentDescription = null)
@@ -1252,16 +1309,18 @@ fun ChatScreen(
1252 // they know there's something new to jump to (cleared once back at bottom).
1309 // they know there's something new to jump to (cleared once back at bottom).
1253 BadgedBox(
1310 BadgedBox(
1254 badge = {
1311 badge = {
1255− if (hasNewContent) {
1312+ if (newContentCount > 0) {
1256 val newContentLabel = stringResource(R.string.new_messages)
1313 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())
1260 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1319 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1262 ExtendedFloatingActionButton(
1321 ExtendedFloatingActionButton(
1263 onClick = {
1322 onClick = {
1264− hasNewContent = false
1323+ newContentCount = 0
1265 if (listItems.isNotEmpty()) {
1324 if (listItems.isNotEmpty()) {
1266 // Scroll to the effective last index, including the
1325 // Scroll to the effective last index, including the
1267 // trailing "__typing" row when a run is active.
1326 // trailing "__typing" row when a run is active.
@@ -1300,6 +1359,61 @@ fun ChatScreen(
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),
1372+ tonalElevation = 3.dp,
1373+ modifier = Modifier
1374+ .padding(horizontal = 12.dp, vertical = 4.dp)
1378+ modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1379+ verticalAlignment = Alignment.CenterVertically,
1382+ value = chatSearch,
1383+ onValueChange = { chatSearch = it },
1384+ modifier = Modifier.weight(1f),
1385+ placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1387+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1388+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1391+ if (searchQuery.isNotEmpty()) {
1392+ stringResource(R.string.search_match_count, searchMessages.size, messages.size)
1394+ style = MaterialTheme.typography.labelSmall,
1395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1396+ modifier = Modifier.padding(horizontal = 4.dp),
1398+ IconButton(onClick = {
1399+ searchActive = false
1402+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
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()) {
1411+ stringResource(R.string.search_no_matches),
1412+ style = MaterialTheme.typography.bodyMedium,
1413+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1414+ modifier = Modifier.align(Alignment.Center),
@@ -1462,8 +1576,48 @@ fun ChatScreen(
1462 if (showPalette) {
1576 if (showPalette) {
1463 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1577 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
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.
1585+ onDismissRequest = { showShortcutsDialog = false },
1586+ title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
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))
1597+ TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
1604+private fun ShortcutRow(keys: String, desc: String) {
1605+ Row(verticalAlignment = Alignment.CenterVertically) {
1608+ style = MaterialTheme.typography.labelLarge,
1609+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1610+ modifier = Modifier.widthIn(min = 160.dp),
1614+ style = MaterialTheme.typography.bodyMedium,
1615+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1620+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
1467@Composable
1621@Composable
1468private fun ChatInputBar(
1622private fun ChatInputBar(
1469 value: String,
1623 value: String,
@@ -1485,6 +1639,7 @@ private fun ChatInputBar(
1485 onPickFile: () -> Unit,
1639 onPickFile: () -> Unit,
1486 onCamera: () -> Unit,
1640 onCamera: () -> Unit,
1487 onVoice: () -> Unit,
1641 onVoice: () -> Unit,
1642+ onPasteImage: (List<Uri>) -> Unit,
1489 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1644 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1490 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1645 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1495,13 +1650,25 @@ private fun ChatInputBar(
1495 // A queued follow-up replaces the Stop button with a "queued" chip so the
1650 // A queued follow-up replaces the Stop button with a "queued" chip so the
1496 // user sees their message will be sent when the run finishes, and can cancel.
1651 // user sees their message will be sent when the run finishes, and can cancel.
1497 if (queuedFollowUp != null) {
1652 if (queuedFollowUp != null) {
1653+ val editLabel = stringResource(R.string.edit)
1499 modifier = Modifier
1655 modifier = Modifier
1500 .fillMaxWidth()
1656 .fillMaxWidth()
1501 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1657 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1502 verticalAlignment = Alignment.CenterVertically,
1658 verticalAlignment = Alignment.CenterVertically,
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.
1504− Column(modifier = Modifier.weight(1f)) {
1663+ modifier = Modifier
1665+ .clip(MaterialTheme.shapes.small)
1666+ .clickable(role = Role.Button, onClickLabel = editLabel) {
1667+ onValueChange(queuedFollowUp)
1670+ .padding(vertical = 4.dp),
1506 stringResource(R.string.queued),
1673 stringResource(R.string.queued),
1507 style = MaterialTheme.typography.labelSmall,
1674 style = MaterialTheme.typography.labelSmall,
@@ -1569,6 +1736,31 @@ private fun ChatInputBar(
1569 .weight(1f)
1736 .weight(1f)
1570 .focusRequester(focusRequester)
1737 .focusRequester(focusRequester)
1571 .testTag("chat_input")
1738 .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.
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:")
1756+ if (imageUris.isNotEmpty()) {
1757+ onPasteImage(imageUris)
1572 .onPreviewKeyEvent { event ->
1764 .onPreviewKeyEvent { event ->
1573 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1765 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1574 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1766 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
@@ -1643,8 +1835,19 @@ private fun ChatInputBar(
1643 value = value,
1835 value = value,
1644 onValueChange = onValueChange,
1836 onValueChange = onValueChange,
1645 onDismiss = { showFullScreenEditor = false },
1837 onDismiss = { showFullScreenEditor = false },
1646− onSend = { showFullScreenEditor = false; 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()
1647 canSend = enabled && hasContent,
1846 canSend = enabled && hasContent,
1848+ attachments = attachments,
1849+ onRemoveAttachment = onRemoveAttachment,
@@ -1660,6 +1863,10 @@ private fun FullScreenEditor(
1660 onDismiss: () -> Unit,
1863 onDismiss: () -> Unit,
1661 onSend: () -> Unit,
1864 onSend: () -> Unit,
1662 canSend: Boolean,
1865 canSend: Boolean,
1866+ running: Boolean = false,
1867+ attachments: List<PendingAttachment> = emptyList(),
1868+ onRemoveAttachment: (String) -> Unit = {},
1869+ staging: Boolean = false,
1664 androidx.compose.ui.window.Dialog(
1871 androidx.compose.ui.window.Dialog(
1665 onDismissRequest = onDismiss,
1872 onDismissRequest = onDismiss,
@@ -1683,20 +1890,26 @@ private fun FullScreenEditor(
1683 horizontalArrangement = Arrangement.End,
1890 horizontalArrangement = Arrangement.End,
1685 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
1892 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))
1691 ) { padding ->
1898 ) { padding ->
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)
1692− OutlinedTextField(
1904+ OutlinedTextField(
1693− value = value,
1905+ value = value,
1694− onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1906+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1695− modifier = Modifier.fillMaxSize().padding(padding),
1907+ modifier = Modifier.fillMaxSize().weight(1f),
1696− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1908+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1697− maxLines = Int.MAX_VALUE,
1909+ maxLines = Int.MAX_VALUE,
1698− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1910+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),