@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
2929import androidx.compose.foundation.layout.WindowInsets
3030import androidx.compose.foundation.layout.fillMaxSize
3131import androidx.compose.foundation.layout.fillMaxWidth
32+import androidx.compose.foundation.layout.heightIn
3233import androidx.compose.foundation.layout.height
3334import androidx.compose.foundation.layout.imePadding
3435import androidx.compose.foundation.layout.ime
@@ -46,6 +47,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
4647import androidx.compose.material.icons.Icons
4748import androidx.compose.material.icons.automirrored.filled.ArrowBack
4849import androidx.compose.material.icons.automirrored.filled.Send
50+import androidx.compose.material.icons.filled.ErrorOutline
4951import androidx.compose.material.icons.filled.Add
5052import androidx.compose.material.icons.filled.AutoAwesome
5153import androidx.compose.material.icons.filled.Delete
@@ -131,13 +133,17 @@ import androidx.compose.ui.layout.onSizeChanged
131133import androidx.compose.ui.platform.LocalContext
132134import androidx.compose.ui.platform.LocalDensity
133135import androidx.compose.ui.platform.LocalHapticFeedback
136+import androidx.compose.ui.platform.LocalLayoutDirection
134137import androidx.compose.ui.platform.LocalView
135138import androidx.compose.ui.platform.testTag
139+import androidx.compose.ui.unit.LayoutDirection
136140import androidx.compose.ui.res.pluralStringResource
137141import androidx.compose.ui.res.stringResource
138142import androidx.compose.ui.semantics.Role
139143import androidx.compose.ui.semantics.contentDescription
140144import androidx.compose.ui.semantics.heading
145+import androidx.compose.ui.semantics.liveRegion
146+import androidx.compose.ui.semantics.LiveRegionMode
141147import androidx.compose.ui.semantics.invisibleToUser
142148import androidx.compose.ui.semantics.semantics
143149import androidx.compose.ui.text.input.ImeAction
@@ -153,6 +159,8 @@ import kotlinx.coroutines.launch
153159import kotlinx.coroutines.withContext
154160import soy.iko.opencode.data.model.Part
155161import soy.iko.opencode.data.model.MessageWithParts
162+import soy.iko.opencode.data.model.AssistantMessage
163+import soy.iko.opencode.data.model.Command
156164import soy.iko.opencode.data.model.ReasoningPart
157165import soy.iko.opencode.data.model.TextPart
158166import soy.iko.opencode.data.model.ToolCompleted
@@ -197,6 +205,7 @@ fun ChatScreen(
197205 val aborting by vm.aborting.collectAsStateWithLifecycle()
198206 val loading by vm.loading.collectAsStateWithLifecycle()
199207 val loadError by vm.loadError.collectAsStateWithLifecycle()
208+ val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
200209 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
201210 val models by vm.models.collectAsStateWithLifecycle()
202211 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
@@ -226,6 +235,7 @@ fun ChatScreen(
226235 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
227236 val isOnline by container.isOnline.collectAsStateWithLifecycle()
228237 val haptics = LocalHapticFeedback.current
238+ val layoutDirection = LocalLayoutDirection.current
229239 val scope = rememberCoroutineScope()
230240 val shareContext = LocalContext.current
231241 val defaultShareSubject = stringResource(R.string.share_subject)
@@ -849,6 +859,14 @@ fun ChatScreen(
849859 onCamera = { launchCamera() },
850860 onVoice = { launchVoice() },
851861 onPasteImage = { stageUris(it) },
862+ commands = commands,
863+ onRunCommand = { cmd ->
864+ vm.runCommand(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).
868+ vm.updateDraft("")
869+ },
852870 )
853871 }
854872 },
@@ -879,28 +897,47 @@ fun ChatScreen(
879897 val yesterdayLabel = stringResource(R.string.yesterday)
880898 // In-conversation search: when active, narrow to messages whose text contains the query.
881899 // 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.
882904 val searchQuery = chatSearch.trim()
883− val searchMessages = remember(messages, searchQuery) {
884− if (searchQuery.isEmpty()) messages
885− else messages.filter { m ->
886− m.parts.any { p ->
887− when (p) {
888− is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
889− is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
890− is ToolPart -> {
891− // 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
893− // tool touched actually finds the message it appeared in.
894− p.tool.contains(searchQuery, ignoreCase = true) ||
895− (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 ||
897− p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
898− (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
899− p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
905+ val searchMessages by produceState(
906+ initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
907+ messages,
908+ searchQuery,
909+ ) {
910+ if (searchQuery.isEmpty()) {
911+ value = messages
912+ } else {
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) {
920+ messages.filter { m ->
921+ m.parts.any { p ->
922+ when (p) {
923+ is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
924+ is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
925+ is ToolPart -> {
926+ // Match the tool name plus its running/completed/error text and the
927+ // stringified input, so searching for an error string or a file the
928+ // tool touched actually finds the message it appeared in.
929+ p.tool.contains(searchQuery, ignoreCase = true) ||
930+ (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
931+ (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
932+ p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
933+ (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
934+ p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
935+ }
936+ is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
937+ p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
938+ else -> false
939+ }
900940 }
901− is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
902− p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
903− else -> false
904941 }
905942 }
906943 }
@@ -1102,6 +1139,48 @@ fun ChatScreen(
11021139 // separate "Not connected" state below).
11031140 onRetry = { vm.refreshMessages() },
11041141 )
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) {
1149+ Surface(
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 },
1157+ ) {
1158+ Row(
1159+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1160+ verticalAlignment = Alignment.CenterVertically,
1161+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1162+ ) {
1163+ Icon(
1164+ Icons.Filled.ErrorOutline,
1165+ contentDescription = null,
1166+ modifier = Modifier.size(16.dp),
1167+ tint = MaterialTheme.colorScheme.onErrorContainer,
1168+ )
1169+ Text(
1170+ stringResource(R.string.load_error_persistent),
1171+ style = MaterialTheme.typography.labelMedium,
1172+ color = MaterialTheme.colorScheme.onErrorContainer,
1173+ modifier = Modifier.weight(1f),
1174+ )
1175+ TextButton(
1176+ onClick = { vm.refreshMessages() },
1177+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1178+ ) {
1179+ Text(stringResource(R.string.retry))
1180+ }
1181+ }
1182+ }
1183+ }
11051184 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
11061185 // recovery is a swipe away everywhere — not just when the message list is
11071186 // populated. Previously PTR was only around the populated list, leaving the
@@ -1247,7 +1326,12 @@ fun ChatScreen(
12471326 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
12481327 val swipeState = rememberSwipeToDismissBoxState(
12491328 confirmValueChange = { value ->
1250− if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1329+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1330+ SwipeToDismissBoxValue.EndToStart
1331+ } else {
1332+ SwipeToDismissBoxValue.StartToEnd
1333+ }
1334+ if (value == replyValue && quoteText != null) {
12511335 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
12521336 vm.quoteReply(quoteText)
12531337 runCatching { inputFocusRequester.requestFocus() }
@@ -1255,14 +1339,19 @@ fun ChatScreen(
12551339 false
12561340 },
12571341 )
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
12581347 SwipeToDismissBox(
12591348 state = swipeState,
1260− enableDismissFromEndToStart = false,
1261− enableDismissFromStartToEnd = quoteText != null,
1349+ enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1350+ enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
12621351 backgroundContent = {
12631352 Box(
12641353 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1265− contentAlignment = Alignment.CenterStart,
1354+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
12661355 ) {
12671356 Icon(
12681357 Icons.AutoMirrored.Filled.Reply,
@@ -1318,6 +1407,12 @@ fun ChatScreen(
13181407 onRegenerate = if (!running) {
13191408 { vm.regenerate(message.info.id) }
13201409 } 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) }
1415+ } else null,
13211416 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
13221417 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
13231418 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1821,7 +1916,99 @@ private fun ShortcutRow(keys: String, desc: String) {
18211916 }
18221917}
18231918
1919+/**
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.
1924+ */
1925+@Composable
1926+private fun SlashCommandAutocomplete(
1927+ value: String,
1928+ commands: List<Command>,
1929+ running: Boolean,
1930+ onRunCommand: (Command) -> Unit,
1931+) {
1932+ if (running || commands.isEmpty()) return
1933+ val slashQuery = remember(value) {
1934+ if (value.startsWith("/")) value.removePrefix("/") else null
1935+ } ?: return
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)
1941+ }.take(8)
1942+ }
1943+ if (matchingCommands.isNotEmpty()) {
1944+ CommandAutocompleteDropdown(
1945+ commands = matchingCommands,
1946+ onPick = onRunCommand,
1947+ )
1948+ }
1949+}
1950+
1951+/**
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.
1956+ */
1957+@Composable
1958+private fun CommandAutocompleteDropdown(
1959+ commands: List<Command>,
1960+ onPick: (Command) -> Unit,
1961+) {
1962+ val haptics = LocalHapticFeedback.current
1963+ Surface(
1964+ color = MaterialTheme.colorScheme.surface,
1965+ tonalElevation = 3.dp,
1966+ shape = MaterialTheme.shapes.medium,
1967+ modifier = Modifier
1968+ .fillMaxWidth()
1969+ .padding(horizontal = 8.dp, vertical = 4.dp)
1970+ .heightIn(max = 240.dp),
1971+ ) {
1972+ LazyColumn {
1973+ items(
1974+ count = commands.size,
1975+ key = { commands[it].name },
1976+ ) { i ->
1977+ val cmd = commands[i]
1978+ Row(
1979+ modifier = Modifier
1980+ .fillMaxWidth()
1981+ .clickable(role = Role.Button) {
1982+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1983+ onPick(cmd)
1984+ }
1985+ .padding(horizontal = 16.dp, vertical = 10.dp),
1986+ verticalAlignment = Alignment.CenterVertically,
1987+ horizontalArrangement = Arrangement.spacedBy(12.dp),
1988+ ) {
1989+ Text(
1990+ "/${cmd.name}",
1991+ style = MaterialTheme.typography.labelLarge,
1992+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1993+ color = MaterialTheme.colorScheme.primary,
1994+ )
1995+ cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
1996+ Text(
1997+ desc,
1998+ style = MaterialTheme.typography.bodySmall,
1999+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2000+ maxLines = 1,
2001+ overflow = TextOverflow.Ellipsis,
2002+ )
2003+ }
2004+ }
2005+ }
2006+ }
2007+ }
2008+}
2009+
18242010@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2011+@Suppress("CyclomaticComplexMethod")
18252012@Composable
18262013private fun ChatInputBar(
18272014 value: String,
@@ -1845,6 +2032,8 @@ private fun ChatInputBar(
18452032 onCamera: () -> Unit,
18462033 onVoice: () -> Unit,
18472034 onPasteImage: (List<Uri>) -> Unit,
2035+ commands: List<Command> = emptyList(),
2036+ onRunCommand: (Command) -> Unit = {},
18482037) {
18492038 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
18502039 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1895,6 +2084,15 @@ private fun ChatInputBar(
18952084 }
18962085 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
18972086 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(
2091+ value = value,
2092+ commands = commands,
2093+ running = running,
2094+ onRunCommand = onRunCommand,
2095+ )
18982096 Row(
18992097 modifier = Modifier.fillMaxWidth().padding(8.dp),
19002098 verticalAlignment = Alignment.Bottom,