@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
29import androidx.compose.foundation.layout.WindowInsets
29import androidx.compose.foundation.layout.WindowInsets
30import androidx.compose.foundation.layout.fillMaxSize
30import androidx.compose.foundation.layout.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
31import androidx.compose.foundation.layout.fillMaxWidth
32+import androidx.compose.foundation.layout.heightIn
32import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.imePadding
34import androidx.compose.foundation.layout.imePadding
34import androidx.compose.foundation.layout.ime
35import androidx.compose.foundation.layout.ime
@@ -46,6 +47,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
46import androidx.compose.material.icons.Icons
47import androidx.compose.material.icons.Icons
47import androidx.compose.material.icons.automirrored.filled.ArrowBack
48import androidx.compose.material.icons.automirrored.filled.ArrowBack
48import androidx.compose.material.icons.automirrored.filled.Send
49import androidx.compose.material.icons.automirrored.filled.Send
50+import androidx.compose.material.icons.filled.ErrorOutline
49import androidx.compose.material.icons.filled.Add
51import androidx.compose.material.icons.filled.Add
50import androidx.compose.material.icons.filled.AutoAwesome
52import androidx.compose.material.icons.filled.AutoAwesome
51import androidx.compose.material.icons.filled.Delete
53import androidx.compose.material.icons.filled.Delete
@@ -131,13 +133,17 @@ import androidx.compose.ui.layout.onSizeChanged
131import androidx.compose.ui.platform.LocalContext
133import androidx.compose.ui.platform.LocalContext
132import androidx.compose.ui.platform.LocalDensity
134import androidx.compose.ui.platform.LocalDensity
133import androidx.compose.ui.platform.LocalHapticFeedback
135import androidx.compose.ui.platform.LocalHapticFeedback
136+import androidx.compose.ui.platform.LocalLayoutDirection
134import androidx.compose.ui.platform.LocalView
137import androidx.compose.ui.platform.LocalView
135import androidx.compose.ui.platform.testTag
138import androidx.compose.ui.platform.testTag
139+import androidx.compose.ui.unit.LayoutDirection
136import androidx.compose.ui.res.pluralStringResource
140import androidx.compose.ui.res.pluralStringResource
137import androidx.compose.ui.res.stringResource
141import androidx.compose.ui.res.stringResource
138import androidx.compose.ui.semantics.Role
142import androidx.compose.ui.semantics.Role
139import androidx.compose.ui.semantics.contentDescription
143import androidx.compose.ui.semantics.contentDescription
140import androidx.compose.ui.semantics.heading
144import androidx.compose.ui.semantics.heading
145+import androidx.compose.ui.semantics.liveRegion
146+import androidx.compose.ui.semantics.LiveRegionMode
141import androidx.compose.ui.semantics.invisibleToUser
147import androidx.compose.ui.semantics.invisibleToUser
142import androidx.compose.ui.semantics.semantics
148import androidx.compose.ui.semantics.semantics
143import androidx.compose.ui.text.input.ImeAction
149import androidx.compose.ui.text.input.ImeAction
@@ -153,6 +159,8 @@ import kotlinx.coroutines.launch
153import kotlinx.coroutines.withContext
159import kotlinx.coroutines.withContext
154import soy.iko.opencode.data.model.Part
160import soy.iko.opencode.data.model.Part
155import soy.iko.opencode.data.model.MessageWithParts
161import soy.iko.opencode.data.model.MessageWithParts
162+import soy.iko.opencode.data.model.AssistantMessage
163+import soy.iko.opencode.data.model.Command
156import soy.iko.opencode.data.model.ReasoningPart
164import soy.iko.opencode.data.model.ReasoningPart
157import soy.iko.opencode.data.model.TextPart
165import soy.iko.opencode.data.model.TextPart
158import soy.iko.opencode.data.model.ToolCompleted
166import soy.iko.opencode.data.model.ToolCompleted
@@ -197,6 +205,7 @@ fun ChatScreen(
197 val aborting by vm.aborting.collectAsStateWithLifecycle()
205 val aborting by vm.aborting.collectAsStateWithLifecycle()
198 val loading by vm.loading.collectAsStateWithLifecycle()
206 val loading by vm.loading.collectAsStateWithLifecycle()
199 val loadError by vm.loadError.collectAsStateWithLifecycle()
207 val loadError by vm.loadError.collectAsStateWithLifecycle()
208+ val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
200 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
209 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
201 val models by vm.models.collectAsStateWithLifecycle()
210 val models by vm.models.collectAsStateWithLifecycle()
202 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
211 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
@@ -226,6 +235,7 @@ fun ChatScreen(
226 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
235 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
227 val isOnline by container.isOnline.collectAsStateWithLifecycle()
236 val isOnline by container.isOnline.collectAsStateWithLifecycle()
228 val haptics = LocalHapticFeedback.current
237 val haptics = LocalHapticFeedback.current
238+ val layoutDirection = LocalLayoutDirection.current
229 val scope = rememberCoroutineScope()
239 val scope = rememberCoroutineScope()
230 val shareContext = LocalContext.current
240 val shareContext = LocalContext.current
231 val defaultShareSubject = stringResource(R.string.share_subject)
241 val defaultShareSubject = stringResource(R.string.share_subject)
@@ -849,6 +859,14 @@ fun ChatScreen(
849 onCamera = { launchCamera() },
859 onCamera = { launchCamera() },
850 onVoice = { launchVoice() },
860 onVoice = { launchVoice() },
851 onPasteImage = { stageUris(it) },
861 onPasteImage = { stageUris(it) },
862+ commands = commands,
863+ onRunCommand = { cmd ->
865+ // Clear the draft so the typed /prefix doesn't linger after the
866+ // command fires (the command picker confirmation, if any, is
867+ // handled inside runCommand).
@@ -879,28 +897,47 @@ fun ChatScreen(
879 val yesterdayLabel = stringResource(R.string.yesterday)
897 val yesterdayLabel = stringResource(R.string.yesterday)
880 // In-conversation search: when active, narrow to messages whose text contains the query.
898 // In-conversation search: when active, narrow to messages whose text contains the query.
881 // Computed on the filtered set so day separators still align with the visible messages.
899 // Computed on the filtered set so day separators still align with the visible messages.
900+ // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
901+ // conversation (with many tool parts whose state stringification is non-trivial)
902+ // doesn't jank the UI. produceState yields the empty list immediately and publishes
903+ // the filtered result once the debounce settles.
882 val searchQuery = chatSearch.trim()
904 val searchQuery = chatSearch.trim()
883− val searchMessages = remember(messages, searchQuery) {
905+ val searchMessages by produceState(
906+ initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
884− if (searchQuery.isEmpty()) messages
910+ if (searchQuery.isEmpty()) {
913+ // Debounce: wait for the user to stop typing before running the filter,
914+ // so each keystroke doesn't kick off a fresh scan.
915+ kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
916+ // Run the filter off the main thread — the walk stringifies tool state
917+ // and scans every part of every message, which is non-trivial work for a
918+ // long conversation.
919+ value = withContext(Dispatchers.Default) {
885− else messages.filter { m ->
920+ messages.filter { m ->
886− m.parts.any { p ->
921+ m.parts.any { p ->
887− when (p) {
922+ when (p) {
888− is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
923+ is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
889− is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
924+ is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
890− is ToolPart -> {
925+ is ToolPart -> {
891− // Match the tool name plus its running/completed/error text and the
926+ // Match the tool name plus its running/completed/error text and the
892− // stringified input, so searching for an error string or a file the
927+ // stringified input, so searching for an error string or a file the
893− // tool touched actually finds the message it appeared in.
928+ // tool touched actually finds the message it appeared in.
894− p.tool.contains(searchQuery, ignoreCase = true) ||
929+ p.tool.contains(searchQuery, ignoreCase = true) ||
895− (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
930+ (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
896− (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
931+ (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
897− p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
932+ p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
898− (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
933+ (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
899− p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
934+ p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
936+ is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
937+ p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
901− is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
902− p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
@@ -1102,6 +1139,48 @@ fun ChatScreen(
1102 // separate "Not connected" state below).
1139 // separate "Not connected" state below).
1103 onRetry = { vm.refreshMessages() },
1140 onRetry = { vm.refreshMessages() },
1142+ // Persistent inline error banner for a mid-conversation load failure.
1143+ // Unlike the one-shot snackbar (which fires once per streak then goes
1144+ // silent), this stays visible until the stream recovers, so a persistent
1145+ // outage doesn't read as "everything is fine" after the snackbar dismisses.
1146+ // Anchored at the top, below the connection banner, so it doesn't overlap
1147+ // the message list.
1148+ if (loadErrorInline && hasMessages) {
1150+ color = MaterialTheme.colorScheme.errorContainer,
1151+ shape = MaterialTheme.shapes.small,
1152+ modifier = Modifier
1153+ .align(Alignment.TopCenter)
1154+ .padding(top = if (bannerVisible) topPad else 8.dp)
1155+ .padding(horizontal = 16.dp)
1156+ .semantics { liveRegion = LiveRegionMode.Polite },
1159+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1160+ verticalAlignment = Alignment.CenterVertically,
1161+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1164+ Icons.Filled.ErrorOutline,
1165+ contentDescription = null,
1166+ modifier = Modifier.size(16.dp),
1167+ tint = MaterialTheme.colorScheme.onErrorContainer,
1170+ stringResource(R.string.load_error_persistent),
1171+ style = MaterialTheme.typography.labelMedium,
1172+ color = MaterialTheme.colorScheme.onErrorContainer,
1173+ modifier = Modifier.weight(1f),
1176+ onClick = { vm.refreshMessages() },
1177+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1179+ Text(stringResource(R.string.retry))
1105 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1184 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1106 // recovery is a swipe away everywhere — not just when the message list is
1185 // recovery is a swipe away everywhere — not just when the message list is
1107 // populated. Previously PTR was only around the populated list, leaving the
1186 // populated. Previously PTR was only around the populated list, leaving the
@@ -1247,7 +1326,12 @@ fun ChatScreen(
1247 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1326 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1248 val swipeState = rememberSwipeToDismissBoxState(
1327 val swipeState = rememberSwipeToDismissBoxState(
1249 confirmValueChange = { value ->
1328 confirmValueChange = { value ->
1329+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1330+ SwipeToDismissBoxValue.EndToStart
1250− if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1332+ SwipeToDismissBoxValue.StartToEnd
1334+ if (value == replyValue && quoteText != null) {
1251 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1335 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1252 vm.quoteReply(quoteText)
1336 vm.quoteReply(quoteText)
1253 runCatching { inputFocusRequester.requestFocus() }
1337 runCatching { inputFocusRequester.requestFocus() }
@@ -1255,14 +1339,19 @@ fun ChatScreen(
1342+ // In RTL locales, mirror the swipe direction so the reply
1343+ // gesture reads naturally (swipe from the trailing edge
1344+ // toward the leading edge), matching how LTR users swipe
1345+ // left-to-right. Without this, RTL users had no reply swipe.
1346+ val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1258 SwipeToDismissBox(
1347 SwipeToDismissBox(
1259 state = swipeState,
1348 state = swipeState,
1260− enableDismissFromEndToStart = false,
1349+ enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1261− enableDismissFromStartToEnd = quoteText != null,
1350+ enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
1262 backgroundContent = {
1351 backgroundContent = {
1264 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1353 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1265− contentAlignment = Alignment.CenterStart,
1354+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1268 Icons.AutoMirrored.Filled.Reply,
1357 Icons.AutoMirrored.Filled.Reply,
@@ -1318,6 +1407,12 @@ fun ChatScreen(
1318 onRegenerate = if (!running) {
1407 onRegenerate = if (!running) {
1319 { vm.regenerate(message.info.id) }
1408 { vm.regenerate(message.info.id) }
1320 } else null,
1409 } else null,
1410+ // Continue: resume a partial reply. Shown only for
1411+ // incomplete assistant messages (a partial reply that
1412+ // was aborted) and not while a run is active.
1413+ onContinue = if (!running && message.info is AssistantMessage && !message.info.isComplete) {
1414+ { vm.continueRun(message.info.id) }
1321 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1416 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1322 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1417 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1323 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1418 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1821,7 +1916,99 @@ private fun ShortcutRow(keys: String, desc: String) {
1920+ * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
1921+ * of matching commands above the composer. Tapping a command fires it immediately.
1922+ * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
1923+ * the detekt threshold. Suppressed while running or when the command catalog is empty.
1926+private fun SlashCommandAutocomplete(
1928+ commands: List<Command>,
1930+ onRunCommand: (Command) -> Unit,
1932+ if (running || commands.isEmpty()) return
1933+ val slashQuery = remember(value) {
1934+ if (value.startsWith("/")) value.removePrefix("/") else null
1936+ val matchingCommands = remember(slashQuery, commands) {
1937+ commands.filter { cmd ->
1938+ slashQuery.isEmpty() ||
1939+ cmd.name.contains(slashQuery, ignoreCase = true) ||
1940+ (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
1943+ if (matchingCommands.isNotEmpty()) {
1944+ CommandAutocompleteDropdown(
1945+ commands = matchingCommands,
1946+ onPick = onRunCommand,
1952+ * Slash-command autocomplete dropdown shown above the composer when the draft starts
1953+ * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
1954+ * user can discover and invoke commands by typing the conventional "/" prefix instead
1955+ * of opening the overflow menu / command palette. Tapping a row fires the command.
1958+private fun CommandAutocompleteDropdown(
1959+ commands: List<Command>,
1960+ onPick: (Command) -> Unit,
1962+ val haptics = LocalHapticFeedback.current
1964+ color = MaterialTheme.colorScheme.surface,
1965+ tonalElevation = 3.dp,
1966+ shape = MaterialTheme.shapes.medium,
1967+ modifier = Modifier
1969+ .padding(horizontal = 8.dp, vertical = 4.dp)
1970+ .heightIn(max = 240.dp),
1974+ count = commands.size,
1975+ key = { commands[it].name },
1977+ val cmd = commands[i]
1979+ modifier = Modifier
1981+ .clickable(role = Role.Button) {
1982+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1985+ .padding(horizontal = 16.dp, vertical = 10.dp),
1986+ verticalAlignment = Alignment.CenterVertically,
1987+ horizontalArrangement = Arrangement.spacedBy(12.dp),
1991+ style = MaterialTheme.typography.labelLarge,
1992+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1993+ color = MaterialTheme.colorScheme.primary,
1995+ cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
1998+ style = MaterialTheme.typography.bodySmall,
1999+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2001+ overflow = TextOverflow.Ellipsis,
1824@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2010@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2011+@Suppress("CyclomaticComplexMethod")
1825@Composable
2012@Composable
1826private fun ChatInputBar(
2013private fun ChatInputBar(
1827 value: String,
2014 value: String,
@@ -1845,6 +2032,8 @@ private fun ChatInputBar(
1845 onCamera: () -> Unit,
2032 onCamera: () -> Unit,
1846 onVoice: () -> Unit,
2033 onVoice: () -> Unit,
1847 onPasteImage: (List<Uri>) -> Unit,
2034 onPasteImage: (List<Uri>) -> Unit,
2035+ commands: List<Command> = emptyList(),
2036+ onRunCommand: (Command) -> Unit = {},
1849 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2038 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1850 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2039 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1895,6 +2084,15 @@ private fun ChatInputBar(
1896 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2085 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1897 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2086 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2087+ // Slash-command autocomplete: extracted to a helper to keep this function's
2088+ // complexity under the detekt threshold. Returns no-op when the draft doesn't
2089+ // start with "/" or the command catalog is empty.
2090+ SlashCommandAutocomplete(
2092+ commands = commands,
2094+ onRunCommand = onRunCommand,
1899 modifier = Modifier.fillMaxWidth().padding(8.dp),
2097 modifier = Modifier.fillMaxWidth().padding(8.dp),
1900 verticalAlignment = Alignment.Bottom,
2098 verticalAlignment = Alignment.Bottom,