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
28832afiko
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+13−1
@@ -12,12 +12,13 @@ enum class ThemeMode { SYSTEM, LIGHT, DARK }
12
12
13private val Context.settingsDataStore by preferencesDataStore(name = "settings")
13private val Context.settingsDataStore by preferencesDataStore(name = "settings")
14
14
15−/** App-preferences store: theme mode, dynamic color (Material You). */
15+/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
16class SettingsStore(context: Context) {
16class SettingsStore(context: Context) {
17
17
18 private val appContext = context.applicationContext
18 private val appContext = context.applicationContext
19 private val themeKey = stringPreferencesKey("theme_mode")
19 private val themeKey = stringPreferencesKey("theme_mode")
20 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
20 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21+ private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
21
22
22 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
23 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
23 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
24 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
@@ -28,6 +29,13 @@ class SettingsStore(context: Context) {
28 prefs[dynamicColorKey] ?: true
29 prefs[dynamicColorKey] ?: true
29 }
30 }
30
31
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+
31 suspend fun setThemeMode(mode: ThemeMode) {
39 suspend fun setThemeMode(mode: ThemeMode) {
32 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
40 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
33 }
41 }
@@ -35,4 +43,8 @@ class SettingsStore(context: Context) {
35 suspend fun setDynamicColor(enabled: Boolean) {
43 suspend fun setDynamicColor(enabled: Boolean) {
36 appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
44 appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
37 }
45 }
46+
47+ suspend fun setSendOnEnter(enabled: Boolean) {
48+ appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
49+ }
38}
50}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−3
@@ -134,6 +134,12 @@ open class AppContainer private constructor(
134 private val _anyRunActive = MutableStateFlow(false)
134 private val _anyRunActive = MutableStateFlow(false)
135 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
135 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
136
136
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+
137 open fun setCurrentSession(id: String?) {
143 open fun setCurrentSession(id: String?) {
138 _currentSession.value = id
144 _currentSession.value = id
139 if (id != null) {
145 if (id != null) {
@@ -326,10 +332,12 @@ open class AppContainer private constructor(
326 if (appContext == null) return null
332 if (appContext == null) return null
327 val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
333 val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
328 ?: return null
334 ?: 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)
329 val callback = object : ConnectivityManager.NetworkCallback() {
338 val callback = object : ConnectivityManager.NetworkCallback() {
330− override fun onAvailable(network: Network) {
331− activeConnection.value?.events?.triggerReconnect()
339+ override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
332− }
340+ override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
333 }
341 }
334 runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
342 runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
335 .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
343 .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) {
82 onConnected = { navController.navigate(Routes.SESSIONS) },
82 onConnected = { navController.navigate(Routes.SESSIONS) },
83 onAddProfile = { navController.navigate(Routes.serverEdit()) },
83 onAddProfile = { navController.navigate(Routes.serverEdit()) },
84 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
84 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
85+ onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
85 )
86 )
86 }
87 }
87
88
88 composable(
89 composable(
89− route = "${Routes.SERVER_EDIT}?id={id}",
90+ route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
91+ arguments = listOf(
90− arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
92+ navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
93+ navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
94+ ),
91 ) { entry ->
95 ) { entry ->
92 ServerEditScreen(
96 ServerEditScreen(
93 container = container,
97 container = container,
94 profileId = entry.arguments?.getString("id"),
98 profileId = entry.arguments?.getString("id"),
99+ sourceId = entry.arguments?.getString("dup"),
95 onDone = { navController.popBackStack() },
100 onDone = { navController.popBackStack() },
96 )
101 )
97 }
102 }
app/src/main/java/soy/iko/opencode/ui/Routes.kt+3
@@ -12,6 +12,9 @@ object Routes {
12 fun serverEdit(id: String? = null) =
12 fun serverEdit(id: String? = null) =
13 if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
13 if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
14
14
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+
15 const val CHAT = "chat"
18 const val CHAT = "chat"
16 fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
19 fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
17
20
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+39−15
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.FlowRow
9import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.Spacer
@@ -68,6 +69,7 @@ import androidx.compose.ui.Alignment
68import androidx.compose.ui.Modifier
69import androidx.compose.ui.Modifier
69import androidx.compose.ui.input.key.Key
70import androidx.compose.ui.input.key.Key
70import androidx.compose.ui.input.key.KeyEventType
71import androidx.compose.ui.input.key.KeyEventType
72+import androidx.compose.ui.input.key.isCtrlPressed
71import androidx.compose.ui.input.key.isShiftPressed
73import androidx.compose.ui.input.key.isShiftPressed
72import androidx.compose.ui.input.key.key
74import androidx.compose.ui.input.key.key
73import androidx.compose.ui.input.key.onPreviewKeyEvent
75import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -130,6 +132,8 @@ fun ChatScreen(
130 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
132 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
131 val draft by vm.draft.collectAsStateWithLifecycle()
133 val draft by vm.draft.collectAsStateWithLifecycle()
132 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
134 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
135+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
136+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
133 val haptics = LocalHapticFeedback.current
137 val haptics = LocalHapticFeedback.current
134 val scope = rememberCoroutineScope()
138 val scope = rememberCoroutineScope()
135 val shareContext = LocalContext.current
139 val shareContext = LocalContext.current
@@ -320,6 +324,7 @@ fun ChatScreen(
320 onValueChange = vm::updateDraft,
324 onValueChange = vm::updateDraft,
321 running = running,
325 running = running,
322 enabled = activeConnection != null,
326 enabled = activeConnection != null,
327+ sendOnEnter = sendOnEnter,
323 onSend = ::doSend,
328 onSend = ::doSend,
324 onAbort = { showStopConfirm = true },
329 onAbort = { showStopConfirm = true },
325 queuedFollowUp = queuedFollowUp,
330 queuedFollowUp = queuedFollowUp,
@@ -393,6 +398,7 @@ fun ChatScreen(
393 ConnectionBanner(
398 ConnectionBanner(
394 state = connectionState,
399 state = connectionState,
395 modifier = Modifier.align(Alignment.TopCenter),
400 modifier = Modifier.align(Alignment.TopCenter),
401+ isOnline = isOnline,
396 // On a hard failure, retry by forcing an SSE reconnect (which
402 // On a hard failure, retry by forcing an SSE reconnect (which
397 // re-seeds from REST). refreshMessages is the right recovery path
403 // re-seeds from REST). refreshMessages is the right recovery path
398 // when the connection is present but the stream died; reconnect()
404 // when the connection is present but the stream died; reconnect()
@@ -465,7 +471,14 @@ fun ChatScreen(
465 // Only the last (streaming) message needs isRunning — it drives the
471 // Only the last (streaming) message needs isRunning — it drives the
466 // reasoning-block spinner. Passing the live flag to every bubble
472 // reasoning-block spinner. Passing the live flag to every bubble
467 // makes all visible messages recompose whenever a run starts or stops.
473 // makes all visible messages recompose whenever a run starts or stops.
474+ val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
475+ ?.let { resolveModelLabel(it, models) }
476+ MessageBubble(
477+ message,
468− MessageBubble(message, isRunning = running && message.info.id == lastMessageId, imageContext = imageContext)
478+ isRunning = running && message.info.id == lastMessageId,
479+ imageContext = imageContext,
480+ modelLabel = modelLabel,
481+ )
469 }
482 }
470 if (running) {
483 if (running) {
471 item(key = "__typing") {
484 item(key = "__typing") {
@@ -589,6 +602,7 @@ private fun ChatInputBar(
589 onValueChange: (String) -> Unit,
602 onValueChange: (String) -> Unit,
590 running: Boolean,
603 running: Boolean,
591 enabled: Boolean,
604 enabled: Boolean,
605+ sendOnEnter: Boolean,
592 onSend: () -> Unit,
606 onSend: () -> Unit,
593 onAbort: () -> Unit,
607 onAbort: () -> Unit,
594 queuedFollowUp: String?,
608 queuedFollowUp: String?,
@@ -632,11 +646,15 @@ private fun ChatInputBar(
632 .weight(1f)
646 .weight(1f)
633 .testTag("chat_input")
647 .testTag("chat_input")
634 .onPreviewKeyEvent { event ->
648 .onPreviewKeyEvent { event ->
635− if (event.type == KeyEventType.KeyDown &&
649+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
636− event.key == Key.Enter &&
650+ // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
637− !event.isShiftPressed &&
651+ // With it off, Enter inserts a newline and Ctrl+Enter sends.
652+ val send = when {
638− enabled && value.isNotBlank()
653+ !enabled || value.isBlank() -> false
639− ) {
654+ sendOnEnter -> !event.isShiftPressed
655+ else -> event.isCtrlPressed
656+ }
657+ if (send) {
640 if (running) onQueueFollowUp(value) else onSend()
658 if (running) onQueueFollowUp(value) else onSend()
641 true
659 true
642 } else {
660 } else {
@@ -690,6 +708,7 @@ private fun ChatInputBar(
690 }
708 }
691}
709}
692
710
711+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
693@Composable
712@Composable
694private fun EmptyConversation(
713private fun EmptyConversation(
695 onSuggestion: (String) -> Unit,
714 onSuggestion: (String) -> Unit,
@@ -727,15 +746,20 @@ private fun EmptyConversation(
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
746 color = MaterialTheme.colorScheme.onSurfaceVariant,
728 )
747 )
729 Spacer(Modifier.size(20.dp))
748 Spacer(Modifier.size(20.dp))
730− // Suggestion chips wrap across multiple rows on narrow screens. FlowRow-like
749+ // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
731− // behavior via a plain Column of Rows keeps the dependency surface zero.
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+ ) {
732− suggestions.forEach { resId ->
756+ suggestions.forEach { resId ->
733− val text = stringResource(resId)
757+ val text = stringResource(resId)
734− androidx.compose.material3.AssistChip(
758+ androidx.compose.material3.AssistChip(
735− onClick = { onSuggestion(text) },
759+ onClick = { onSuggestion(text) },
736− label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
760+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
737− modifier = Modifier.padding(vertical = 4.dp),
761+ )
738− )
762+ }
739 }
763 }
740 }
764 }
741}
765}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+41−1
@@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.ModalBottomSheet
18import androidx.compose.material3.ModalBottomSheet
19import androidx.compose.material3.OutlinedTextField
19import androidx.compose.material3.OutlinedTextField
20import androidx.compose.material3.Text
20import androidx.compose.material3.Text
21+import androidx.compose.material3.AlertDialog
21import androidx.compose.material3.TextButton
22import androidx.compose.material3.TextButton
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Composable
23import androidx.compose.runtime.getValue
24import androidx.compose.runtime.getValue
@@ -49,6 +50,7 @@ fun CommandPickerSheet(
49 onRetry: () -> Unit,
50 onRetry: () -> Unit,
50 onDismiss: () -> Unit,
51 onDismiss: () -> Unit,
51) {
52) {
53+ var pendingCommand by rememberSaveable { mutableStateOf<Command?>(null) }
52 ModalBottomSheet(onDismissRequest = onDismiss) {
54 ModalBottomSheet(onDismissRequest = onDismiss) {
53 Text(
55 Text(
54 stringResource(R.string.commands),
56 stringResource(R.string.commands),
@@ -114,7 +116,14 @@ fun CommandPickerSheet(
114 Column(
116 Column(
115 modifier = Modifier
117 modifier = Modifier
116 .fillMaxWidth()
118 .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+ }
118 .padding(horizontal = 24.dp, vertical = 12.dp),
127 .padding(horizontal = 24.dp, vertical = 12.dp),
119 ) {
128 ) {
120 androidx.compose.foundation.layout.Row(
129 androidx.compose.foundation.layout.Row(
@@ -141,4 +150,35 @@ fun CommandPickerSheet(
141 }
150 }
142 }
151 }
143 }
152 }
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")
144}
184}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+44−3
@@ -4,13 +4,17 @@ import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.MessageWithParts
4import soy.iko.opencode.data.model.MessageWithParts
5import soy.iko.opencode.data.model.ReasoningPart
5import soy.iko.opencode.data.model.ReasoningPart
6import soy.iko.opencode.data.model.TextPart
6import 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
7import soy.iko.opencode.data.model.UserMessage
11import soy.iko.opencode.data.model.UserMessage
8
12
9/**
13/**
10 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
14 * 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
15+ * role-prefixed section containing its text parts; reasoning is exported as a blockquote
12− * omitted to keep the exported transcript readable. Pure (no Android deps) so it can
16+ * and tool calls are summarized so the transcript shows what the agent *did*, not just
13− * be unit-tested directly.
17+ * what it said. Pure (no Android deps) so it can be unit-tested directly.
14 */
18 */
15fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
19fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
16 val sb = StringBuilder(messages.size * 128)
20 val sb = StringBuilder(messages.size * 128)
@@ -28,6 +32,7 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
28 when (part) {
32 when (part) {
29 is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
33 is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
30 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "> _${escapeMarkdown(it)}_" }
34 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "> _${escapeMarkdown(it)}_" }
35+ is ToolPart -> formatToolCall(part)
31 else -> null
36 else -> null
32 }
37 }
33 }
38 }
@@ -39,6 +44,42 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
39 return sb.toString().trimEnd()
44 return sb.toString().trimEnd()
40}
45}
41
46
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+
42/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
83/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
43private fun escapeMarkdown(text: String): String {
84private fun escapeMarkdown(text: String): String {
44 if (text.isEmpty()) return text
85 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
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.HelpOutline
11import androidx.compose.material.icons.filled.ContentCopy
12import androidx.compose.material.icons.filled.ContentCopy
12import androidx.compose.material3.Icon
13import androidx.compose.material3.Icon
13import androidx.compose.material3.IconButton
14import androidx.compose.material3.IconButton
@@ -24,14 +25,30 @@ import androidx.compose.ui.unit.dp
24import soy.iko.opencode.R
25import soy.iko.opencode.R
25import soy.iko.opencode.data.model.AssistantMessage
26import soy.iko.opencode.data.model.AssistantMessage
26import soy.iko.opencode.data.model.MessageWithParts
27import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.ModelOption
27import soy.iko.opencode.data.model.TextPart
29import soy.iko.opencode.data.model.TextPart
28import soy.iko.opencode.data.model.Tokens
30import soy.iko.opencode.data.model.Tokens
31+import soy.iko.opencode.data.model.UnknownMessage
29import soy.iko.opencode.data.model.UserMessage
32import soy.iko.opencode.data.model.UserMessage
30import soy.iko.opencode.data.network.NetworkConfig
33import soy.iko.opencode.data.network.NetworkConfig
31import soy.iko.opencode.ui.components.ImageLoadContext
34import soy.iko.opencode.ui.components.ImageLoadContext
32import soy.iko.opencode.ui.components.copyToClipboard
35import soy.iko.opencode.ui.components.copyToClipboard
33import soy.iko.opencode.ui.components.rememberRelativeTime
36import soy.iko.opencode.ui.components.rememberRelativeTime
34
37
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+
35/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
52/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
36@Composable
53@Composable
37fun MessageBubble(
54fun MessageBubble(
@@ -39,10 +56,42 @@ fun MessageBubble(
39 modifier: Modifier = Modifier,
56 modifier: Modifier = Modifier,
40 isRunning: Boolean = false,
57 isRunning: Boolean = false,
41 imageContext: ImageLoadContext? = null,
58 imageContext: ImageLoadContext? = null,
59+ modelLabel: String? = null,
42) {
60) {
43 when (message.info) {
61 when (message.info) {
44 is UserMessage -> UserBubble(message, imageContext, modifier)
62 is UserMessage -> UserBubble(message, imageContext, modifier)
63+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier)
45− else -> AssistantBlock(message, isRunning, 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)
46 }
95 }
47}
96}
48
97
@@ -80,7 +129,6 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
80 if (textToCopy != null) {
129 if (textToCopy != null) {
81 IconButton(
130 IconButton(
82 onClick = { copyToClipboard(context, "message", textToCopy) },
131 onClick = { copyToClipboard(context, "message", textToCopy) },
83− modifier = Modifier.size(20.dp),
84 ) {
132 ) {
85 Icon(
133 Icon(
86 Icons.Filled.ContentCopy,
134 Icons.Filled.ContentCopy,
@@ -96,18 +144,27 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
96}
144}
97
145
98@Composable
146@Composable
147+private fun AssistantBlock(
148+ message: MessageWithParts,
149+ isRunning: Boolean,
99−private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageContext: ImageLoadContext?, modifier: Modifier) {
150+ imageContext: ImageLoadContext?,
151+ modifier: Modifier,
152+ modelLabel: String? = null,
153+) {
100 Column(
154 Column(
101 modifier = modifier.fillMaxWidth(),
155 modifier = modifier.fillMaxWidth(),
102 verticalArrangement = Arrangement.spacedBy(8.dp),
156 verticalArrangement = Arrangement.spacedBy(8.dp),
103 ) {
157 ) {
104 val info = message.info
158 val info = message.info
105− if (info is AssistantMessage && info.modelID != null) {
159+ if (info is AssistantMessage) {
160+ val label = modelLabel ?: info.modelID
161+ if (label != null) {
106− Text(
162+ Text(
107− info.modelID,
163+ label,
108− style = MaterialTheme.typography.labelSmall,
164+ style = MaterialTheme.typography.labelSmall,
109− color = MaterialTheme.colorScheme.onSurfaceVariant,
165+ color = MaterialTheme.colorScheme.onSurfaceVariant,
110− )
166+ )
167+ }
111 }
168 }
112 for (part in message.parts) {
169 for (part in message.parts) {
113 PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext)
170 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(
113 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
113 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
114 )
114 )
115 } else {
115 } 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 } }
116 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
119 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
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+ }
117− items(filtered, key = { it.providerID to it.modelID }) { option ->
129+ items(opts, key = { it.providerID to it.modelID }) { option ->
118 val isSelected = option.providerID == selected?.providerID &&
130 val isSelected = option.providerID == selected?.providerID &&
119 option.modelID == selected?.modelID
131 option.modelID == selected?.modelID
120 Column(
132 Column(
@@ -145,13 +157,9 @@ fun ModelPickerSheet(
145 )
157 )
146 }
158 }
147 }
159 }
148− Text(
149− option.providerLabel,
150− style = MaterialTheme.typography.bodySmall,
151− color = MaterialTheme.colorScheme.onSurfaceVariant,
152− )
153 }
160 }
154 }
161 }
162+ }
155 }
163 }
156 }
164 }
157 }
165 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+145−27
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Description
18import androidx.compose.material.icons.filled.Error
18import androidx.compose.material.icons.filled.Error
19import androidx.compose.material.icons.filled.ExpandLess
19import androidx.compose.material.icons.filled.ExpandLess
20import androidx.compose.material.icons.filled.ExpandMore
20import androidx.compose.material.icons.filled.ExpandMore
21+import androidx.compose.material.icons.filled.HelpOutline
21import androidx.compose.material.icons.filled.Psychology
22import androidx.compose.material.icons.filled.Psychology
22import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
@@ -42,6 +43,8 @@ import androidx.compose.ui.semantics.stateDescription
42import androidx.compose.ui.text.font.FontFamily
43import androidx.compose.ui.text.font.FontFamily
43import androidx.compose.ui.res.stringResource
44import androidx.compose.ui.res.stringResource
44import androidx.compose.ui.unit.dp
45import androidx.compose.ui.unit.dp
46+import kotlinx.serialization.json.Json
47+import kotlinx.serialization.json.JsonElement
45import soy.iko.opencode.data.model.FilePart
48import soy.iko.opencode.data.model.FilePart
46import soy.iko.opencode.data.model.Part
49import soy.iko.opencode.data.model.Part
47import soy.iko.opencode.data.model.ReasoningPart
50import soy.iko.opencode.data.model.ReasoningPart
@@ -63,10 +66,30 @@ import soy.iko.opencode.ui.components.RemoteImage
63import soy.iko.opencode.ui.components.copyToClipboard
66import soy.iko.opencode.ui.components.copyToClipboard
64import soy.iko.opencode.ui.components.isImage
67import soy.iko.opencode.ui.components.isImage
65import soy.iko.opencode.ui.components.looksLikeDiff
68import soy.iko.opencode.ui.components.looksLikeDiff
69+import soy.iko.opencode.ui.components.showToast
66import soy.iko.opencode.R
70import soy.iko.opencode.R
67
71
68private const val COLLAPSED_LIMIT = 600
72private const val COLLAPSED_LIMIT = 600
69
73
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+
70/**
93/**
71 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
94 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
72 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
95 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
@@ -152,6 +175,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
152 }
175 }
153}
176}
154
177
178+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
155@Composable
179@Composable
156private fun ToolCallView(part: ToolPart, modifier: Modifier) {
180private fun ToolCallView(part: ToolPart, modifier: Modifier) {
157 val context = LocalContext.current
181 val context = LocalContext.current
@@ -171,6 +195,31 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
171 modifier = Modifier.padding(start = 6.dp),
195 modifier = Modifier.padding(start = 6.dp),
172 )
196 )
173 }
197 }
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+ }
174 val detail = when (val s = part.state) {
223 val detail = when (val s = part.state) {
175 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
224 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
176 is ToolError -> s.error ?: stringResource(R.string.error_generic)
225 is ToolError -> s.error ?: stringResource(R.string.error_generic)
@@ -185,38 +234,97 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
185 // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
234 // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
186 // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
235 // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
187 val isDiff = remember(display) { looksLikeDiff(display) }
236 val isDiff = remember(display) { looksLikeDiff(display) }
188− if (isDiff) {
237+ CollapsibleDetail(
238+ label = null,
189− DiffView(display)
239+ detail = display,
190− } else {
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+ )
191 Text(
289 Text(
192− display,
290+ label,
193− style = MaterialTheme.typography.bodySmall,
291+ style = MaterialTheme.typography.labelSmall,
194− fontFamily = FontFamily.Monospace,
292+ color = MaterialTheme.colorScheme.onSurfaceVariant,
195− modifier = Modifier.padding(top = 6.dp),
293+ modifier = Modifier.padding(start = 4.dp),
196 )
294 )
197 }
295 }
198− if (detail.length > COLLAPSED_LIMIT) {
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) {
199− TextButton(
308+ TextButton(
200− onClick = { expanded = !expanded },
309+ onClick = onToggleExpand,
201− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
310+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
202− modifier = Modifier.semantics {
203− stateDescription = if (expanded) expandedState else collapsedState
311+ modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
204− },
205− ) {
312+ ) {
206− Icon(
313+ Icon(
207− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
314+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
208− contentDescription = null,
315+ contentDescription = null,
209− modifier = Modifier.size(16.dp),
316+ modifier = Modifier.size(16.dp),
210− )
317+ )
211− Text(
318+ Text(
212− if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
319+ if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
213− style = MaterialTheme.typography.labelSmall,
320+ style = MaterialTheme.typography.labelSmall,
214− modifier = Modifier.padding(start = 4.dp),
321+ modifier = Modifier.padding(start = 4.dp),
215− )
322+ )
216− }
217 }
323 }
324+ }
325+ if (onCopy != null) {
218 TextButton(
326 TextButton(
219− onClick = { copyToClipboard(context, "output", detail) },
327+ onClick = onCopy,
220 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
328 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
221 modifier = Modifier.semantics(mergeDescendants = true) {},
329 modifier = Modifier.semantics(mergeDescendants = true) {},
222 ) {
330 ) {
@@ -262,10 +370,20 @@ private fun ToolStatusIcon(state: ToolState) {
262
370
263@Composable
371@Composable
264private fun FileChip(part: FilePart, modifier: Modifier) {
372private 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)
265 Row(
376 Row(
266 modifier = modifier
377 modifier = modifier
267 .clip(RoundedCornerShape(12.dp))
378 .clip(RoundedCornerShape(12.dp))
268 .background(MaterialTheme.colorScheme.surfaceVariant)
379 .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 }
269 .padding(horizontal = 12.dp, vertical = 6.dp),
387 .padding(horizontal = 12.dp, vertical = 6.dp),
270 verticalAlignment = Alignment.CenterVertically,
388 verticalAlignment = Alignment.CenterVertically,
271 ) {
389 ) {
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+21−12
@@ -38,23 +38,32 @@ import soy.iko.opencode.data.network.EventStreamClient
38fun ConnectionBanner(
38fun ConnectionBanner(
39 state: EventStreamClient.ConnectionState,
39 state: EventStreamClient.ConnectionState,
40 modifier: Modifier = Modifier,
40 modifier: Modifier = Modifier,
41+ isOnline: Boolean = true,
41 onRetry: (() -> Unit)? = null,
42 onRetry: (() -> Unit)? = null,
42) {
43) {
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 {
43− val text = when (state) {
50+ when (state) {
44− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
51+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
45− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
52+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
46− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
53+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
47− EventStreamClient.ConnectionState.Connected -> null
54+ EventStreamClient.ConnectionState.Connected -> null
55+ }
48 }
56 }
57+ val isOffline = !isOnline
49 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
58 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
50 // and disappearing instantly — a state change that's especially jarring when the
59 // and disappearing instantly — a state change that's especially jarring when the
51 // connection flaps, and was called out as a rough edge in the UX audit.
60 // connection flaps, and was called out as a rough edge in the UX audit.
52 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
61 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
53 if (text == null) return@AnimatedVisibility
62 if (text == null) return@AnimatedVisibility
54− // Distinguish a hard failure (e.g. bad credentials) from transient
63+ // Distinguish a hard failure (e.g. bad credentials) and an offline device from
55− // connecting/reconnecting states by switching to the error palette, so
64+ // transient connecting/reconnecting states by switching to the error palette,
56− // the banner conveys urgency without relying on text alone.
65+ // so the banner conveys urgency without relying on text alone.
57− val isFailed = state == EventStreamClient.ConnectionState.Failed
66+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
58 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
67 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
59 else MaterialTheme.colorScheme.tertiaryContainer
68 else MaterialTheme.colorScheme.tertiaryContainer
60 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
69 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
@@ -91,9 +100,9 @@ fun ConnectionBanner(
91 )
100 )
92 // Inline retry on the Failed banner so the user can recover without
101 // Inline retry on the Failed banner so the user can recover without
93 // leaving the screen. Hidden during transient states (the system is
102 // leaving the screen. Hidden during transient states (the system is
94− // already reconnecting) and when no callback is wired (callers that
103+ // already reconnecting), when offline (retry can't help), and when no
95− // don't have a reconnect path, e.g. the file browser).
104+ // callback is wired (callers that don't have a reconnect path).
96− if (isFailed && onRetry != null) {
105+ if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
97 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
106 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
98 Text(stringResource(R.string.retry_now), color = onContainer)
107 Text(stringResource(R.string.retry_now), color = onContainer)
99 }
108 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.KeyboardActions
17import androidx.compose.foundation.text.KeyboardOptions
18import androidx.compose.foundation.text.KeyboardOptions
18import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -41,6 +42,7 @@ import androidx.compose.runtime.remember
41import androidx.compose.ui.Alignment
42import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
43import androidx.compose.ui.Modifier
43import androidx.compose.ui.graphics.Color
44import androidx.compose.ui.graphics.Color
45+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
44import androidx.compose.ui.platform.testTag
46import androidx.compose.ui.platform.testTag
45import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.Role
46import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.contentDescription
@@ -96,6 +98,7 @@ fun FileBrowserScreen(
96 onNavigate = vm::open,
98 onNavigate = vm::open,
97 modifier = Modifier.fillMaxWidth(),
99 modifier = Modifier.fillMaxWidth(),
98 )
100 )
101+ val keyboardController = LocalSoftwareKeyboardController.current
99 OutlinedTextField(
102 OutlinedTextField(
100 value = state.query,
103 value = state.query,
101 onValueChange = vm::setQuery,
104 onValueChange = vm::setQuery,
@@ -105,6 +108,7 @@ fun FileBrowserScreen(
105 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
108 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
106 singleLine = true,
109 singleLine = true,
107 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
110 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
108 )
112 )
109
113
110 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
114 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
7import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeIn
8import androidx.compose.animation.fadeOut
8import androidx.compose.animation.fadeOut
9import androidx.compose.animation.togetherWith
9import androidx.compose.animation.togetherWith
10+import androidx.compose.foundation.background
10import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.BoxScope
12import androidx.compose.foundation.layout.Column
14import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Arrangement
13import androidx.compose.foundation.layout.Row
16import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.Spacer
17import androidx.compose.foundation.layout.Spacer
15import androidx.compose.foundation.layout.fillMaxSize
18import androidx.compose.foundation.layout.fillMaxSize
19+import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.width
22import androidx.compose.foundation.layout.width
19import androidx.compose.foundation.lazy.LazyColumn
23import androidx.compose.foundation.lazy.LazyColumn
24+import androidx.compose.foundation.lazy.LazyListState
20import androidx.compose.foundation.lazy.itemsIndexed
25import androidx.compose.foundation.lazy.itemsIndexed
26+import androidx.compose.foundation.lazy.rememberLazyListState
21import androidx.compose.foundation.rememberScrollState
27import androidx.compose.foundation.rememberScrollState
28+import androidx.compose.foundation.text.KeyboardActions
29+import androidx.compose.foundation.text.KeyboardOptions
22import androidx.compose.foundation.verticalScroll
30import androidx.compose.foundation.verticalScroll
23import androidx.compose.material.icons.Icons
31import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.automirrored.filled.ArrowBack
32import androidx.compose.material.icons.automirrored.filled.ArrowBack
33+import androidx.compose.material.icons.automirrored.filled.WrapText
34+import androidx.compose.material.icons.filled.Close
25import androidx.compose.material.icons.filled.ContentCopy
35import androidx.compose.material.icons.filled.ContentCopy
36+import androidx.compose.material.icons.filled.KeyboardArrowDown
37+import androidx.compose.material.icons.filled.KeyboardArrowUp
26import androidx.compose.material.icons.filled.Refresh
38import androidx.compose.material.icons.filled.Refresh
39+import androidx.compose.material.icons.filled.Search
27import androidx.compose.material.icons.filled.Share
40import androidx.compose.material.icons.filled.Share
28import androidx.compose.material3.CircularProgressIndicator
41import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.ExperimentalMaterial3Api
42import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,12 +44,15 @@ import androidx.compose.material3.FilterChip
31import androidx.compose.material3.Icon
44import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
45import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
46import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.Scaffold
48import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Text
49import androidx.compose.material3.Text
36import androidx.compose.material3.TextButton
50import androidx.compose.material3.TextButton
37import androidx.compose.material3.TopAppBar
51import androidx.compose.material3.TopAppBar
38import androidx.compose.runtime.Composable
52import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.LaunchedEffect
39import androidx.compose.runtime.getValue
54import androidx.compose.runtime.getValue
55+import androidx.compose.runtime.mutableIntStateOf
40import androidx.compose.runtime.mutableStateOf
56import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.remember
57import androidx.compose.runtime.remember
42import androidx.compose.runtime.saveable.rememberSaveable
58import androidx.compose.runtime.saveable.rememberSaveable
@@ -47,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
47import androidx.compose.ui.semantics.contentDescription
63import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.semantics
64import androidx.compose.ui.semantics.semantics
49import androidx.compose.ui.text.font.FontFamily
65import androidx.compose.ui.text.font.FontFamily
66+import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.style.TextOverflow
67import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.unit.dp
68import androidx.compose.ui.unit.dp
52import androidx.compose.ui.res.stringResource
69import androidx.compose.ui.res.stringResource
@@ -71,167 +88,374 @@ fun FileViewScreen(
71 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
88 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
72 val state by vm.state.collectAsStateWithLifecycle()
89 val state by vm.state.collectAsStateWithLifecycle()
73 val context = LocalContext.current
90 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 "/").
76 val filename = remember(path) {
91 val filename = remember(path) {
77 val trimmed = path.trimEnd('/')
92 val trimmed = path.trimEnd('/')
78 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
93 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
79 }
94 }
80 val shareLabel = stringResource(R.string.share)
95 val shareLabel = stringResource(R.string.share)
81− // Diff vs raw view toggle. Defaults to showing the diff when one exists (the common
96+ // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
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.
97+ // switch to the raw new content. Persisted via rememberSaveable across rotation.
85 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
98 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
86 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
99 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
87 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
100 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+ }
88
120
89 Scaffold(
121 Scaffold(
90 topBar = {
122 topBar = {
91− TopAppBar(
123+ FileViewTopBar(
92− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
124+ filename = filename,
93− navigationIcon = {
125+ loading = state.loading,
94− IconButton(onClick = onBack) {
126+ content = rawText,
95− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
127+ wrap = wrap,
96− }
128+ onBack = onBack,
97− },
129+ onReload = { vm.reload() },
98− actions = {
130+ onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
99− // In-place refresh: a file's content can change on the server (the
131+ onToggleWrap = { wrap = !wrap },
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) }) {
132+ onCopy = { copyToClipboard(context, filename, rawText) },
108− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
133+ onShare = {
109− }
110− IconButton(onClick = {
111− val send = Intent(Intent.ACTION_SEND).apply {
134+ val send = Intent(Intent.ACTION_SEND).apply {
112− type = "text/plain"
135+ type = "text/plain"
113− putExtra(Intent.EXTRA_SUBJECT, filename)
136+ putExtra(Intent.EXTRA_SUBJECT, filename)
114− putExtra(Intent.EXTRA_TEXT, content)
137+ putExtra(Intent.EXTRA_TEXT, rawText)
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 }
138 }
139+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
140+ .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
124 },
141 },
125 )
142 )
126 },
143 },
127 ) { padding ->
144 ) { padding ->
128 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
145 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
129− // AnimatedContent cross-fades between the loading / error / content states
146+ // AnimatedContent cross-fades between loading / error / content states so the
130− // so the viewer doesn't snap abruptly when a reload finishes or fails. The
147+ // viewer doesn't snap abruptly when a reload finishes or fails.
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.
133 AnimatedContent(
148 AnimatedContent(
134 targetState = state,
149 targetState = state,
135 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
150 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
136 label = "fileViewState",
151 label = "fileViewState",
137 ) { s ->
152 ) { s ->
138− when {
153+ FileViewStateContent(
139− s.loading -> {
154+ state = s,
140− val loadingLabel = stringResource(R.string.loading)
155+ filename = filename,
141− CircularProgressIndicator(
156+ showToggle = showToggle,
142− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
157+ showDiff = showDiff,
143− )
158+ onSetShowDiff = { showDiff = it },
144− }
159+ findActive = findActive,
145− s.error != null -> Column(
160+ findQuery = findQuery,
146− modifier = Modifier.align(Alignment.Center).padding(24.dp),
161+ onQueryChange = { findQuery = it; matchPos = 0 },
147− horizontalAlignment = Alignment.CenterHorizontally,
162+ matchIndices = matchIndices,
148− ) {
163+ matchPos = matchPos,
149− Text(
164+ onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
150− s.error ?: "",
165+ onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
151− color = MaterialTheme.colorScheme.error,
166+ onCloseFind = { findActive = false; findQuery = "" },
152− )
167+ wrap = wrap,
153− Spacer(Modifier.size(12.dp))
168+ listState = listState,
154− TextButton(onClick = { vm.reload() }) {
169+ onRetry = { vm.reload() },
155− Text(stringResource(R.string.retry))
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))
156− }
195+ }
157− }
196+ },
158− s.content?.isBinary == true -> Text(
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) {
159− stringResource(R.string.binary_file),
206+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
160− modifier = Modifier.align(Alignment.Center).padding(24.dp),
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),
161− color = MaterialTheme.colorScheme.onSurfaceVariant,
213+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
162 )
214 )
163− else -> {
215+ }
164− val content = s.content
216+ IconButton(onClick = onCopy) {
165− // Diff/raw toggle: when both a diff and raw content are present,
217+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
166− // let the user switch. The chip row sits above the content so
218+ }
167− // it's always reachable while scrolling.
219+ IconButton(onClick = onShare) {
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),
220+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
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− }
213 }
221 }
214 }
222 }
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+ )
215 }
375 }
216 }
376 }
217}
377}
218
378
219@Composable
379@Composable
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,
220−private fun FileTextContent(text: String, filename: String, topInset: androidx.compose.ui.unit.Dp) {
430+ topInset: androidx.compose.ui.unit.Dp,
431+ wrap: Boolean,
432+ findQuery: String,
433+ matchIndices: List<Int>,
434+ listState: LazyListState,
435+) {
221 val lines = remember(text) { text.split("\n") }
436 val lines = remember(text) { text.split("\n") }
222 val gutterWidth = remember(lines.size) {
437 val gutterWidth = remember(lines.size) {
223 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
438 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
224 }
439 }
225 val hScrollState = rememberScrollState()
440 val hScrollState = rememberScrollState()
226 val palette = rememberHighlightPalette()
441 val palette = rememberHighlightPalette()
442+ val q = findQuery.trim()
443+ val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
227 LazyColumn(
444 LazyColumn(
445+ state = listState,
228 modifier = Modifier
446 modifier = Modifier
229 .fillMaxSize()
447 .fillMaxSize()
230 .padding(top = topInset)
448 .padding(top = topInset)
231 .padding(8.dp),
449 .padding(8.dp),
232 ) {
450 ) {
233 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
451 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
452+ val isMatch = q.isNotEmpty() && index in matchSet
453+ Row(
454+ modifier = Modifier
455+ .fillMaxWidth()
234− Row(modifier = Modifier.horizontalScroll(hScrollState)) {
456+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
457+ .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
458+ ) {
235 Text(
459 Text(
236 "${index + 1}",
460 "${index + 1}",
237 modifier = Modifier.width(gutterWidth),
461 modifier = Modifier.width(gutterWidth),
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+27−3
@@ -62,9 +62,10 @@ fun ServerEditScreen(
62 container: AppContainer,
62 container: AppContainer,
63 profileId: String?,
63 profileId: String?,
64 onDone: () -> Unit,
64 onDone: () -> Unit,
65+ sourceId: String? = null,
65) {
66) {
66 val vm: ServerEditViewModel =
67 val vm: ServerEditViewModel =
67− viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
68+ viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
68 val state by vm.state.collectAsStateWithLifecycle()
69 val state by vm.state.collectAsStateWithLifecycle()
69 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
70 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
70 var passwordVisible by rememberSaveable { mutableStateOf(false) }
71 var passwordVisible by rememberSaveable { mutableStateOf(false) }
@@ -159,14 +160,26 @@ private fun ServerEditForm(
159 )
160 )
160 OutlinedTextField(
161 OutlinedTextField(
161 value = state.baseUrl,
162 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) } },
163 label = { Text(stringResource(R.string.base_url)) },
164 label = { Text(stringResource(R.string.base_url)) },
164 placeholder = { Text(stringResource(R.string.base_url_hint)) },
165 placeholder = { Text(stringResource(R.string.base_url_hint)) },
165 singleLine = true,
166 singleLine = true,
166 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
167 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
167 supportingText = {
168 supportingText = {
168 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
169 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
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 {
169− Text(stringResource(R.string.invalid_url))
181+ Text(stringResource(R.string.invalid_url))
182+ }
170 }
183 }
171 },
184 },
172 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
185 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
@@ -187,6 +200,17 @@ private fun ServerEditForm(
187 Text(stringResource(R.string.check_connectivity))
200 Text(stringResource(R.string.check_connectivity))
188 }
201 }
189 }
202 }
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+ }
190 if (!state.authFieldsVisible) {
214 if (!state.authFieldsVisible) {
191 Text(
215 Text(
192 stringResource(R.string.auth_help),
216 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(
38 val testingCredentials: Boolean = false,
38 val testingCredentials: Boolean = false,
39 /** Non-null when the last credential test succeeded (true) or failed (false). */
39 /** Non-null when the last credential test succeeded (true) or failed (false). */
40 val credentialsResult: Boolean? = null,
40 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,
41) {
44) {
42 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
45 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
43 val isNew: Boolean get() = id == null
46 val isNew: Boolean get() = id == null
@@ -67,9 +70,22 @@ fun isValidUrl(url: String): Boolean = try {
67 false
70 false
68}
71}
69
72
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+
70class ServerEditViewModel(
85class ServerEditViewModel(
71 private val container: AppContainer,
86 private val container: AppContainer,
72 private val profileId: String?,
87 private val profileId: String?,
88+ private val sourceId: String? = null,
73) : ViewModel() {
89) : ViewModel() {
74
90
75 private val _state = MutableStateFlow(ServerEditState(id = profileId))
91 private val _state = MutableStateFlow(ServerEditState(id = profileId))
@@ -109,6 +125,31 @@ class ServerEditViewModel(
109 )
125 )
110 return@launch
126 return@launch
111 }
127 }
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+ }
112 // New-profile form: seed an initial snapshot of empty values so isDirty
153 // New-profile form: seed an initial snapshot of empty values so isDirty
113 // becomes true the moment the user types anything.
154 // becomes true the moment the user types anything.
114 _state.update {
155 _state.update {
@@ -127,7 +168,7 @@ class ServerEditViewModel(
127 fun probe() {
168 fun probe() {
128 val s = _state.value
169 val s = _state.value
129 if (!s.canSave || s.probing) return
170 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) }
131 viewModelScope.launch {
172 viewModelScope.launch {
132 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
173 val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
133 result.onSuccess { pr ->
174 result.onSuccess { pr ->
@@ -137,21 +178,24 @@ class ServerEditViewModel(
137 probing = false,
178 probing = false,
138 authFieldsVisible = false,
179 authFieldsVisible = false,
139 error = null,
180 error = null,
181+ probeReachable = true,
140 )
182 )
141 is ProbeResult.NeedsAuth -> it.copy(
183 is ProbeResult.NeedsAuth -> it.copy(
142 probing = false,
184 probing = false,
143 authFieldsVisible = true,
185 authFieldsVisible = true,
144 error = null,
186 error = null,
187+ probeReachable = false,
145 )
188 )
146 is ProbeResult.Unreachable -> it.copy(
189 is ProbeResult.Unreachable -> it.copy(
147 probing = false,
190 probing = false,
148 authFieldsVisible = false,
191 authFieldsVisible = false,
149 error = pr.error,
192 error = pr.error,
193+ probeReachable = false,
150 )
194 )
151 }
195 }
152 }
196 }
153 }.onFailure { e ->
197 }.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) }
155 }
199 }
156 }
200 }
157 }
201 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+33
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.CheckCircle
18import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.Edit
22import androidx.compose.material.icons.filled.Edit
@@ -62,6 +63,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
62import soy.iko.opencode.data.model.ServerProfile
63import soy.iko.opencode.data.model.ServerProfile
63import soy.iko.opencode.di.AppContainer
64import soy.iko.opencode.di.AppContainer
64import soy.iko.opencode.R
65import soy.iko.opencode.R
66+import soy.iko.opencode.ui.components.rememberRelativeTime
65import soy.iko.opencode.ui.vmFactory
67import soy.iko.opencode.ui.vmFactory
66
68
67@OptIn(ExperimentalMaterial3Api::class)
69@OptIn(ExperimentalMaterial3Api::class)
@@ -71,6 +73,7 @@ fun ServerListScreen(
71 onConnected: () -> Unit,
73 onConnected: () -> Unit,
72 onAddProfile: () -> Unit,
74 onAddProfile: () -> Unit,
73 onEditProfile: (String) -> Unit,
75 onEditProfile: (String) -> Unit,
76+ onDuplicateProfile: (String) -> Unit,
74) {
77) {
75 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
78 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
76 val profiles by vm.profiles.collectAsStateWithLifecycle()
79 val profiles by vm.profiles.collectAsStateWithLifecycle()
@@ -188,6 +191,7 @@ fun ServerListScreen(
188 style = MaterialTheme.typography.labelSmall,
191 style = MaterialTheme.typography.labelSmall,
189 color = MaterialTheme.colorScheme.primary,
192 color = MaterialTheme.colorScheme.primary,
190 )
193 )
194+ LastUsedText(profile.lastUsed)
191 }
195 }
192 Icon(
196 Icon(
193 Icons.Filled.CheckCircle,
197 Icons.Filled.CheckCircle,
@@ -200,6 +204,9 @@ fun ServerListScreen(
200 IconButton(onClick = { onEditProfile(profile.id) }) {
204 IconButton(onClick = { onEditProfile(profile.id) }) {
201 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
205 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
202 }
206 }
207+ IconButton(onClick = { onDuplicateProfile(profile.id) }) {
208+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
209+ }
203 }
210 }
204 }
211 }
205 } else {
212 } else {
@@ -261,6 +268,7 @@ fun ServerListScreen(
261 maxLines = 1,
268 maxLines = 1,
262 overflow = TextOverflow.Ellipsis,
269 overflow = TextOverflow.Ellipsis,
263 )
270 )
271+ LastUsedText(profile.lastUsed)
264 }
272 }
265 if (connectingId == profile.id) {
273 if (connectingId == profile.id) {
266 val connectingLabel = stringResource(R.string.connecting)
274 val connectingLabel = stringResource(R.string.connecting)
@@ -271,6 +279,9 @@ fun ServerListScreen(
271 IconButton(onClick = { onEditProfile(profile.id) }) {
279 IconButton(onClick = { onEditProfile(profile.id) }) {
272 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
280 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
273 }
281 }
282+ IconButton(onClick = { onDuplicateProfile(profile.id) }) {
283+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
284+ }
274 IconButton(onClick = { pendingDeleteId = profile.id }) {
285 IconButton(onClick = { pendingDeleteId = profile.id }) {
275 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
286 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
276 }
287 }
@@ -312,6 +323,28 @@ fun ServerListScreen(
312 }
323 }
313}
324}
314
325
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+
315@Composable
348@Composable
316private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
349private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
317 Box(modifier = modifier, contentAlignment = Alignment.Center) {
350 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
19import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.text.KeyboardOptions
20import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.Logout
21import androidx.compose.material.icons.automirrored.filled.Logout
22+import androidx.compose.material.icons.automirrored.filled.Sort
22import androidx.compose.material.icons.filled.Add
23import androidx.compose.material.icons.filled.Add
23import androidx.compose.material.icons.filled.ArrowDropDown
24import androidx.compose.material.icons.filled.ArrowDropDown
24import androidx.compose.material.icons.filled.ChatBubbleOutline
25import androidx.compose.material.icons.filled.ChatBubbleOutline
25import androidx.compose.material.icons.filled.CheckCircle
26import androidx.compose.material.icons.filled.CheckCircle
27+import androidx.compose.material.icons.filled.ContentCopy
26import androidx.compose.material.icons.filled.Delete
28import androidx.compose.material.icons.filled.Delete
29+import androidx.compose.material.icons.filled.Dns
27import androidx.compose.material.icons.filled.Edit
30import androidx.compose.material.icons.filled.Edit
28import androidx.compose.material.icons.filled.Folder
31import androidx.compose.material.icons.filled.Folder
29import androidx.compose.material.icons.filled.Refresh
32import androidx.compose.material.icons.filled.Refresh
@@ -63,6 +66,7 @@ import androidx.compose.ui.Alignment
63import androidx.compose.ui.Modifier
66import androidx.compose.ui.Modifier
64import androidx.compose.ui.draw.clip
67import androidx.compose.ui.draw.clip
65import androidx.compose.ui.platform.LocalHapticFeedback
68import androidx.compose.ui.platform.LocalHapticFeedback
69+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
66import androidx.compose.ui.platform.testTag
70import androidx.compose.ui.platform.testTag
67import androidx.compose.ui.semantics.Role
71import androidx.compose.ui.semantics.Role
68import androidx.compose.ui.semantics.contentDescription
72import androidx.compose.ui.semantics.contentDescription
@@ -104,6 +108,7 @@ fun SessionListScreen(
104 val creating by vm.creating.collectAsStateWithLifecycle()
108 val creating by vm.creating.collectAsStateWithLifecycle()
105 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
109 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
106 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
110 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
111+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
107 val connectedId = activeConnection?.profile?.id
112 val connectedId = activeConnection?.profile?.id
108 val haptics = LocalHapticFeedback.current
113 val haptics = LocalHapticFeedback.current
109 val snackbar = remember { SnackbarHostState() }
114 val snackbar = remember { SnackbarHostState() }
@@ -113,6 +118,7 @@ fun SessionListScreen(
113 // each card spinning up its own coroutine + lifecycle observer while scrolling.
118 // each card spinning up its own coroutine + lifecycle observer while scrolling.
114 val timeTick = rememberRelativeTimeTick()
119 val timeTick = rememberRelativeTimeTick()
115 var showServerMenu by rememberSaveable { mutableStateOf(false) }
120 var showServerMenu by rememberSaveable { mutableStateOf(false) }
121+ var showSortMenu by rememberSaveable { mutableStateOf(false) }
116 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
122 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
117 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
123 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
118 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
124 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
@@ -167,6 +173,21 @@ fun SessionListScreen(
167 )
173 )
168 },
174 },
169 actions = {
175 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+ }
170 IconButton(onClick = { vm.refresh() }) {
191 IconButton(onClick = { vm.refresh() }) {
171 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
192 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
172 }
193 }
@@ -204,6 +225,7 @@ fun SessionListScreen(
204 ConnectionBanner(
225 ConnectionBanner(
205 state = connectionState,
226 state = connectionState,
206 modifier = Modifier.align(Alignment.TopCenter),
227 modifier = Modifier.align(Alignment.TopCenter),
228+ isOnline = isOnline,
207 onRetry = { vm.retryConnection() },
229 onRetry = { vm.retryConnection() },
208 )
230 )
209 SessionListBody(
231 SessionListBody(
@@ -313,6 +335,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
313 modifier = Modifier.align(Alignment.Center),
335 modifier = Modifier.align(Alignment.Center),
314 )
336 )
315 else -> Column(modifier = Modifier.fillMaxSize()) {
337 else -> Column(modifier = Modifier.fillMaxSize()) {
338+ val keyboardController = LocalSoftwareKeyboardController.current
316 OutlinedTextField(
339 OutlinedTextField(
317 value = state.query,
340 value = state.query,
318 onValueChange = onQueryChange,
341 onValueChange = onQueryChange,
@@ -322,6 +345,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
322 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
345 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
323 singleLine = true,
346 singleLine = true,
324 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
347 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
348+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
325 )
349 )
326 val sessions = remember(state.sessions, state.query, state.previews) {
350 val sessions = remember(state.sessions, state.query, state.previews) {
327 // Fast-path an empty query: filtered just returns sessions, so skip
351 // 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
8import soy.iko.opencode.data.model.SessionDeleted
8import soy.iko.opencode.data.model.SessionDeleted
9import soy.iko.opencode.data.model.SessionUpdated
9import soy.iko.opencode.data.model.SessionUpdated
10import soy.iko.opencode.data.model.TextPart
10import 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
11import soy.iko.opencode.data.network.EventStreamClient
15import soy.iko.opencode.data.network.EventStreamClient
12import soy.iko.opencode.data.network.NetworkConfig
16import soy.iko.opencode.data.network.NetworkConfig
13import soy.iko.opencode.di.AppContainer
17import soy.iko.opencode.di.AppContainer
@@ -36,6 +40,9 @@ import kotlinx.coroutines.launch
36import kotlinx.coroutines.sync.Semaphore
40import kotlinx.coroutines.sync.Semaphore
37import kotlinx.coroutines.sync.withPermit
41import kotlinx.coroutines.sync.withPermit
38
42
43+/** Sort order for the session list. */
44+enum class SessionSortMode { RECENT, TITLE }
45+
39@Immutable
46@Immutable
40data class SessionListState(
47data class SessionListState(
41 val sessions: List<Session> = emptyList(),
48 val sessions: List<Session> = emptyList(),
@@ -43,6 +50,7 @@ data class SessionListState(
43 val previews: Map<String, String> = emptyMap(),
50 val previews: Map<String, String> = emptyMap(),
44 val loading: Boolean = true,
51 val loading: Boolean = true,
45 val error: String? = null,
52 val error: String? = null,
53+ val sortMode: SessionSortMode = SessionSortMode.RECENT,
46) {
54) {
47 /** Sessions filtered by the current search query (title or preview text). */
55 /** Sessions filtered by the current search query (title or preview text). */
48 val filtered: List<Session>
56 val filtered: List<Session>
@@ -56,6 +64,15 @@ data class SessionListState(
56 }
64 }
57}
65}
58
66
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+
59class SessionListViewModel(private val container: AppContainer) : ViewModel() {
76class SessionListViewModel(private val container: AppContainer) : ViewModel() {
60
77
61 /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
78 /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
@@ -221,7 +238,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
221 // new state (and trigger recomposition) when the batch held no real changes
238 // new state (and trigger recomposition) when the batch held no real changes
222 // (e.g. a duplicate SessionUpdated for an unchanged session).
239 // (e.g. a duplicate SessionUpdated for an unchanged session).
223 if (!anyChanged) return@update s
240 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)
225 // If the new ordering matches the existing one (e.g. only the already-newest
242 // If the new ordering matches the existing one (e.g. only the already-newest
226 // session updated its timestamp), reuse the same list instance so downstream
243 // session updated its timestamp), reuse the same list instance so downstream
227 // skips a recomposition for an identical reference.
244 // skips a recomposition for an identical reference.
@@ -263,7 +280,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
263 try {
280 try {
264 runCatchingCancellable { conn.repository.listSessions() }
281 runCatchingCancellable { conn.repository.listSessions() }
265 .onSuccess { list ->
282 .onSuccess { list ->
266− val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
283+ val sorted = list.sortedByMode(_state.value.sortMode)
267 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
284 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
268 loadPreviews(sorted)
285 loadPreviews(sorted)
269 }
286 }
@@ -388,7 +405,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
388 }
405 }
389 .onFailure {
406 .onFailure {
390 // Restore the session to the list since the delete failed.
407 // 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)) }
392 _transientErrors.tryEmit(container.friendlyError(it))
409 _transientErrors.tryEmit(container.friendlyError(it))
393 }
410 }
394 }
411 }
@@ -401,11 +418,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
401 if (pending != null && pending.id == sessionId) {
418 if (pending != null && pending.id == sessionId) {
402 pendingDelete = null
419 pendingDelete = null
403 _state.update { s ->
420 _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))
405 }
422 }
406 }
423 }
407 }
424 }
408
425
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+
409 fun renameSession(session: Session, newTitle: String) {
431 fun renameSession(session: Session, newTitle: String) {
410 val conn = container.activeConnection.value ?: return
432 val conn = container.activeConnection.value ?: return
411 val title = newTitle.trim()
433 val title = newTitle.trim()
@@ -479,15 +501,29 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
479}
501}
480
502
481/**
503/**
482− * Fetch the last text part of a session's most recent message, truncated to
504+ * Fetch a preview of a session's most recent activity, truncated to
483− * [NetworkConfig.previewTextMaxLength]. Returns null on failure or when the
505+ * [NetworkConfig.previewTextMaxLength]. Returns the last text part if present; otherwise
484− * last message has no text part.
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.
485 */
509 */
486private suspend fun fetchSessionPreview(
510private suspend fun fetchSessionPreview(
487 api: soy.iko.opencode.data.network.OpencodeApiClient,
511 api: soy.iko.opencode.data.network.OpencodeApiClient,
488 sessionId: String,
512 sessionId: String,
489): String? = runCatchingCancellable {
513): String? = runCatchingCancellable {
490 api.listMessages(sessionId).lastOrNull()?.let { msg ->
514 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
492 }
528 }
493}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
529}.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:
67 val scope = rememberCoroutineScope()
67 val scope = rememberCoroutineScope()
68 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
68 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
69 val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
69 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
70 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
72 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
71 val context = LocalContext.current
73 val context = LocalContext.current
72 // Crash count badge: surface that there are reports to look at without making the
74 // 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:
117 }
119 }
118
120
119 Spacer(Modifier.size(8.dp))
121 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))
120 Row(
153 Row(
121 modifier = Modifier
154 modifier = Modifier
122 .fillMaxWidth()
155 .fillMaxWidth()
123 .toggleable(
156 .toggleable(
124− value = dynamicColor,
157+ value = sendOnEnter,
125− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
158+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
126 role = Role.Switch,
159 role = Role.Switch,
127 )
160 )
128 .padding(vertical = 8.dp),
161 .padding(vertical = 8.dp),
129 verticalAlignment = Alignment.CenterVertically,
162 verticalAlignment = Alignment.CenterVertically,
130 ) {
163 ) {
131 Column(modifier = Modifier.weight(1f)) {
164 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)
133 Text(
166 Text(
134− stringResource(R.string.dynamic_color_desc),
167+ stringResource(R.string.send_on_enter_desc),
135 style = MaterialTheme.typography.bodySmall,
168 style = MaterialTheme.typography.bodySmall,
136 color = MaterialTheme.colorScheme.onSurfaceVariant,
169 color = MaterialTheme.colorScheme.onSurfaceVariant,
137 )
170 )
138 }
171 }
139 Switch(
172 Switch(
140− checked = dynamicColor,
173+ checked = sendOnEnter,
141 onCheckedChange = null,
174 onCheckedChange = null,
142 )
175 )
143 }
176 }
app/src/main/res/values/strings.xml+25
@@ -226,4 +226,29 @@
226 <string name="notif_running_text">Agent is working…</string>
226 <string name="notif_running_text">Agent is working…</string>
227 <string name="notif_completed_title">Session ready</string>
227 <string name="notif_completed_title">Session ready</string>
228 <string name="notif_completed_text">opencode replied in “%1$s”.</string>
228 <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>
229</resources>
254</resources>
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+34
@@ -7,6 +7,8 @@ import soy.iko.opencode.data.model.AssistantMessage
7import soy.iko.opencode.data.model.MessageWithParts
7import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.ReasoningPart
8import soy.iko.opencode.data.model.ReasoningPart
9import soy.iko.opencode.data.model.TextPart
9import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.model.ToolCompleted
11+import soy.iko.opencode.data.model.ToolPart
10import soy.iko.opencode.data.model.UserMessage
12import soy.iko.opencode.data.model.UserMessage
11
13
12class ConversationExportTest {
14class ConversationExportTest {
@@ -53,4 +55,36 @@ class ConversationExportTest {
53 // No message bodies to export, but the title header is still emitted.
55 // No message bodies to export, but the title header is still emitted.
54 assertEquals("# x", buildConversationMarkdown(emptyList(), title = "x"))
56 assertEquals("# x", buildConversationMarkdown(emptyList(), title = "x"))
55 }
57 }
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+ }
56}
90}
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+63
@@ -398,4 +398,67 @@ class ServerEditViewModelTest {
398 // With default fake, probe succeeds → no error
398 // With default fake, probe succeeds → no error
399 assertNull(vm.state.value.error)
399 assertNull(vm.state.value.error)
400 }
400 }
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+ }
401}
464}
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+62
@@ -25,7 +25,10 @@ import soy.iko.opencode.data.model.ServerProfile
25import soy.iko.opencode.data.model.Session
25import soy.iko.opencode.data.model.Session
26import soy.iko.opencode.data.model.SessionDeleted
26import soy.iko.opencode.data.model.SessionDeleted
27import soy.iko.opencode.data.model.SessionUpdated
27import soy.iko.opencode.data.model.SessionUpdated
28+import soy.iko.opencode.data.model.AssistantMessage
28import soy.iko.opencode.data.model.TextPart
29import soy.iko.opencode.data.model.TextPart
30+import soy.iko.opencode.data.model.ToolCompleted
31+import soy.iko.opencode.data.model.ToolPart
29import soy.iko.opencode.data.model.UserMessage
32import soy.iko.opencode.data.model.UserMessage
30import soy.iko.opencode.data.model.MessageWithParts
33import soy.iko.opencode.data.model.MessageWithParts
31import soy.iko.opencode.ui.testing.FakeAppContainer
34import soy.iko.opencode.ui.testing.FakeAppContainer
@@ -458,4 +461,63 @@ class SessionListViewModelTest {
458 api.listMessagesCalls.size,
461 api.listMessagesCalls.size,
459 )
462 )
460 }
463 }
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+ }
461}
523}