git.iko.soy/opencode-android
on master help sign in
Add 22 UX improvements: tool input/title rendering, friendly model labels, copy-button touch target, UnknownMessage treatment, tappable FileChip, FlowRow suggestion chips, tool-call export, session sorting + stale-preview fallback, probe success feedback + scheme auto-suggestion, server duplicate + last-used timestamps, offline indicator, model picker provider grouping, destructive-command confirmation, find-in-file + wrap toggle, dynamic-color guard + send-on-Enter setting, search dismiss-key actions
commit28832af3081f33e2c296a0da2087be84dfd27dde authoriko <mail@iko.soy> dateWed 1 Jul 2026, 11:24 (3 months ago) parente131d1e
23 files changed+1146−215
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+13−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−3 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+7−2 app/src/main/java/soy/iko/opencode/ui/Routes.kt+3 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+39−15 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+41−1 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+44−3 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+66−9 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+14−6 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+145−27 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+21−12 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+342−118 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+27−3 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+46−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+33 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+24 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+44−8 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+38−5 app/src/main/res/values/strings.xml+25 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+34 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+63 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+62
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+13−1
@@ -12,12 +12,13 @@ enum class ThemeMode { SYSTEM, LIGHT, DARK }
1212
1313private val Context.settingsDataStore by preferencesDataStore(name = "settings")
1414
15−/** App-preferences store: theme mode, dynamic color (Material You). */
15+/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
1616class SettingsStore(context: Context) {
1717
1818 private val appContext = context.applicationContext
1919 private val themeKey = stringPreferencesKey("theme_mode")
2020 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21+ private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
2122
2223 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
2324 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
@@ -28,6 +29,13 @@ class SettingsStore(context: Context) {
2829 prefs[dynamicColorKey] ?: true
2930 }
3031
32+ /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
33+ * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
34+ * true to match the prior hardcoded behavior. */
35+ val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
36+ prefs[sendOnEnterKey] ?: true
37+ }
38+
3139 suspend fun setThemeMode(mode: ThemeMode) {
3240 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
3341 }
@@ -35,4 +43,8 @@ class SettingsStore(context: Context) {
3543 suspend fun setDynamicColor(enabled: Boolean) {
3644 appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
3745 }
46+
47+ suspend fun setSendOnEnter(enabled: Boolean) {
48+ appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
49+ }
3850}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−3
@@ -134,6 +134,12 @@ open class AppContainer private constructor(
134134 private val _anyRunActive = MutableStateFlow(false)
135135 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
136136
137+ /** Whether the device currently has network connectivity. Distinct from the SSE
138+ * connection state so the UI can tell "you're offline" (device) from "server
139+ * unreachable" (credentials/host). Updated by the [networkCallback]. */
140+ private val _isOnline = MutableStateFlow(true)
141+ open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
142+
137143 open fun setCurrentSession(id: String?) {
138144 _currentSession.value = id
139145 if (id != null) {
@@ -326,10 +332,12 @@ open class AppContainer private constructor(
326332 if (appContext == null) return null
327333 val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
328334 ?: return null
335+ // Seed the online state from the current active network so the indicator is
336+ // correct on cold start rather than defaulting to "online" until a callback fires.
337+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
329338 val callback = object : ConnectivityManager.NetworkCallback() {
330− override fun onAvailable(network: Network) {
331− activeConnection.value?.events?.triggerReconnect()
332− }
339+ override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
340+ override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
333341 }
334342 runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
335343 .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+7−2
@@ -82,16 +82,21 @@ fun OpencodeApp(container: AppContainer) {
8282 onConnected = { navController.navigate(Routes.SESSIONS) },
8383 onAddProfile = { navController.navigate(Routes.serverEdit()) },
8484 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
85+ onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
8586 )
8687 }
8788
8889 composable(
89− route = "${Routes.SERVER_EDIT}?id={id}",
90− arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
90+ route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
91+ arguments = listOf(
92+ navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
93+ navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
94+ ),
9195 ) { entry ->
9296 ServerEditScreen(
9397 container = container,
9498 profileId = entry.arguments?.getString("id"),
99+ sourceId = entry.arguments?.getString("dup"),
95100 onDone = { navController.popBackStack() },
96101 )
97102 }
app/src/main/java/soy/iko/opencode/ui/Routes.kt+3
@@ -12,6 +12,9 @@ object Routes {
1212 fun serverEdit(id: String? = null) =
1313 if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
1414
15+ /** Open the editor seeded with [sourceId]'s fields but creating a new profile. */
16+ fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
17+
1518 const val CHAT = "chat"
1619 fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
1720
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+39−15
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
66import androidx.compose.foundation.layout.Arrangement
77import androidx.compose.foundation.layout.Box
88import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.FlowRow
910import androidx.compose.foundation.layout.PaddingValues
1011import androidx.compose.foundation.layout.Row
1112import androidx.compose.foundation.layout.Spacer
@@ -68,6 +69,7 @@ import androidx.compose.ui.Alignment
6869import androidx.compose.ui.Modifier
6970import androidx.compose.ui.input.key.Key
7071import androidx.compose.ui.input.key.KeyEventType
72+import androidx.compose.ui.input.key.isCtrlPressed
7173import androidx.compose.ui.input.key.isShiftPressed
7274import androidx.compose.ui.input.key.key
7375import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -130,6 +132,8 @@ fun ChatScreen(
130132 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
131133 val draft by vm.draft.collectAsStateWithLifecycle()
132134 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
135+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
136+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
133137 val haptics = LocalHapticFeedback.current
134138 val scope = rememberCoroutineScope()
135139 val shareContext = LocalContext.current
@@ -320,6 +324,7 @@ fun ChatScreen(
320324 onValueChange = vm::updateDraft,
321325 running = running,
322326 enabled = activeConnection != null,
327+ sendOnEnter = sendOnEnter,
323328 onSend = ::doSend,
324329 onAbort = { showStopConfirm = true },
325330 queuedFollowUp = queuedFollowUp,
@@ -393,6 +398,7 @@ fun ChatScreen(
393398 ConnectionBanner(
394399 state = connectionState,
395400 modifier = Modifier.align(Alignment.TopCenter),
401+ isOnline = isOnline,
396402 // On a hard failure, retry by forcing an SSE reconnect (which
397403 // re-seeds from REST). refreshMessages is the right recovery path
398404 // when the connection is present but the stream died; reconnect()
@@ -465,7 +471,14 @@ fun ChatScreen(
465471 // Only the last (streaming) message needs isRunning — it drives the
466472 // reasoning-block spinner. Passing the live flag to every bubble
467473 // makes all visible messages recompose whenever a run starts or stops.
468− MessageBubble(message, isRunning = running && message.info.id == lastMessageId, imageContext = imageContext)
474+ val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
475+ ?.let { resolveModelLabel(it, models) }
476+ MessageBubble(
477+ message,
478+ isRunning = running && message.info.id == lastMessageId,
479+ imageContext = imageContext,
480+ modelLabel = modelLabel,
481+ )
469482 }
470483 if (running) {
471484 item(key = "__typing") {
@@ -589,6 +602,7 @@ private fun ChatInputBar(
589602 onValueChange: (String) -> Unit,
590603 running: Boolean,
591604 enabled: Boolean,
605+ sendOnEnter: Boolean,
592606 onSend: () -> Unit,
593607 onAbort: () -> Unit,
594608 queuedFollowUp: String?,
@@ -632,11 +646,15 @@ private fun ChatInputBar(
632646 .weight(1f)
633647 .testTag("chat_input")
634648 .onPreviewKeyEvent { event ->
635− if (event.type == KeyEventType.KeyDown &&
636− event.key == Key.Enter &&
637− !event.isShiftPressed &&
638− enabled && value.isNotBlank()
639− ) {
649+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
650+ // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
651+ // With it off, Enter inserts a newline and Ctrl+Enter sends.
652+ val send = when {
653+ !enabled || value.isBlank() -> false
654+ sendOnEnter -> !event.isShiftPressed
655+ else -> event.isCtrlPressed
656+ }
657+ if (send) {
640658 if (running) onQueueFollowUp(value) else onSend()
641659 true
642660 } else {
@@ -690,6 +708,7 @@ private fun ChatInputBar(
690708 }
691709}
692710
711+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
693712@Composable
694713private fun EmptyConversation(
695714 onSuggestion: (String) -> Unit,
@@ -727,15 +746,20 @@ private fun EmptyConversation(
727746 color = MaterialTheme.colorScheme.onSurfaceVariant,
728747 )
729748 Spacer(Modifier.size(20.dp))
730− // Suggestion chips wrap across multiple rows on narrow screens. FlowRow-like
731− // behavior via a plain Column of Rows keeps the dependency surface zero.
732− suggestions.forEach { resId ->
733− val text = stringResource(resId)
734− androidx.compose.material3.AssistChip(
735− onClick = { onSuggestion(text) },
736− label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
737− modifier = Modifier.padding(vertical = 4.dp),
738− )
749+ // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
750+ // so a longer localized prompt doesn't push the chips off-screen on a phone.
751+ FlowRow(
752+ modifier = Modifier.fillMaxWidth(),
753+ horizontalArrangement = Arrangement.spacedBy(8.dp),
754+ verticalArrangement = Arrangement.spacedBy(8.dp),
755+ ) {
756+ suggestions.forEach { resId ->
757+ val text = stringResource(resId)
758+ androidx.compose.material3.AssistChip(
759+ onClick = { onSuggestion(text) },
760+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
761+ )
762+ }
739763 }
740764 }
741765}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+41−1
@@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
1818import androidx.compose.material3.ModalBottomSheet
1919import androidx.compose.material3.OutlinedTextField
2020import androidx.compose.material3.Text
21+import androidx.compose.material3.AlertDialog
2122import androidx.compose.material3.TextButton
2223import androidx.compose.runtime.Composable
2324import androidx.compose.runtime.getValue
@@ -49,6 +50,7 @@ fun CommandPickerSheet(
4950 onRetry: () -> Unit,
5051 onDismiss: () -> Unit,
5152) {
53+ var pendingCommand by rememberSaveable { mutableStateOf<Command?>(null) }
5254 ModalBottomSheet(onDismissRequest = onDismiss) {
5355 Text(
5456 stringResource(R.string.commands),
@@ -114,7 +116,14 @@ fun CommandPickerSheet(
114116 Column(
115117 modifier = Modifier
116118 .fillMaxWidth()
117− .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
119+ .clickable(role = Role.Button) {
120+ if (isDestructiveCommand(cmd.name)) {
121+ pendingCommand = cmd
122+ } else {
123+ onSelect(cmd)
124+ onDismiss()
125+ }
126+ }
118127 .padding(horizontal = 24.dp, vertical = 12.dp),
119128 ) {
120129 androidx.compose.foundation.layout.Row(
@@ -141,4 +150,35 @@ fun CommandPickerSheet(
141150 }
142151 }
143152 }
153+
154+ // Confirmation for commands whose name suggests they may discard work or reset state,
155+ // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
156+ pendingCommand?.let { cmd ->
157+ AlertDialog(
158+ onDismissRequest = { pendingCommand = null },
159+ title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
160+ text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
161+ confirmButton = {
162+ TextButton(onClick = {
163+ pendingCommand = null
164+ onSelect(cmd)
165+ onDismiss()
166+ }) { Text(stringResource(R.string.run)) }
167+ },
168+ dismissButton = {
169+ TextButton(onClick = { pendingCommand = null }) { Text(stringResource(R.string.cancel)) }
170+ },
171+ )
172+ }
173+}
174+
175+/**
176+ * Heuristic for commands that may be destructive (discard work / reset state) and so
177+ * warrant a confirmation step before invocation. Matches common opencode slash-command
178+ * names; non-matching commands invoke immediately.
179+ */
180+private fun isDestructiveCommand(name: String): Boolean {
181+ val n = name.lowercase()
182+ return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
183+ n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
144184}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+44−3
@@ -4,13 +4,17 @@ import soy.iko.opencode.data.model.AssistantMessage
44import soy.iko.opencode.data.model.MessageWithParts
55import soy.iko.opencode.data.model.ReasoningPart
66import soy.iko.opencode.data.model.TextPart
7+import soy.iko.opencode.data.model.ToolCompleted
8+import soy.iko.opencode.data.model.ToolError
9+import soy.iko.opencode.data.model.ToolPart
10+import soy.iko.opencode.data.model.ToolRunning
711import soy.iko.opencode.data.model.UserMessage
812
913/**
1014 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
11− * role-prefixed section containing its text parts; reasoning and tool parts are
12− * omitted to keep the exported transcript readable. Pure (no Android deps) so it can
13− * be unit-tested directly.
15+ * role-prefixed section containing its text parts; reasoning is exported as a blockquote
16+ * and tool calls are summarized so the transcript shows what the agent *did*, not just
17+ * what it said. Pure (no Android deps) so it can be unit-tested directly.
1418 */
1519fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
1620 val sb = StringBuilder(messages.size * 128)
@@ -28,6 +32,7 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
2832 when (part) {
2933 is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
3034 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "> _${escapeMarkdown(it)}_" }
35+ is ToolPart -> formatToolCall(part)
3136 else -> null
3237 }
3338 }
@@ -39,6 +44,42 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
3944 return sb.toString().trimEnd()
4045}
4146
47+/** Render a tool call as a compact, readable blockquote summary: the tool name, its
48+ * human-readable title (if any), and a truncated output/error. Keeps the exported
49+ * transcript useful when the agent's work (edits, command runs) is the substance. */
50+private fun formatToolCall(part: ToolPart): String? {
51+ val title = when (val s = part.state) {
52+ is ToolRunning -> s.title
53+ is ToolCompleted -> s.title
54+ else -> null
55+ }
56+ val detail = when (val s = part.state) {
57+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
58+ is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
59+ else -> null
60+ }
61+ val status = when (part.state) {
62+ is ToolCompleted -> null
63+ is ToolError -> " — error"
64+ is ToolRunning -> " — running"
65+ else -> null
66+ }
67+ val head = "**${part.tool}**${status ?: ""}"
68+ val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
69+ return buildList {
70+ add("> $head")
71+ if (titleLine != null) add("> $titleLine")
72+ if (detail != null) add(">\n> ```\n$detail\n```")
73+ }.joinToString("\n").takeIf { it.isNotBlank() }
74+}
75+
76+private fun truncateOutput(output: String): String {
77+ if (output.length <= TOOL_OUTPUT_LIMIT) return output
78+ return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
79+}
80+
81+private const val TOOL_OUTPUT_LIMIT = 2000
82+
4283/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
4384private fun escapeMarkdown(text: String): String {
4485 if (text.isEmpty()) return text
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+66−9
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.HelpOutline
1112import androidx.compose.material.icons.filled.ContentCopy
1213import androidx.compose.material3.Icon
1314import androidx.compose.material3.IconButton
@@ -24,14 +25,30 @@ import androidx.compose.ui.unit.dp
2425import soy.iko.opencode.R
2526import soy.iko.opencode.data.model.AssistantMessage
2627import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.ModelOption
2729import soy.iko.opencode.data.model.TextPart
2830import soy.iko.opencode.data.model.Tokens
31+import soy.iko.opencode.data.model.UnknownMessage
2932import soy.iko.opencode.data.model.UserMessage
3033import soy.iko.opencode.data.network.NetworkConfig
3134import soy.iko.opencode.ui.components.ImageLoadContext
3235import soy.iko.opencode.ui.components.copyToClipboard
3336import soy.iko.opencode.ui.components.rememberRelativeTime
3437
38+/**
39+ * Resolve an assistant message's model id to a friendly label from the loaded catalog,
40+ * falling back to the raw id (so a model not in the catalog still shows something
41+ * meaningful instead of being hidden). Returns null only when the message has no model.
42+ */
43+fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
44+ val id = info.modelID ?: return null
45+ if (models.isEmpty()) return id
46+ val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
47+ return byPair?.modelLabel
48+ ?: models.firstOrNull { it.modelID == id }?.modelLabel
49+ ?: id
50+}
51+
3552/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
3653@Composable
3754fun MessageBubble(
@@ -39,10 +56,42 @@ fun MessageBubble(
3956 modifier: Modifier = Modifier,
4057 isRunning: Boolean = false,
4158 imageContext: ImageLoadContext? = null,
59+ modelLabel: String? = null,
4260) {
4361 when (message.info) {
4462 is UserMessage -> UserBubble(message, imageContext, modifier)
45− else -> AssistantBlock(message, isRunning, imageContext, modifier)
63+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier)
64+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel)
65+ }
66+}
67+
68+@Composable
69+private fun UnknownMessageBlock(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
70+ // Forward-compat: a role the client doesn't model. Render a muted note so the user
71+ // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
72+ // server attached, so content isn't silently dropped.
73+ Column(
74+ modifier = modifier.fillMaxWidth(),
75+ verticalArrangement = Arrangement.spacedBy(8.dp),
76+ ) {
77+ Row(verticalAlignment = Alignment.CenterVertically) {
78+ Icon(
79+ Icons.AutoMirrored.Filled.HelpOutline,
80+ contentDescription = null,
81+ modifier = Modifier.size(14.dp),
82+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
83+ )
84+ Text(
85+ stringResource(R.string.unknown_message),
86+ style = MaterialTheme.typography.labelSmall,
87+ color = MaterialTheme.colorScheme.onSurfaceVariant,
88+ modifier = Modifier.padding(start = 6.dp),
89+ )
90+ }
91+ for (part in message.parts) {
92+ PartView(part, imageContext = imageContext)
93+ }
94+ MessageTimestampText(message.info)
4695 }
4796}
4897
@@ -80,7 +129,6 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
80129 if (textToCopy != null) {
81130 IconButton(
82131 onClick = { copyToClipboard(context, "message", textToCopy) },
83− modifier = Modifier.size(20.dp),
84132 ) {
85133 Icon(
86134 Icons.Filled.ContentCopy,
@@ -96,18 +144,27 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
96144}
97145
98146@Composable
99−private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageContext: ImageLoadContext?, modifier: Modifier) {
147+private fun AssistantBlock(
148+ message: MessageWithParts,
149+ isRunning: Boolean,
150+ imageContext: ImageLoadContext?,
151+ modifier: Modifier,
152+ modelLabel: String? = null,
153+) {
100154 Column(
101155 modifier = modifier.fillMaxWidth(),
102156 verticalArrangement = Arrangement.spacedBy(8.dp),
103157 ) {
104158 val info = message.info
105− if (info is AssistantMessage && info.modelID != null) {
106− Text(
107− info.modelID,
108− style = MaterialTheme.typography.labelSmall,
109− color = MaterialTheme.colorScheme.onSurfaceVariant,
110− )
159+ if (info is AssistantMessage) {
160+ val label = modelLabel ?: info.modelID
161+ if (label != null) {
162+ Text(
163+ label,
164+ style = MaterialTheme.typography.labelSmall,
165+ color = MaterialTheme.colorScheme.onSurfaceVariant,
166+ )
167+ }
111168 }
112169 for (part in message.parts) {
113170 PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+14−6
@@ -113,8 +113,20 @@ fun ModelPickerSheet(
113113 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
114114 )
115115 } else {
116+ // Group by provider so a long catalog is scannable: a provider header
117+ // followed by its models, instead of a flat list mixing providers.
118+ val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
116119 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
117− items(filtered, key = { it.providerID to it.modelID }) { option ->
120+ grouped.forEach { (providerID, opts) ->
121+ item(key = "header_$providerID") {
122+ Text(
123+ opts.firstOrNull()?.providerLabel ?: providerID,
124+ style = MaterialTheme.typography.labelMedium,
125+ color = MaterialTheme.colorScheme.primary,
126+ modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
127+ )
128+ }
129+ items(opts, key = { it.providerID to it.modelID }) { option ->
118130 val isSelected = option.providerID == selected?.providerID &&
119131 option.modelID == selected?.modelID
120132 Column(
@@ -145,13 +157,9 @@ fun ModelPickerSheet(
145157 )
146158 }
147159 }
148− Text(
149− option.providerLabel,
150− style = MaterialTheme.typography.bodySmall,
151− color = MaterialTheme.colorScheme.onSurfaceVariant,
152− )
153160 }
154161 }
162+ }
155163 }
156164 }
157165 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+145−27
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Description
1818import androidx.compose.material.icons.filled.Error
1919import androidx.compose.material.icons.filled.ExpandLess
2020import androidx.compose.material.icons.filled.ExpandMore
21+import androidx.compose.material.icons.filled.HelpOutline
2122import androidx.compose.material.icons.filled.Psychology
2223import androidx.compose.material3.CircularProgressIndicator
2324import androidx.compose.material3.Icon
@@ -42,6 +43,8 @@ import androidx.compose.ui.semantics.stateDescription
4243import androidx.compose.ui.text.font.FontFamily
4344import androidx.compose.ui.res.stringResource
4445import androidx.compose.ui.unit.dp
46+import kotlinx.serialization.json.Json
47+import kotlinx.serialization.json.JsonElement
4548import soy.iko.opencode.data.model.FilePart
4649import soy.iko.opencode.data.model.Part
4750import soy.iko.opencode.data.model.ReasoningPart
@@ -63,10 +66,30 @@ import soy.iko.opencode.ui.components.RemoteImage
6366import soy.iko.opencode.ui.components.copyToClipboard
6467import soy.iko.opencode.ui.components.isImage
6568import soy.iko.opencode.ui.components.looksLikeDiff
69+import soy.iko.opencode.ui.components.showToast
6670import soy.iko.opencode.R
6771
6872private const val COLLAPSED_LIMIT = 600
6973
74+// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
75+// resilient decoding) so this stays human-readable; constructed lazily and memoized.
76+private val prettyJson: Json = Json { prettyPrint = true }
77+
78+/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
79+private fun ToolState.titleText(): String? = when (this) {
80+ is ToolRunning -> title
81+ is ToolCompleted -> title
82+ else -> null
83+}
84+
85+/** Extract the raw input arguments of a tool call, if any. */
86+private fun ToolState.inputJson(): JsonElement? = when (this) {
87+ is ToolRunning -> input
88+ is ToolCompleted -> input
89+ is ToolError -> input
90+ else -> null
91+}
92+
7093/**
7194 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
7295 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
@@ -152,6 +175,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
152175 }
153176}
154177
178+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
155179@Composable
156180private fun ToolCallView(part: ToolPart, modifier: Modifier) {
157181 val context = LocalContext.current
@@ -171,6 +195,31 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
171195 modifier = Modifier.padding(start = 6.dp),
172196 )
173197 }
198+ // A human-readable summary of what the tool was asked to do (e.g. "Reading
199+ // src/main.kt"), when the server provides one. Sits directly under the tool
200+ // name so the user understands the call before diving into input/output.
201+ part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
202+ Text(
203+ title,
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ modifier = Modifier.padding(start = 22.dp, top = 2.dp),
207+ )
208+ }
209+ // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
210+ // output so the call reads top-to-bottom: name → what → input → result.
211+ part.state.inputJson()?.let { input ->
212+ val inputLabel = stringResource(R.string.tool_input)
213+ val pretty = remember(input) {
214+ runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
215+ }
216+ CollapsibleDetail(
217+ label = inputLabel,
218+ detail = pretty,
219+ isDiff = false,
220+ keySuffix = "input",
221+ )
222+ }
174223 val detail = when (val s = part.state) {
175224 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
176225 is ToolError -> s.error ?: stringResource(R.string.error_generic)
@@ -185,38 +234,97 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
185234 // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
186235 // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
187236 val isDiff = remember(display) { looksLikeDiff(display) }
188− if (isDiff) {
189− DiffView(display)
190− } else {
237+ CollapsibleDetail(
238+ label = null,
239+ detail = display,
240+ isDiff = isDiff,
241+ keySuffix = "output",
242+ expanded = expanded || detail.length <= COLLAPSED_LIMIT,
243+ onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
244+ { expanded = !expanded }
245+ } else null,
246+ expandedState = expandedState,
247+ collapsedState = collapsedState,
248+ onCopy = { copyToClipboard(context, "output", detail) },
249+ )
250+ }
251+ }
252+}
253+
254+/**
255+ * A collapsible monospace detail block shared by tool input and output. [label] is an
256+ * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
257+ * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
258+ * with an expand/collapse affordance when [onToggleExpand] is supplied.
259+ */
260+@Composable
261+private fun CollapsibleDetail(
262+ label: String?,
263+ detail: String,
264+ isDiff: Boolean,
265+ keySuffix: String,
266+ expanded: Boolean = false,
267+ onToggleExpand: (() -> Unit)? = null,
268+ expandedState: String = "",
269+ collapsedState: String = "",
270+ onCopy: (() -> Unit)? = null,
271+) {
272+ Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
273+ if (label != null) {
274+ var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
275+ Row(
276+ modifier = Modifier
277+ .fillMaxWidth()
278+ .defaultMinSize(minHeight = 48.dp)
279+ .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
280+ .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
281+ verticalAlignment = Alignment.CenterVertically,
282+ ) {
283+ Icon(
284+ if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
285+ contentDescription = null,
286+ modifier = Modifier.size(16.dp),
287+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
288+ )
191289 Text(
192− display,
193− style = MaterialTheme.typography.bodySmall,
194− fontFamily = FontFamily.Monospace,
195− modifier = Modifier.padding(top = 6.dp),
290+ label,
291+ style = MaterialTheme.typography.labelSmall,
292+ color = MaterialTheme.colorScheme.onSurfaceVariant,
293+ modifier = Modifier.padding(start = 4.dp),
196294 )
197295 }
198− if (detail.length > COLLAPSED_LIMIT) {
199− TextButton(
200− onClick = { expanded = !expanded },
201− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
202− modifier = Modifier.semantics {
203− stateDescription = if (expanded) expandedState else collapsedState
204− },
205− ) {
206− Icon(
207− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
208− contentDescription = null,
209− modifier = Modifier.size(16.dp),
210− )
211− Text(
212− if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
213− style = MaterialTheme.typography.labelSmall,
214− modifier = Modifier.padding(start = 4.dp),
215− )
216− }
296+ if (!inputExpanded) return@Column
297+ }
298+ if (isDiff) {
299+ DiffView(detail)
300+ } else {
301+ Text(
302+ detail,
303+ style = MaterialTheme.typography.bodySmall,
304+ fontFamily = FontFamily.Monospace,
305+ )
306+ }
307+ if (onToggleExpand != null) {
308+ TextButton(
309+ onClick = onToggleExpand,
310+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
311+ modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
312+ ) {
313+ Icon(
314+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
315+ contentDescription = null,
316+ modifier = Modifier.size(16.dp),
317+ )
318+ Text(
319+ if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
320+ style = MaterialTheme.typography.labelSmall,
321+ modifier = Modifier.padding(start = 4.dp),
322+ )
217323 }
324+ }
325+ if (onCopy != null) {
218326 TextButton(
219− onClick = { copyToClipboard(context, "output", detail) },
327+ onClick = onCopy,
220328 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
221329 modifier = Modifier.semantics(mergeDescendants = true) {},
222330 ) {
@@ -262,10 +370,20 @@ private fun ToolStatusIcon(state: ToolState) {
262370
263371@Composable
264372private fun FileChip(part: FilePart, modifier: Modifier) {
373+ val context = LocalContext.current
374+ val path = part.source ?: part.url ?: part.filename
375+ val copyLabel = stringResource(R.string.copy_path)
265376 Row(
266377 modifier = modifier
267378 .clip(RoundedCornerShape(12.dp))
268379 .background(MaterialTheme.colorScheme.surfaceVariant)
380+ .clickable(role = Role.Button) {
381+ if (!path.isNullOrBlank()) {
382+ copyToClipboard(context, "path", path)
383+ showToast(context, context.getString(R.string.path_copied))
384+ }
385+ }
386+ .semantics { contentDescription = copyLabel }
269387 .padding(horizontal = 12.dp, vertical = 6.dp),
270388 verticalAlignment = Alignment.CenterVertically,
271389 ) {
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+21−12
@@ -38,23 +38,32 @@ import soy.iko.opencode.data.network.EventStreamClient
3838fun ConnectionBanner(
3939 state: EventStreamClient.ConnectionState,
4040 modifier: Modifier = Modifier,
41+ isOnline: Boolean = true,
4142 onRetry: (() -> Unit)? = null,
4243) {
43− val text = when (state) {
44− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
45− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
46− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
47− EventStreamClient.ConnectionState.Connected -> null
44+ // When the device itself has no connectivity, surface that distinctly (instead of
45+ // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
46+ // credentials", neither of which reflects the real problem.
47+ val text = if (!isOnline) {
48+ stringResource(R.string.offline)
49+ } else {
50+ when (state) {
51+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
52+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
53+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
54+ EventStreamClient.ConnectionState.Connected -> null
55+ }
4856 }
57+ val isOffline = !isOnline
4958 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
5059 // and disappearing instantly — a state change that's especially jarring when the
5160 // connection flaps, and was called out as a rough edge in the UX audit.
5261 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
5362 if (text == null) return@AnimatedVisibility
54− // Distinguish a hard failure (e.g. bad credentials) from transient
55− // connecting/reconnecting states by switching to the error palette, so
56− // the banner conveys urgency without relying on text alone.
57− val isFailed = state == EventStreamClient.ConnectionState.Failed
63+ // Distinguish a hard failure (e.g. bad credentials) and an offline device from
64+ // transient connecting/reconnecting states by switching to the error palette,
65+ // so the banner conveys urgency without relying on text alone.
66+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
5867 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
5968 else MaterialTheme.colorScheme.tertiaryContainer
6069 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
@@ -91,9 +100,9 @@ fun ConnectionBanner(
91100 )
92101 // Inline retry on the Failed banner so the user can recover without
93102 // leaving the screen. Hidden during transient states (the system is
94− // already reconnecting) and when no callback is wired (callers that
95− // don't have a reconnect path, e.g. the file browser).
96− if (isFailed && onRetry != null) {
103+ // already reconnecting), when offline (retry can't help), and when no
104+ // callback is wired (callers that don't have a reconnect path).
105+ if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
97106 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
98107 Text(stringResource(R.string.retry_now), color = onContainer)
99108 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size
1414import androidx.compose.foundation.lazy.LazyColumn
1515import androidx.compose.foundation.lazy.items
1616import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.KeyboardActions
1718import androidx.compose.foundation.text.KeyboardOptions
1819import androidx.compose.material.icons.Icons
1920import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -41,6 +42,7 @@ import androidx.compose.runtime.remember
4142import androidx.compose.ui.Alignment
4243import androidx.compose.ui.Modifier
4344import androidx.compose.ui.graphics.Color
45+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
4446import androidx.compose.ui.platform.testTag
4547import androidx.compose.ui.semantics.Role
4648import androidx.compose.ui.semantics.contentDescription
@@ -96,6 +98,7 @@ fun FileBrowserScreen(
9698 onNavigate = vm::open,
9799 modifier = Modifier.fillMaxWidth(),
98100 )
101+ val keyboardController = LocalSoftwareKeyboardController.current
99102 OutlinedTextField(
100103 value = state.query,
101104 onValueChange = vm::setQuery,
@@ -105,6 +108,7 @@ fun FileBrowserScreen(
105108 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
106109 singleLine = true,
107110 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
108112 )
109113
110114 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+342−118
@@ -7,23 +7,36 @@ import androidx.compose.animation.core.tween
77import androidx.compose.animation.fadeIn
88import androidx.compose.animation.fadeOut
99import androidx.compose.animation.togetherWith
10+import androidx.compose.foundation.background
1011import androidx.compose.foundation.horizontalScroll
1112import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.BoxScope
1214import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Arrangement
1316import androidx.compose.foundation.layout.Row
1417import androidx.compose.foundation.layout.Spacer
1518import androidx.compose.foundation.layout.fillMaxSize
19+import androidx.compose.foundation.layout.fillMaxWidth
1620import androidx.compose.foundation.layout.padding
1721import androidx.compose.foundation.layout.size
1822import androidx.compose.foundation.layout.width
1923import androidx.compose.foundation.lazy.LazyColumn
24+import androidx.compose.foundation.lazy.LazyListState
2025import androidx.compose.foundation.lazy.itemsIndexed
26+import androidx.compose.foundation.lazy.rememberLazyListState
2127import androidx.compose.foundation.rememberScrollState
28+import androidx.compose.foundation.text.KeyboardActions
29+import androidx.compose.foundation.text.KeyboardOptions
2230import androidx.compose.foundation.verticalScroll
2331import androidx.compose.material.icons.Icons
2432import androidx.compose.material.icons.automirrored.filled.ArrowBack
33+import androidx.compose.material.icons.automirrored.filled.WrapText
34+import androidx.compose.material.icons.filled.Close
2535import androidx.compose.material.icons.filled.ContentCopy
36+import androidx.compose.material.icons.filled.KeyboardArrowDown
37+import androidx.compose.material.icons.filled.KeyboardArrowUp
2638import androidx.compose.material.icons.filled.Refresh
39+import androidx.compose.material.icons.filled.Search
2740import androidx.compose.material.icons.filled.Share
2841import androidx.compose.material3.CircularProgressIndicator
2942import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,12 +44,15 @@ import androidx.compose.material3.FilterChip
3144import androidx.compose.material3.Icon
3245import androidx.compose.material3.IconButton
3346import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.OutlinedTextField
3448import androidx.compose.material3.Scaffold
3549import androidx.compose.material3.Text
3650import androidx.compose.material3.TextButton
3751import androidx.compose.material3.TopAppBar
3852import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.LaunchedEffect
3954import androidx.compose.runtime.getValue
55+import androidx.compose.runtime.mutableIntStateOf
4056import androidx.compose.runtime.mutableStateOf
4157import androidx.compose.runtime.remember
4258import androidx.compose.runtime.saveable.rememberSaveable
@@ -47,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
4763import androidx.compose.ui.semantics.contentDescription
4864import androidx.compose.ui.semantics.semantics
4965import androidx.compose.ui.text.font.FontFamily
66+import androidx.compose.ui.text.input.ImeAction
5067import androidx.compose.ui.text.style.TextOverflow
5168import androidx.compose.ui.unit.dp
5269import androidx.compose.ui.res.stringResource
@@ -71,167 +88,374 @@ fun FileViewScreen(
7188 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
7289 val state by vm.state.collectAsStateWithLifecycle()
7390 val context = LocalContext.current
74− // Trim a trailing slash so "dir/" doesn't yield an empty filename; fall back to the
75− // raw path if there's no basename at all (e.g. the root "/").
7691 val filename = remember(path) {
7792 val trimmed = path.trimEnd('/')
7893 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
7994 }
8095 val shareLabel = stringResource(R.string.share)
81− // Diff vs raw view toggle. Defaults to showing the diff when one exists (the common
82− // case a developer opens a file for); the user can switch to the raw new content to
83− // see the full file without +/- markers. Persisted across recomposition via
84− // rememberSaveable so a rotation doesn't reset the user's choice.
96+ // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
97+ // switch to the raw new content. Persisted via rememberSaveable across rotation.
8598 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
8699 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
87100 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
101+ // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
102+ // user's query/mode.
103+ var findActive by rememberSaveable(path) { mutableStateOf(false) }
104+ var findQuery by rememberSaveable(path) { mutableStateOf("") }
105+ var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
106+ var wrap by rememberSaveable(path) { mutableStateOf(false) }
107+ val listState = rememberLazyListState()
108+ val rawText = state.content?.content.orEmpty()
109+ val lines = remember(rawText) { rawText.split("\n") }
110+ val matchIndices = remember(lines, findQuery) {
111+ val q = findQuery.trim()
112+ if (q.isEmpty()) emptyList()
113+ else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
114+ .filter { it >= 0 }
115+ }
116+ // Scroll to the current match whenever it (or the match set) changes.
117+ LaunchedEffect(matchPos, matchIndices) {
118+ matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
119+ }
88120
89121 Scaffold(
90122 topBar = {
91− TopAppBar(
92− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
93− navigationIcon = {
94− IconButton(onClick = onBack) {
95− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
96− }
97− },
98− actions = {
99− // In-place refresh: a file's content can change on the server (the
100− // agent may be editing it), so reload without requiring a back-out.
101− // Disabled while loading to avoid stacking parallel fetches.
102− IconButton(onClick = { vm.reload() }, enabled = !state.loading) {
103− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
104− }
105− val content = state.content?.content.orEmpty()
106− if (content.isNotEmpty()) {
107− IconButton(onClick = { copyToClipboard(context, filename, content) }) {
108− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
109− }
110− IconButton(onClick = {
111− val send = Intent(Intent.ACTION_SEND).apply {
112− type = "text/plain"
113− putExtra(Intent.EXTRA_SUBJECT, filename)
114− putExtra(Intent.EXTRA_TEXT, content)
115− }
116− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
117− .onFailure {
118− showToast(context, context.getString(R.string.no_share_app))
119− }
120− }) {
121− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
122− }
123+ FileViewTopBar(
124+ filename = filename,
125+ loading = state.loading,
126+ content = rawText,
127+ wrap = wrap,
128+ onBack = onBack,
129+ onReload = { vm.reload() },
130+ onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
131+ onToggleWrap = { wrap = !wrap },
132+ onCopy = { copyToClipboard(context, filename, rawText) },
133+ onShare = {
134+ val send = Intent(Intent.ACTION_SEND).apply {
135+ type = "text/plain"
136+ putExtra(Intent.EXTRA_SUBJECT, filename)
137+ putExtra(Intent.EXTRA_TEXT, rawText)
123138 }
139+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
140+ .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
124141 },
125142 )
126143 },
127144 ) { padding ->
128145 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
129− // AnimatedContent cross-fades between the loading / error / content states
130− // so the viewer doesn't snap abruptly when a reload finishes or fails. The
131− // transition is short (180ms) and fade-only to avoid a slide that would
132− // disorient when the content is the same file just re-fetched.
146+ // AnimatedContent cross-fades between loading / error / content states so the
147+ // viewer doesn't snap abruptly when a reload finishes or fails.
133148 AnimatedContent(
134149 targetState = state,
135150 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
136151 label = "fileViewState",
137152 ) { s ->
138− when {
139− s.loading -> {
140− val loadingLabel = stringResource(R.string.loading)
141− CircularProgressIndicator(
142− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
143− )
144− }
145− s.error != null -> Column(
146− modifier = Modifier.align(Alignment.Center).padding(24.dp),
147− horizontalAlignment = Alignment.CenterHorizontally,
148− ) {
149− Text(
150− s.error ?: "",
151− color = MaterialTheme.colorScheme.error,
152− )
153− Spacer(Modifier.size(12.dp))
154− TextButton(onClick = { vm.reload() }) {
155− Text(stringResource(R.string.retry))
156− }
157− }
158− s.content?.isBinary == true -> Text(
159− stringResource(R.string.binary_file),
160− modifier = Modifier.align(Alignment.Center).padding(24.dp),
161− color = MaterialTheme.colorScheme.onSurfaceVariant,
153+ FileViewStateContent(
154+ state = s,
155+ filename = filename,
156+ showToggle = showToggle,
157+ showDiff = showDiff,
158+ onSetShowDiff = { showDiff = it },
159+ findActive = findActive,
160+ findQuery = findQuery,
161+ onQueryChange = { findQuery = it; matchPos = 0 },
162+ matchIndices = matchIndices,
163+ matchPos = matchPos,
164+ onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
165+ onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
166+ onCloseFind = { findActive = false; findQuery = "" },
167+ wrap = wrap,
168+ listState = listState,
169+ onRetry = { vm.reload() },
170+ )
171+ }
172+ }
173+ }
174+}
175+
176+@OptIn(ExperimentalMaterial3Api::class)
177+@Composable
178+private fun FileViewTopBar(
179+ filename: String,
180+ loading: Boolean,
181+ content: String,
182+ wrap: Boolean,
183+ onBack: () -> Unit,
184+ onReload: () -> Unit,
185+ onToggleFind: () -> Unit,
186+ onToggleWrap: () -> Unit,
187+ onCopy: () -> Unit,
188+ onShare: () -> Unit,
189+) {
190+ TopAppBar(
191+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
192+ navigationIcon = {
193+ IconButton(onClick = onBack) {
194+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
195+ }
196+ },
197+ actions = {
198+ // In-place refresh: a file's content can change on the server (the agent may
199+ // be editing it), so reload without requiring a back-out.
200+ IconButton(onClick = onReload, enabled = !loading) {
201+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
202+ }
203+ if (content.isNotEmpty()) {
204+ // Find-in-file: toggles a search bar over the raw content.
205+ IconButton(onClick = onToggleFind) {
206+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
207+ }
208+ // Wrap long lines instead of horizontal-scrolling, useful for prose.
209+ IconButton(onClick = onToggleWrap) {
210+ Icon(
211+ Icons.AutoMirrored.Filled.WrapText,
212+ contentDescription = stringResource(R.string.wrap_lines),
213+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
162214 )
163− else -> {
164− val content = s.content
165− // Diff/raw toggle: when both a diff and raw content are present,
166− // let the user switch. The chip row sits above the content so
167− // it's always reachable while scrolling.
168− if (showToggle) {
169− Row(
170− modifier = Modifier
171− .align(Alignment.TopCenter)
172− .padding(top = 4.dp),
173− horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
174− ) {
175− FilterChip(
176− selected = showDiff,
177− onClick = { showDiff = true },
178− label = { Text(stringResource(R.string.show_diff)) },
179− )
180− FilterChip(
181− selected = !showDiff,
182− onClick = { showDiff = false },
183− label = { Text(stringResource(R.string.show_raw)) },
184− )
185− }
186− }
187− if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
188− DiffView(
189− diff = content.diff,
190− modifier = Modifier
191− .fillMaxSize()
192− .padding(top = if (showToggle) 48.dp else 0.dp)
193− .verticalScroll(rememberScrollState())
194− .padding(8.dp),
195− )
196− } else {
197− val text = content?.content.orEmpty()
198− if (text.isEmpty()) {
199− Text(
200− stringResource(R.string.empty_file),
201− modifier = Modifier.align(Alignment.Center).padding(24.dp),
202− color = MaterialTheme.colorScheme.onSurfaceVariant,
203− )
204− } else {
205− FileTextContent(
206− text = text,
207− filename = filename,
208− topInset = if (showToggle) 48.dp else 0.dp,
209− )
210− }
211− }
212− }
215+ }
216+ IconButton(onClick = onCopy) {
217+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
218+ }
219+ IconButton(onClick = onShare) {
220+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
213221 }
214222 }
223+ },
224+ )
225+}
226+
227+@Composable
228+private fun BoxScope.FileViewStateContent(
229+ state: FileViewState,
230+ filename: String,
231+ showToggle: Boolean,
232+ showDiff: Boolean,
233+ onSetShowDiff: (Boolean) -> Unit,
234+ findActive: Boolean,
235+ findQuery: String,
236+ onQueryChange: (String) -> Unit,
237+ matchIndices: List<Int>,
238+ matchPos: Int,
239+ onPrev: () -> Unit,
240+ onNext: () -> Unit,
241+ onCloseFind: () -> Unit,
242+ wrap: Boolean,
243+ listState: LazyListState,
244+ onRetry: () -> Unit,
245+) {
246+ when {
247+ state.loading -> {
248+ val loadingLabel = stringResource(R.string.loading)
249+ CircularProgressIndicator(
250+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
251+ )
252+ }
253+ state.error != null -> Column(
254+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
255+ horizontalAlignment = Alignment.CenterHorizontally,
256+ ) {
257+ Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
258+ Spacer(Modifier.size(12.dp))
259+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
260+ }
261+ state.content?.isBinary == true -> Text(
262+ stringResource(R.string.binary_file),
263+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
264+ color = MaterialTheme.colorScheme.onSurfaceVariant,
265+ )
266+ else -> FileViewContentBody(
267+ content = state.content,
268+ filename = filename,
269+ showToggle = showToggle,
270+ showDiff = showDiff,
271+ onSetShowDiff = onSetShowDiff,
272+ findActive = findActive,
273+ findQuery = findQuery,
274+ onQueryChange = onQueryChange,
275+ matchIndices = matchIndices,
276+ matchPos = matchPos,
277+ onPrev = onPrev,
278+ onNext = onNext,
279+ onCloseFind = onCloseFind,
280+ wrap = wrap,
281+ listState = listState,
282+ )
283+ }
284+}
285+
286+@Composable
287+private fun BoxScope.FileViewContentBody(
288+ content: soy.iko.opencode.data.model.FileContent?,
289+ filename: String,
290+ showToggle: Boolean,
291+ showDiff: Boolean,
292+ onSetShowDiff: (Boolean) -> Unit,
293+ findActive: Boolean,
294+ findQuery: String,
295+ onQueryChange: (String) -> Unit,
296+ matchIndices: List<Int>,
297+ matchPos: Int,
298+ onPrev: () -> Unit,
299+ onNext: () -> Unit,
300+ onCloseFind: () -> Unit,
301+ wrap: Boolean,
302+ listState: LazyListState,
303+) {
304+ // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
305+ // reachable while scrolling. Each contributes a top inset so the content below
306+ // isn't hidden behind the overlay.
307+ val overlayRows = (if (showToggle) 1 else 0) + (if (findActive) 1 else 0)
308+ val topInset = (overlayRows * 52).dp
309+ if (overlayRows > 0) {
310+ Column(
311+ modifier = Modifier
312+ .align(Alignment.TopCenter)
313+ .fillMaxWidth()
314+ .padding(top = 4.dp),
315+ verticalArrangement = Arrangement.spacedBy(4.dp),
316+ ) {
317+ if (showToggle) {
318+ Row(
319+ modifier = Modifier.fillMaxWidth(),
320+ horizontalArrangement = Arrangement.Center,
321+ ) {
322+ FilterChip(
323+ selected = showDiff,
324+ onClick = { onSetShowDiff(true) },
325+ label = { Text(stringResource(R.string.show_diff)) },
326+ )
327+ Spacer(Modifier.size(8.dp))
328+ FilterChip(
329+ selected = !showDiff,
330+ onClick = { onSetShowDiff(false) },
331+ label = { Text(stringResource(R.string.show_raw)) },
332+ )
333+ }
334+ }
335+ if (findActive) {
336+ FindBar(
337+ query = findQuery,
338+ onQueryChange = onQueryChange,
339+ matchCount = matchIndices.size,
340+ matchPos = matchPos,
341+ onPrev = onPrev,
342+ onNext = onNext,
343+ onClose = onCloseFind,
344+ )
345+ }
346+ }
347+ }
348+ if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
349+ DiffView(
350+ diff = content.diff,
351+ modifier = Modifier
352+ .fillMaxSize()
353+ .padding(top = topInset)
354+ .verticalScroll(rememberScrollState())
355+ .padding(8.dp),
356+ )
357+ } else {
358+ val text = content?.content.orEmpty()
359+ if (text.isEmpty()) {
360+ Text(
361+ stringResource(R.string.empty_file),
362+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
363+ color = MaterialTheme.colorScheme.onSurfaceVariant,
364+ )
365+ } else {
366+ FileTextContent(
367+ text = text,
368+ filename = filename,
369+ topInset = topInset,
370+ wrap = wrap,
371+ findQuery = findQuery,
372+ matchIndices = matchIndices,
373+ listState = listState,
374+ )
215375 }
216376 }
217377}
218378
219379@Composable
220−private fun FileTextContent(text: String, filename: String, topInset: androidx.compose.ui.unit.Dp) {
380+private fun FindBar(
381+ query: String,
382+ onQueryChange: (String) -> Unit,
383+ matchCount: Int,
384+ matchPos: Int,
385+ onPrev: () -> Unit,
386+ onNext: () -> Unit,
387+ onClose: () -> Unit,
388+) {
389+ Row(
390+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
391+ verticalAlignment = Alignment.CenterVertically,
392+ ) {
393+ OutlinedTextField(
394+ value = query,
395+ onValueChange = onQueryChange,
396+ modifier = Modifier.weight(1f),
397+ placeholder = { Text(stringResource(R.string.find_hint)) },
398+ singleLine = true,
399+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
400+ keyboardActions = KeyboardActions(onSearch = { onQueryChange(query) }),
401+ trailingIcon = {
402+ val countText = if (query.isBlank()) ""
403+ else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
404+ else stringResource(R.string.match_count, matchPos + 1, matchCount)
405+ if (countText.isNotEmpty()) {
406+ Text(
407+ countText,
408+ style = MaterialTheme.typography.labelSmall,
409+ color = MaterialTheme.colorScheme.onSurfaceVariant,
410+ )
411+ }
412+ },
413+ )
414+ IconButton(onClick = onPrev, enabled = matchCount > 0) {
415+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_in_file))
416+ }
417+ IconButton(onClick = onNext, enabled = matchCount > 0) {
418+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_in_file))
419+ }
420+ IconButton(onClick = onClose) {
421+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
422+ }
423+ }
424+}
425+
426+@Composable
427+private fun FileTextContent(
428+ text: String,
429+ filename: String,
430+ topInset: androidx.compose.ui.unit.Dp,
431+ wrap: Boolean,
432+ findQuery: String,
433+ matchIndices: List<Int>,
434+ listState: LazyListState,
435+) {
221436 val lines = remember(text) { text.split("\n") }
222437 val gutterWidth = remember(lines.size) {
223438 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
224439 }
225440 val hScrollState = rememberScrollState()
226441 val palette = rememberHighlightPalette()
442+ val q = findQuery.trim()
443+ val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
227444 LazyColumn(
445+ state = listState,
228446 modifier = Modifier
229447 .fillMaxSize()
230448 .padding(top = topInset)
231449 .padding(8.dp),
232450 ) {
233451 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
234− Row(modifier = Modifier.horizontalScroll(hScrollState)) {
452+ val isMatch = q.isNotEmpty() && index in matchSet
453+ Row(
454+ modifier = Modifier
455+ .fillMaxWidth()
456+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
457+ .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
458+ ) {
235459 Text(
236460 "${index + 1}",
237461 modifier = Modifier.width(gutterWidth),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+27−3
@@ -62,9 +62,10 @@ fun ServerEditScreen(
6262 container: AppContainer,
6363 profileId: String?,
6464 onDone: () -> Unit,
65+ sourceId: String? = null,
6566) {
6667 val vm: ServerEditViewModel =
67− viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
68+ viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
6869 val state by vm.state.collectAsStateWithLifecycle()
6970 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
7071 var passwordVisible by rememberSaveable { mutableStateOf(false) }
@@ -159,14 +160,26 @@ private fun ServerEditForm(
159160 )
160161 OutlinedTextField(
161162 value = state.baseUrl,
162− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
163+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
163164 label = { Text(stringResource(R.string.base_url)) },
164165 placeholder = { Text(stringResource(R.string.base_url_hint)) },
165166 singleLine = true,
166167 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
167168 supportingText = {
168169 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
169− Text(stringResource(R.string.invalid_url))
170+ // Offer a one-tap "http://" fix for bare host:port input instead of a
171+ // generic error, so the user doesn't have to know the URL needs a scheme.
172+ val suggestion = suggestUrlScheme(state.baseUrl)
173+ if (suggestion != null) {
174+ TextButton(
175+ onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
176+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
177+ ) {
178+ Text(stringResource(R.string.suggest_scheme))
179+ }
180+ } else {
181+ Text(stringResource(R.string.invalid_url))
182+ }
170183 }
171184 },
172185 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
@@ -187,6 +200,17 @@ private fun ServerEditForm(
187200 Text(stringResource(R.string.check_connectivity))
188201 }
189202 }
203+ // Positive feedback when the probe found the server reachable without auth, so
204+ // the user knows the connection works before saving (closes the loop that the
205+ // prior "silently does nothing" Reachable path left open).
206+ if (state.probeReachable == true && !state.authFieldsVisible) {
207+ Text(
208+ stringResource(R.string.server_reachable),
209+ style = MaterialTheme.typography.bodySmall,
210+ color = MaterialTheme.colorScheme.primary,
211+ modifier = Modifier.fillMaxWidth(),
212+ )
213+ }
190214 if (!state.authFieldsVisible) {
191215 Text(
192216 stringResource(R.string.auth_help),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+46−2
@@ -38,6 +38,9 @@ data class ServerEditState(
3838 val testingCredentials: Boolean = false,
3939 /** Non-null when the last credential test succeeded (true) or failed (false). */
4040 val credentialsResult: Boolean? = null,
41+ /** True when the last connectivity probe found the server reachable without auth.
42+ * Null until a probe runs, and cleared when the base URL is edited. */
43+ val probeReachable: Boolean? = null,
4144) {
4245 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
4346 val isNew: Boolean get() = id == null
@@ -67,9 +70,22 @@ fun isValidUrl(url: String): Boolean = try {
6770 false
6871}
6972
73+/**
74+ * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
75+ * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
76+ * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
77+ */
78+fun suggestUrlScheme(input: String): String? {
79+ val trimmed = input.trim()
80+ if (trimmed.isEmpty() || "://" in trimmed) return null
81+ val candidate = "http://$trimmed"
82+ return if (isValidUrl(candidate)) candidate else null
83+}
84+
7085class ServerEditViewModel(
7186 private val container: AppContainer,
7287 private val profileId: String?,
88+ private val sourceId: String? = null,
7389) : ViewModel() {
7490
7591 private val _state = MutableStateFlow(ServerEditState(id = profileId))
@@ -109,6 +125,31 @@ class ServerEditViewModel(
109125 )
110126 return@launch
111127 }
128+ // Duplicate: seed the form from an existing profile but as a NEW profile
129+ // (id stays null so save() generates a fresh one). Appends " (copy)" to the
130+ // label so the user can tell the duplicate from the original at a glance.
131+ if (sourceId != null) {
132+ val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133+ container.profileStore.profiles
134+ .first { it.isNotEmpty() || it.none { p -> p.id == sourceId } }
135+ .firstOrNull { it.id == sourceId }
136+ }
137+ if (source != null) {
138+ val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
139+ _state.value = ServerEditState(
140+ id = null,
141+ label = dupLabel,
142+ baseUrl = source.baseUrl,
143+ username = source.username.orEmpty(),
144+ password = source.password.orEmpty(),
145+ loaded = true,
146+ authFieldsVisible = source.hasAuth,
147+ initial = InitialProfile("", "", "", ""),
148+ )
149+ return@launch
150+ }
151+ // Source not found — fall through to a blank new-profile form.
152+ }
112153 // New-profile form: seed an initial snapshot of empty values so isDirty
113154 // becomes true the moment the user types anything.
114155 _state.update {
@@ -127,7 +168,7 @@ class ServerEditViewModel(
127168 fun probe() {
128169 val s = _state.value
129170 if (!s.canSave || s.probing) return
130− _state.update { it.copy(probing = true, error = null, credentialsResult = null) }
171+ _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
131172 viewModelScope.launch {
132173 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
133174 result.onSuccess { pr ->
@@ -137,21 +178,24 @@ class ServerEditViewModel(
137178 probing = false,
138179 authFieldsVisible = false,
139180 error = null,
181+ probeReachable = true,
140182 )
141183 is ProbeResult.NeedsAuth -> it.copy(
142184 probing = false,
143185 authFieldsVisible = true,
144186 error = null,
187+ probeReachable = false,
145188 )
146189 is ProbeResult.Unreachable -> it.copy(
147190 probing = false,
148191 authFieldsVisible = false,
149192 error = pr.error,
193+ probeReachable = false,
150194 )
151195 }
152196 }
153197 }.onFailure { e ->
154− _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
198+ _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
155199 }
156200 }
157201 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+33
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Add
1818import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.ContentCopy
1920import androidx.compose.material.icons.filled.Delete
2021import androidx.compose.material.icons.filled.Dns
2122import androidx.compose.material.icons.filled.Edit
@@ -62,6 +63,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
6263import soy.iko.opencode.data.model.ServerProfile
6364import soy.iko.opencode.di.AppContainer
6465import soy.iko.opencode.R
66+import soy.iko.opencode.ui.components.rememberRelativeTime
6567import soy.iko.opencode.ui.vmFactory
6668
6769@OptIn(ExperimentalMaterial3Api::class)
@@ -71,6 +73,7 @@ fun ServerListScreen(
7173 onConnected: () -> Unit,
7274 onAddProfile: () -> Unit,
7375 onEditProfile: (String) -> Unit,
76+ onDuplicateProfile: (String) -> Unit,
7477) {
7578 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
7679 val profiles by vm.profiles.collectAsStateWithLifecycle()
@@ -188,6 +191,7 @@ fun ServerListScreen(
188191 style = MaterialTheme.typography.labelSmall,
189192 color = MaterialTheme.colorScheme.primary,
190193 )
194+ LastUsedText(profile.lastUsed)
191195 }
192196 Icon(
193197 Icons.Filled.CheckCircle,
@@ -200,6 +204,9 @@ fun ServerListScreen(
200204 IconButton(onClick = { onEditProfile(profile.id) }) {
201205 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
202206 }
207+ IconButton(onClick = { onDuplicateProfile(profile.id) }) {
208+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
209+ }
203210 }
204211 }
205212 } else {
@@ -261,6 +268,7 @@ fun ServerListScreen(
261268 maxLines = 1,
262269 overflow = TextOverflow.Ellipsis,
263270 )
271+ LastUsedText(profile.lastUsed)
264272 }
265273 if (connectingId == profile.id) {
266274 val connectingLabel = stringResource(R.string.connecting)
@@ -271,6 +279,9 @@ fun ServerListScreen(
271279 IconButton(onClick = { onEditProfile(profile.id) }) {
272280 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
273281 }
282+ IconButton(onClick = { onDuplicateProfile(profile.id) }) {
283+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
284+ }
274285 IconButton(onClick = { pendingDeleteId = profile.id }) {
275286 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
276287 }
@@ -312,6 +323,28 @@ fun ServerListScreen(
312323 }
313324}
314325
326+/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
327+ * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
328+@Composable
329+private fun LastUsedText(lastUsed: Long) {
330+ if (lastUsed <= 0) {
331+ Text(
332+ stringResource(R.string.last_used_never),
333+ style = MaterialTheme.typography.labelSmall,
334+ color = MaterialTheme.colorScheme.onSurfaceVariant,
335+ )
336+ } else {
337+ val relative = rememberRelativeTime(lastUsed)
338+ if (relative.isNotEmpty()) {
339+ Text(
340+ stringResource(R.string.last_used, relative),
341+ style = MaterialTheme.typography.labelSmall,
342+ color = MaterialTheme.colorScheme.onSurfaceVariant,
343+ )
344+ }
345+ }
346+}
347+
315348@Composable
316349private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
317350 Box(modifier = modifier, contentAlignment = Alignment.Center) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+24
@@ -19,11 +19,14 @@ import androidx.compose.foundation.text.KeyboardActions
1919import androidx.compose.foundation.text.KeyboardOptions
2020import androidx.compose.material.icons.Icons
2121import androidx.compose.material.icons.automirrored.filled.Logout
22+import androidx.compose.material.icons.automirrored.filled.Sort
2223import androidx.compose.material.icons.filled.Add
2324import androidx.compose.material.icons.filled.ArrowDropDown
2425import androidx.compose.material.icons.filled.ChatBubbleOutline
2526import androidx.compose.material.icons.filled.CheckCircle
27+import androidx.compose.material.icons.filled.ContentCopy
2628import androidx.compose.material.icons.filled.Delete
29+import androidx.compose.material.icons.filled.Dns
2730import androidx.compose.material.icons.filled.Edit
2831import androidx.compose.material.icons.filled.Folder
2932import androidx.compose.material.icons.filled.Refresh
@@ -63,6 +66,7 @@ import androidx.compose.ui.Alignment
6366import androidx.compose.ui.Modifier
6467import androidx.compose.ui.draw.clip
6568import androidx.compose.ui.platform.LocalHapticFeedback
69+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
6670import androidx.compose.ui.platform.testTag
6771import androidx.compose.ui.semantics.Role
6872import androidx.compose.ui.semantics.contentDescription
@@ -104,6 +108,7 @@ fun SessionListScreen(
104108 val creating by vm.creating.collectAsStateWithLifecycle()
105109 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
106110 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
111+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
107112 val connectedId = activeConnection?.profile?.id
108113 val haptics = LocalHapticFeedback.current
109114 val snackbar = remember { SnackbarHostState() }
@@ -113,6 +118,7 @@ fun SessionListScreen(
113118 // each card spinning up its own coroutine + lifecycle observer while scrolling.
114119 val timeTick = rememberRelativeTimeTick()
115120 var showServerMenu by rememberSaveable { mutableStateOf(false) }
121+ var showSortMenu by rememberSaveable { mutableStateOf(false) }
116122 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
117123 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
118124 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
@@ -167,6 +173,21 @@ fun SessionListScreen(
167173 )
168174 },
169175 actions = {
176+ Box {
177+ IconButton(onClick = { showSortMenu = true }) {
178+ Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
179+ }
180+ DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
181+ DropdownMenuItem(
182+ text = { Text(stringResource(R.string.sort_recent)) },
183+ onClick = { vm.setSortMode(SessionSortMode.RECENT); showSortMenu = false },
184+ )
185+ DropdownMenuItem(
186+ text = { Text(stringResource(R.string.sort_title)) },
187+ onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
188+ )
189+ }
190+ }
170191 IconButton(onClick = { vm.refresh() }) {
171192 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
172193 }
@@ -204,6 +225,7 @@ fun SessionListScreen(
204225 ConnectionBanner(
205226 state = connectionState,
206227 modifier = Modifier.align(Alignment.TopCenter),
228+ isOnline = isOnline,
207229 onRetry = { vm.retryConnection() },
208230 )
209231 SessionListBody(
@@ -313,6 +335,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
313335 modifier = Modifier.align(Alignment.Center),
314336 )
315337 else -> Column(modifier = Modifier.fillMaxSize()) {
338+ val keyboardController = LocalSoftwareKeyboardController.current
316339 OutlinedTextField(
317340 value = state.query,
318341 onValueChange = onQueryChange,
@@ -322,6 +345,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
322345 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
323346 singleLine = true,
324347 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
348+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
325349 )
326350 val sessions = remember(state.sessions, state.query, state.previews) {
327351 // Fast-path an empty query: filtered just returns sessions, so skip
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+44−8
@@ -8,6 +8,10 @@ import soy.iko.opencode.data.model.Session
88import soy.iko.opencode.data.model.SessionDeleted
99import soy.iko.opencode.data.model.SessionUpdated
1010import soy.iko.opencode.data.model.TextPart
11+import soy.iko.opencode.data.model.ToolCompleted
12+import soy.iko.opencode.data.model.ToolError
13+import soy.iko.opencode.data.model.ToolPart
14+import soy.iko.opencode.data.model.ToolRunning
1115import soy.iko.opencode.data.network.EventStreamClient
1216import soy.iko.opencode.data.network.NetworkConfig
1317import soy.iko.opencode.di.AppContainer
@@ -36,6 +40,9 @@ import kotlinx.coroutines.launch
3640import kotlinx.coroutines.sync.Semaphore
3741import kotlinx.coroutines.sync.withPermit
3842
43+/** Sort order for the session list. */
44+enum class SessionSortMode { RECENT, TITLE }
45+
3946@Immutable
4047data class SessionListState(
4148 val sessions: List<Session> = emptyList(),
@@ -43,6 +50,7 @@ data class SessionListState(
4350 val previews: Map<String, String> = emptyMap(),
4451 val loading: Boolean = true,
4552 val error: String? = null,
53+ val sortMode: SessionSortMode = SessionSortMode.RECENT,
4654) {
4755 /** Sessions filtered by the current search query (title or preview text). */
4856 val filtered: List<Session>
@@ -56,6 +64,15 @@ data class SessionListState(
5664 }
5765}
5866
67+/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
68+ * display title asc (case-insensitive), falling back to recency for ties. */
69+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
70+ SessionSortMode.RECENT ->
71+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
72+ SessionSortMode.TITLE ->
73+ sortedWith(compareBy<Session> { it.displayTitle.lowercase() }.thenByDescending { it.time?.updated ?: it.time?.created ?: 0L })
74+}
75+
5976class SessionListViewModel(private val container: AppContainer) : ViewModel() {
6077
6178 /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
@@ -221,7 +238,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
221238 // new state (and trigger recomposition) when the batch held no real changes
222239 // (e.g. a duplicate SessionUpdated for an unchanged session).
223240 if (!anyChanged) return@update s
224− val sorted = byId.values.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
241+ val sorted = byId.values.sortedByMode(s.sortMode)
225242 // If the new ordering matches the existing one (e.g. only the already-newest
226243 // session updated its timestamp), reuse the same list instance so downstream
227244 // skips a recomposition for an identical reference.
@@ -263,7 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
263280 try {
264281 runCatchingCancellable { conn.repository.listSessions() }
265282 .onSuccess { list ->
266− val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
283+ val sorted = list.sortedByMode(_state.value.sortMode)
267284 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
268285 loadPreviews(sorted)
269286 }
@@ -388,7 +405,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
388405 }
389406 .onFailure {
390407 // Restore the session to the list since the delete failed.
391− _state.update { s -> s.copy(sessions = (s.sessions + session).sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }) }
408+ _state.update { s -> s.copy(sessions = (s.sessions + session).sortedByMode(s.sortMode)) }
392409 _transientErrors.tryEmit(container.friendlyError(it))
393410 }
394411 }
@@ -401,11 +418,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
401418 if (pending != null && pending.id == sessionId) {
402419 pendingDelete = null
403420 _state.update { s ->
404− s.copy(sessions = (s.sessions + pending).sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 })
421+ s.copy(sessions = (s.sessions + pending).sortedByMode(s.sortMode))
405422 }
406423 }
407424 }
408425
426+ fun setSortMode(mode: SessionSortMode) {
427+ if (mode == _state.value.sortMode) return
428+ _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
429+ }
430+
409431 fun renameSession(session: Session, newTitle: String) {
410432 val conn = container.activeConnection.value ?: return
411433 val title = newTitle.trim()
@@ -479,15 +501,29 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
479501}
480502
481503/**
482− * Fetch the last text part of a session's most recent message, truncated to
483− * [NetworkConfig.previewTextMaxLength]. Returns null on failure or when the
484− * last message has no text part.
504+ * Fetch a preview of a session's most recent activity, truncated to
505+ * [NetworkConfig.previewTextMaxLength]. Returns the last text part if present; otherwise
506+ * a short summary of the last tool call (e.g. "🔧 read") so a session whose last activity
507+ * was a tool call (no assistant text yet) still shows a meaningful preview instead of
508+ * stale/empty. Returns null on failure or when there's nothing usable.
485509 */
486510private suspend fun fetchSessionPreview(
487511 api: soy.iko.opencode.data.network.OpencodeApiClient,
488512 sessionId: String,
489513): String? = runCatchingCancellable {
490514 api.listMessages(sessionId).lastOrNull()?.let { msg ->
491− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
515+ val text = msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
516+ if (text != null) return@let text
517+ val tool = msg.parts.filterIsInstance<ToolPart>().lastOrNull()
518+ if (tool != null) {
519+ val title = when (val st = tool.state) {
520+ is ToolRunning -> st.title
521+ is ToolCompleted -> st.title
522+ is ToolError -> st.error
523+ else -> null
524+ }
525+ return@let title?.takeIf { it.isNotBlank() }?.let { "🔧 $it" } ?: "🔧 ${tool.tool}"
526+ }
527+ null
492528 }
493529}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+38−5
@@ -67,6 +67,8 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
6767 val scope = rememberCoroutineScope()
6868 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
6969 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
70+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
71+ val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
7072 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
7173 val context = LocalContext.current
7274 // Crash count badge: surface that there are reports to look at without making the
@@ -117,27 +119,58 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
117119 }
118120
119121 Spacer(Modifier.size(8.dp))
122+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
123+ // on older devices instead of offering a control that silently does nothing.
124+ if (dynamicColorAvailable) {
125+ Row(
126+ modifier = Modifier
127+ .fillMaxWidth()
128+ .toggleable(
129+ value = dynamicColor,
130+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
131+ role = Role.Switch,
132+ )
133+ .padding(vertical = 8.dp),
134+ verticalAlignment = Alignment.CenterVertically,
135+ ) {
136+ Column(modifier = Modifier.weight(1f)) {
137+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
138+ Text(
139+ stringResource(R.string.dynamic_color_desc),
140+ style = MaterialTheme.typography.bodySmall,
141+ color = MaterialTheme.colorScheme.onSurfaceVariant,
142+ )
143+ }
144+ Switch(
145+ checked = dynamicColor,
146+ onCheckedChange = null,
147+ )
148+ }
149+ }
150+
151+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
152+ Spacer(Modifier.size(4.dp))
120153 Row(
121154 modifier = Modifier
122155 .fillMaxWidth()
123156 .toggleable(
124− value = dynamicColor,
125− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
157+ value = sendOnEnter,
158+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
126159 role = Role.Switch,
127160 )
128161 .padding(vertical = 8.dp),
129162 verticalAlignment = Alignment.CenterVertically,
130163 ) {
131164 Column(modifier = Modifier.weight(1f)) {
132− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
165+ Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
133166 Text(
134− stringResource(R.string.dynamic_color_desc),
167+ stringResource(R.string.send_on_enter_desc),
135168 style = MaterialTheme.typography.bodySmall,
136169 color = MaterialTheme.colorScheme.onSurfaceVariant,
137170 )
138171 }
139172 Switch(
140− checked = dynamicColor,
173+ checked = sendOnEnter,
141174 onCheckedChange = null,
142175 )
143176 }
app/src/main/res/values/strings.xml+25
@@ -226,4 +226,29 @@
226226 <string name="notif_running_text">Agent is working…</string>
227227 <string name="notif_completed_title">Session ready</string>
228228 <string name="notif_completed_text">opencode replied in “%1$s”.</string>
229+
230+ <!-- UX additions -->
231+ <string name="tool_input">Input</string>
232+ <string name="copy_path">Copy path</string>
233+ <string name="path_copied">Path copied</string>
234+ <string name="unknown_message">Unknown message type</string>
235+ <string name="server_reachable">Server is reachable ✓</string>
236+ <string name="suggest_scheme">Add http:// prefix</string>
237+ <string name="sort">Sort</string>
238+ <string name="sort_recent">Recent activity</string>
239+ <string name="sort_title">Title</string>
240+ <string name="last_used_never">Not used yet</string>
241+ <string name="last_used">Last used %1$s</string>
242+ <string name="duplicate_server">Duplicate</string>
243+ <string name="find_in_file">Find in file</string>
244+ <string name="find_hint">Find…</string>
245+ <string name="no_matches_in_file">No matches</string>
246+ <string name="match_count">%1$d / %2$d</string>
247+ <string name="wrap_lines">Wrap lines</string>
248+ <string name="send_on_enter">Send on Enter</string>
249+ <string name="send_on_enter_desc">When off, Enter inserts a newline; press Ctrl+Enter to send.</string>
250+ <string name="offline">You\'re offline</string>
251+ <string name="run_command_title">Run /%1$s?</string>
252+ <string name="run_command_text">Run the command “%1$s”?</string>
253+ <string name="run">Run</string>
229254</resources>
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+34
@@ -7,6 +7,8 @@ import soy.iko.opencode.data.model.AssistantMessage
77import soy.iko.opencode.data.model.MessageWithParts
88import soy.iko.opencode.data.model.ReasoningPart
99import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.model.ToolCompleted
11+import soy.iko.opencode.data.model.ToolPart
1012import soy.iko.opencode.data.model.UserMessage
1113
1214class ConversationExportTest {
@@ -53,4 +55,36 @@ class ConversationExportTest {
5355 // No message bodies to export, but the title header is still emitted.
5456 assertEquals("# x", buildConversationMarkdown(emptyList(), title = "x"))
5557 }
58+
59+ @Test
60+ fun includesToolCallSummary() {
61+ val assistant = MessageWithParts(
62+ AssistantMessage("a1", sid),
63+ listOf(
64+ TextPart(id = "t1", text = "Done"),
65+ ToolPart(id = "tool1", tool = "read", state = ToolCompleted(output = "file contents")),
66+ ),
67+ )
68+ val md = buildConversationMarkdown(listOf(user("u1", "read the file"), assistant), title = null)
69+ assertTrue(md.contains("## opencode"))
70+ assertTrue("tool name should appear", md.contains("**read**"))
71+ assertTrue("tool output should appear", md.contains("file contents"))
72+ }
73+
74+ @Test
75+ fun includesToolCallWithTitle() {
76+ val assistant = MessageWithParts(
77+ AssistantMessage("a1", sid),
78+ listOf(
79+ ToolPart(
80+ id = "tool1",
81+ tool = "edit",
82+ state = ToolCompleted(title = "Editing src/main.kt", output = "ok"),
83+ ),
84+ ),
85+ )
86+ val md = buildConversationMarkdown(listOf(assistant), title = null)
87+ assertTrue(md.contains("**edit**"))
88+ assertTrue(md.contains("Editing src/main.kt"))
89+ }
5690}
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+63
@@ -398,4 +398,67 @@ class ServerEditViewModelTest {
398398 // With default fake, probe succeeds → no error
399399 assertNull(vm.state.value.error)
400400 }
401+
402+ @Test
403+ fun probe_reachable_setsProbeReachable() = testScope.runTest {
404+ val container = FakeAppContainer()
405+ container.probeResult = ProbeResult.Reachable
406+ val vm = makeVm(container)
407+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
408+ vm.probe()
409+ testScheduler.advanceUntilIdle()
410+ assertEquals(true, vm.state.value.probeReachable)
411+ }
412+
413+ @Test
414+ fun probe_needsAuth_setsProbeReachableFalse() = testScope.runTest {
415+ val container = FakeAppContainer()
416+ container.probeResult = ProbeResult.NeedsAuth
417+ val vm = makeVm(container)
418+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
419+ vm.probe()
420+ testScheduler.advanceUntilIdle()
421+ assertEquals(false, vm.state.value.probeReachable)
422+ }
423+
424+ @Test
425+ fun suggestUrlScheme_addsHttpForBareHost() {
426+ assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))
427+ assertEquals("http://localhost:3000", suggestUrlScheme("localhost:3000"))
428+ }
429+
430+ @Test
431+ fun suggestUrlScheme_returnsNullForAlreadySchemed() {
432+ assertNull(suggestUrlScheme("http://localhost:3000"))
433+ assertNull(suggestUrlScheme("https://example.com"))
434+ }
435+
436+ @Test
437+ fun suggestUrlScheme_returnsNullForUnparseable() {
438+ assertNull(suggestUrlScheme("not a url"))
439+ assertNull(suggestUrlScheme(""))
440+ }
441+
442+ @Test
443+ fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
444+ val container = FakeAppContainer()
445+ val source = ServerProfile(
446+ id = "src1",
447+ label = "My Server",
448+ baseUrl = "http://localhost:3000",
449+ username = "admin",
450+ password = "secret",
451+ )
452+ container.fakeProfileStore.setProfiles(listOf(source))
453+ val vm = ServerEditViewModel(container, profileId = null, sourceId = "src1")
454+ testScope.testScheduler.advanceUntilIdle()
455+ assertTrue(vm.state.value.loaded)
456+ // A duplicate is a NEW profile (no id), so save() generates a fresh one.
457+ assertNull(vm.state.value.id)
458+ assertEquals("My Server (copy)", vm.state.value.label)
459+ assertEquals("http://localhost:3000", vm.state.value.baseUrl)
460+ assertEquals("admin", vm.state.value.username)
461+ assertEquals("secret", vm.state.value.password)
462+ assertTrue(vm.state.value.authFieldsVisible)
463+ }
401464}
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+62
@@ -25,7 +25,10 @@ import soy.iko.opencode.data.model.ServerProfile
2525import soy.iko.opencode.data.model.Session
2626import soy.iko.opencode.data.model.SessionDeleted
2727import soy.iko.opencode.data.model.SessionUpdated
28+import soy.iko.opencode.data.model.AssistantMessage
2829import soy.iko.opencode.data.model.TextPart
30+import soy.iko.opencode.data.model.ToolCompleted
31+import soy.iko.opencode.data.model.ToolPart
2932import soy.iko.opencode.data.model.UserMessage
3033import soy.iko.opencode.data.model.MessageWithParts
3134import soy.iko.opencode.ui.testing.FakeAppContainer
@@ -458,4 +461,63 @@ class SessionListViewModelTest {
458461 api.listMessagesCalls.size,
459462 )
460463 }
464+
465+ // --- sorting ---
466+
467+ @Test
468+ fun defaultSortMode_isRecent() = testScope.runTest {
469+ val container = makeContainer()
470+ val vm = makeVm(container)
471+ assertEquals(SessionSortMode.RECENT, vm.state.value.sortMode)
472+ }
473+
474+ @Test
475+ fun setSortMode_titleSortsAlphabetically() = testScope.runTest {
476+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
477+ repo.sessions = listOf(
478+ Session(id = "s1", title = "Zebra"),
479+ Session(id = "s2", title = "Apple"),
480+ Session(id = "s3", title = "Mango"),
481+ )
482+ val container = makeContainer(repo = repo)
483+ val vm = makeVm(container)
484+ vm.setSortMode(SessionSortMode.TITLE)
485+ val titles = vm.state.value.sessions.map { it.title }
486+ assertEquals(listOf("Apple", "Mango", "Zebra"), titles)
487+ }
488+
489+ @Test
490+ fun setSortMode_recentSortsByActivity() = testScope.runTest {
491+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
492+ repo.sessions = listOf(
493+ Session(id = "old", title = "Old", time = soy.iko.opencode.data.model.TimeInfo(created = 100)),
494+ Session(id = "new", title = "New", time = soy.iko.opencode.data.model.TimeInfo(created = 900)),
495+ )
496+ val container = makeContainer(repo = repo)
497+ val vm = makeVm(container)
498+ vm.setSortMode(SessionSortMode.RECENT)
499+ assertEquals("new", vm.state.value.sessions[0].id)
500+ }
501+
502+ // --- preview fallback ---
503+
504+ @Test
505+ fun preview_fallsBackToToolSummaryWhenNoText() = testScope.runTest {
506+ val api = FakeOpencodeApiClient()
507+ val events = FakeEventStreamClient()
508+ val repo = FakeSessionRepository(api, events)
509+ repo.sessions = listOf(Session(id = "s1", title = "Chat 1"))
510+ api.messages = listOf(
511+ MessageWithParts(
512+ info = AssistantMessage("m1", "s1"),
513+ parts = listOf(ToolPart(id = "tool1", tool = "read", state = ToolCompleted(output = "contents"))),
514+ ),
515+ )
516+ val container = makeContainer(api = api, events = events, repo = repo)
517+ val vm = makeVm(container)
518+ val preview = vm.state.value.previews["s1"]
519+ assertNotNull(preview)
520+ assertTrue("preview should reference the tool: $preview", preview!!.contains("🔧"))
521+ assertTrue("preview should name the tool: $preview", preview.contains("read"))
522+ }
461523}