git.iko.soy/opencode-android
on master help sign in
Add 19 UX improvements: chat pull-to-refresh, undo for server/crash-report deletion, swipe-to-delete on server and diagnostics lists, inline retry on failed connection banner, add-server menu item, complete Tokyo Night theme palette with Typography/Shapes, syntax highlighting in file viewer, diff/raw toggle, unread count badges, theme preview swatches, draft length countdown, example prompt chips, animated state transitions, and FileBrowser empty-state consistency
commite131d1e97ecdc5d9daf292cbcfeedec30b2da179 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 10:26 (3 months ago) parent9b25fb8
21 files changed+1274−288
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+14−7 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+63−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−2 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+245 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+28−3 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+33−4 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+108−45 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+134−42 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+41−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+194−126 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−3 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+139−46 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+40−1 app/src/main/java/soy/iko/opencode/ui/theme/Shapes.kt+19 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+84 app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+87 app/src/main/res/values/strings.xml+14−2 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+2−2
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13
@@ -66,6 +66,14 @@ object NetworkConfig {
6666 * Undo snackbar lets the user cancel it. */
6767 const val undoDeleteDelayMs = 5_000L
6868
69+ /** Grace period before a deferred server-profile delete actually fires, during which
70+ * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
71+ const val undoServerDeleteDelayMs = 5_000L
72+
73+ /** Grace period before a deferred crash-report delete actually fires, during which
74+ * an Undo snackbar lets the user cancel it. */
75+ const val undoReportDeleteDelayMs = 5_000L
76+
6977 // --- In-memory message store (SessionRepository.MessageStore) ---
7078
7179 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
@@ -91,6 +99,11 @@ object NetworkConfig {
9199 * an unbounded string) while leaving plenty of room for long prompts. */
92100 const val maxDraftLengthChars = 32_000
93101
102+ /** Show the "remaining characters" supportingText under the chat input once the
103+ * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
104+ * visible before it silently kicks in but doesn't clutter a normal short prompt. */
105+ const val draftCountdownThresholdFraction = 0.8f
106+
94107 // --- Profile store (ServerEditViewModel) ---
95108
96109 /** Timeout for loading a profile from DataStore before giving up. */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+14−7
@@ -122,9 +122,12 @@ open class AppContainer private constructor(
122122 private val _currentSession = MutableStateFlow<String?>(null)
123123 open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
124124
125− /** Session ids that received activity while not being viewed. */
126− private val _unread = MutableStateFlow<Set<String>>(emptySet())
127− open val unread: StateFlow<Set<String>> = _unread.asStateFlow()
125+ /** Session ids that received activity while not being viewed, mapped to the count
126+ * of unread message events. A count (not just a presence set) lets the session
127+ * list badge show "3 unread" instead of a bare dot, so the user can tell whether
128+ * one reply or a whole burst arrived. */
129+ private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
130+ open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
128131
129132 /** True when any assistant run is actively streaming across all sessions. Drives the
130133 * disconnect confirmation on the session list (disconnecting mid-run kills it). */
@@ -139,10 +142,12 @@ open class AppContainer private constructor(
139142 }
140143 }
141144
142− /** Restore a session's unread badge after a failed server switch reconnects. */
145+ /** Restore a session's unread badge (preserving its prior count) after a failed
146+ * server switch reconnects. Falls back to a count of 1 when the prior count isn't
147+ * known, since the restore path only runs for sessions that were badged before. */
143148 open fun restoreUnread(id: String) {
144149 if (id != _currentSession.value) {
145− _unread.update { it + id }
150+ _unread.update { it + (id to (it[id] ?: 1)) }
146151 }
147152 }
148153
@@ -245,7 +250,9 @@ open class AppContainer private constructor(
245250 }
246251 val sid = sessionOf(event) ?: return@collect
247252 if (sid != _currentSession.value) {
248− _unread.update { it + sid }
253+ // Increment the unread count for this session so the badge can
254+ // show "N unread" instead of just a dot.
255+ _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
249256 }
250257 // Track sessions actively streaming so we know which idle events
251258 // represent a finished run worth notifying about.
@@ -338,7 +345,7 @@ open class AppContainer private constructor(
338345 _activeConnection.value = null
339346 activeRuns.clear()
340347 _anyRunActive.value = false
341− _unread.value = emptySet()
348+ _unread.value = emptyMap()
342349 val now = System.currentTimeMillis()
343350 val resolved = profileStore.resolve(profile)
344351 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+2
@@ -106,6 +106,7 @@ fun OpencodeApp(container: AppContainer) {
106106 scope.launch { runCatchingCancellable { container.disconnect() } }
107107 navController.popBackStack(Routes.SERVERS, inclusive = false)
108108 },
109+ onAddServer = { navController.navigate(Routes.serverEdit()) },
109110 )
110111 } else {
111112 SessionListScreen(
@@ -126,6 +127,7 @@ fun OpencodeApp(container: AppContainer) {
126127 },
127128 onOpenFiles = { navController.navigate(Routes.FILES) },
128129 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
130+ onAddServer = { navController.navigate(Routes.serverEdit()) },
129131 )
130132 }
131133 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+63−1
@@ -49,6 +49,7 @@ import androidx.compose.material3.Surface
4949import androidx.compose.material3.Text
5050import androidx.compose.material3.TextButton
5151import androidx.compose.material3.TopAppBar
52+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
5253import androidx.compose.runtime.Composable
5354import androidx.compose.runtime.CompositionLocalProvider
5455import androidx.compose.runtime.DisposableEffect
@@ -392,6 +393,12 @@ fun ChatScreen(
392393 ConnectionBanner(
393394 state = connectionState,
394395 modifier = Modifier.align(Alignment.TopCenter),
396+ // On a hard failure, retry by forcing an SSE reconnect (which
397+ // re-seeds from REST). refreshMessages is the right recovery path
398+ // when the connection is present but the stream died; reconnect()
399+ // is the path when the whole connection is gone (handled by the
400+ // separate "Not connected" state below).
401+ onRetry = { vm.refreshMessages() },
395402 )
396403 if (loading && messages.isEmpty()) {
397404 val loadingLabel = stringResource(R.string.loading)
@@ -433,9 +440,20 @@ fun ChatScreen(
433440 }
434441 } else if (messages.isEmpty()) {
435442 EmptyConversation(
443+ onSuggestion = { vm.updateDraft(it) },
436444 modifier = Modifier.align(Alignment.Center),
437445 )
438446 } else {
447+ // Pull-to-refresh wraps the message list so the user can force an SSE
448+ // reconnect + re-seed with the same gesture they use on the session and
449+ // file lists, instead of hunting for the top-bar refresh icon. The empty
450+ // / loading / error states above stay outside the PTR box since there's
451+ // no list content to pull against.
452+ PullToRefreshBox(
453+ isRefreshing = refreshing,
454+ onRefresh = { vm.refreshMessages() },
455+ modifier = Modifier.fillMaxSize(),
456+ ) {
439457 val lastMessageId = messages.lastOrNull()?.info?.id
440458 LazyColumn(
441459 state = listState,
@@ -464,6 +482,7 @@ fun ChatScreen(
464482 }
465483 }
466484 }
485+ }
467486 // Jump-to-latest affordance when the user has scrolled away during a stream.
468487 AnimatedVisibility(
469488 visible = !isPinnedToBottom && messages.isNotEmpty(),
@@ -627,6 +646,25 @@ private fun ChatInputBar(
627646 placeholder = { Text(stringResource(R.string.message_placeholder)) },
628647 enabled = enabled,
629648 maxLines = 6,
649+ // Show a "N / max" countdown once the draft crosses a high fraction of
650+ // the cap, so the user knows a paste is about to be truncated instead
651+ // of being silently cut off. Hidden for normal short prompts to avoid
652+ // clutter under a typical one-line message.
653+ supportingText = {
654+ val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
655+ if (value.length >= threshold) {
656+ Text(
657+ stringResource(
658+ R.string.draft_chars_remaining,
659+ NetworkConfig.maxDraftLengthChars - value.length,
660+ NetworkConfig.maxDraftLengthChars,
661+ ),
662+ style = MaterialTheme.typography.labelSmall,
663+ color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
664+ else MaterialTheme.colorScheme.onSurfaceVariant,
665+ )
666+ }
667+ },
630668 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
631669 keyboardActions = KeyboardActions(onSend = {
632670 if (enabled && value.isNotBlank()) {
@@ -653,7 +691,20 @@ private fun ChatInputBar(
653691}
654692
655693@Composable
656−private fun EmptyConversation(modifier: Modifier = Modifier) {
694+private fun EmptyConversation(
695+ onSuggestion: (String) -> Unit,
696+ modifier: Modifier = Modifier,
697+) {
698+ // A few example prompts so a first-time user has a one-tap path to their first
699+ // message instead of staring at a blank input. Tapping a chip fills the draft
700+ // (without sending) so the user can edit it before sending.
701+ val suggestions = remember {
702+ listOf(
703+ R.string.empty_chat_suggest_1,
704+ R.string.empty_chat_suggest_2,
705+ R.string.empty_chat_suggest_3,
706+ )
707+ }
657708 Column(
658709 modifier = modifier.padding(32.dp),
659710 horizontalAlignment = Alignment.CenterHorizontally,
@@ -675,6 +726,17 @@ private fun EmptyConversation(modifier: Modifier = Modifier) {
675726 style = MaterialTheme.typography.bodyMedium,
676727 color = MaterialTheme.colorScheme.onSurfaceVariant,
677728 )
729+ Spacer(Modifier.size(20.dp))
730+ // Suggestion chips wrap across multiple rows on narrow screens. FlowRow-like
731+ // behavior via a plain Column of Rows keeps the dependency surface zero.
732+ suggestions.forEach { resId ->
733+ val text = stringResource(resId)
734+ androidx.compose.material3.AssistChip(
735+ onClick = { onSuggestion(text) },
736+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
737+ modifier = Modifier.padding(vertical = 4.dp),
738+ )
739+ }
678740 }
679741}
680742
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−2
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row
77import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.shape.RoundedCornerShape
1110import androidx.compose.material.icons.Icons
1211import androidx.compose.material.icons.filled.ContentCopy
1312import androidx.compose.material3.Icon
@@ -63,7 +62,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
6362 Column(
6463 modifier = Modifier
6564 .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
66− .clip(RoundedCornerShape(16.dp))
65+ .clip(MaterialTheme.shapes.medium)
6766 .background(MaterialTheme.colorScheme.primaryContainer)
6867 .padding(12.dp),
6968 verticalArrangement = Arrangement.spacedBy(6.dp),
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktnew file+245
@@ -0,0 +1,245 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.material3.MaterialTheme
4+import androidx.compose.runtime.Composable
5+import androidx.compose.runtime.remember
6+import androidx.compose.ui.graphics.Color
7+import androidx.compose.ui.text.AnnotatedString
8+import androidx.compose.ui.text.SpanStyle
9+import androidx.compose.ui.text.font.FontFamily
10+import androidx.compose.ui.text.font.FontStyle
11+import androidx.compose.ui.text.font.FontWeight
12+import androidx.compose.ui.text.withStyle
13+
14+/**
15+ * Lightweight, dependency-free syntax highlighting for the file viewer.
16+ *
17+ * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
18+ * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
19+ * heuristic highlighter instead colors comments, strings, numbers, and a per-language
20+ * keyword set via simple character/word scanning — enough to make a code file scannable
21+ * in the viewer without pulling in a parser.
22+ *
23+ * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
24+ * comments and triple-quoted strings are not handled; that's an accepted trade-off for
25+ * a viewer (not an editor) where the goal is visual orientation, not correctness.
26+ */
27+
28+/** Language families recognized by [highlightLine]. The set of keywords is shared
29+ * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
30+ * common keywords overlap heavily; python has its own set; shell has its own. */
31+private enum class Language {
32+ C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
33+ PYTHON,
34+ SHELL, // sh, bash, zsh
35+ MARKUP, // html, xml, svg
36+ NONE,
37+}
38+
39+private fun languageFor(filename: String): Language {
40+ val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
41+ return when (ext) {
42+ "kt", "kts", "java", "js", "mjs", "cjs", "ts", "tsx",
43+ "swift", "rs", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh",
44+ "cs", "scala", "groovy", "dart", "gradle",
45+ -> Language.C_FAMILY
46+ "py", "pyw", "rb" -> Language.PYTHON
47+ "sh", "bash", "zsh", "fish" -> Language.SHELL
48+ "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
49+ else -> Language.NONE
50+ }
51+}
52+
53+private val cFamilyKeywords = setOf(
54+ "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
55+ "const", "continue", "default", "defer", "do", "else", "enum", "export",
56+ "extends", "external", "final", "finally", "fn", "for", "func", "fun", "go",
57+ "if", "impl", "implements", "import", "in", "init", "inline", "instanceof", "is",
58+ "let", "match", "mod", "module", "mut", "namespace", "new", "object", "of",
59+ "override", "package", "private", "protected", "public", "pub", "raise", "return",
60+ "sealed", "self", "static", "struct", "super", "suspend", "switch", "this", "throw",
61+ "throws", "trait", "try", "type", "typeof", "union", "unsafe", "val", "var", "when",
62+ "where", "while", "with", "yield", "true", "false", "null", "nil",
63+ "and", "or", "not", "lambda", "elif", "pass", "print",
64+)
65+
66+private val pythonKeywords = setOf(
67+ "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
68+ "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
69+ "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
70+ "raise", "return", "try", "while", "with", "yield", "self", "cls", "print",
71+)
72+
73+private val shellKeywords = setOf(
74+ "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac",
75+ "function", "in", "return", "exit", "echo", "export", "local", "readonly", "unset",
76+ "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
77+)
78+
79+/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
80+ * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81+data class HighlightPalette(
82+ val keyword: Color,
83+ val comment: Color,
84+ val string: Color,
85+ val number: Color,
86+ val annotation: Color,
87+ val tag: Color,
88+ val base: Color,
89+)
90+
91+@Composable
92+fun rememberHighlightPalette(): HighlightPalette {
93+ // Use muted theme roles so the highlighting reads as part of the UI, not a foreign
94+ // palette. Keyword = primary, string = secondary, comment = outline (dimmed),
95+ // number = tertiary. Falls back gracefully when dynamic color is on (the roles
96+ // come from the wallpaper-derived scheme).
97+ val scheme = MaterialTheme.colorScheme
98+ return remember(scheme) {
99+ HighlightPalette(
100+ keyword = scheme.primary,
101+ comment = scheme.outline,
102+ string = scheme.secondary,
103+ number = scheme.tertiary,
104+ annotation = scheme.tertiary,
105+ tag = scheme.primary,
106+ base = scheme.onSurface,
107+ )
108+ }
109+}
110+
111+/**
112+ * Produce a highlighted [AnnotatedString] for a single line of source code.
113+ *
114+ * The scanner walks the line character by character, classifying runs of chars as
115+ * comments, strings, numbers, identifiers (checked against the keyword set), or plain
116+ * text. It's O(n) per line and allocation-light (a single AnnotatedString.Builder),
117+ * which matters because the file viewer renders lazily but can scroll through
118+ * thousands of lines — a heavy highlighter would jank on fast scrolls.
119+ */
120+fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString {
121+ val lang = languageFor(filename)
122+ if (lang == Language.NONE) return AnnotatedString(line)
123+ val keywords = keywordsFor(lang)
124+ val builder = AnnotatedString.Builder(line.length + 8)
125+ val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
126+ val mono = FontFamily.Monospace
127+ var i = 0
128+ val n = line.length
129+ while (i < n) {
130+ val c = line[i]
131+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
132+ if (emitted.consumed) {
133+ i = emitted.nextIndex
134+ } else {
135+ builder.withStyle(baseStyle) { append(c) }
136+ i++
137+ }
138+ }
139+ return builder.toAnnotatedString()
140+}
141+
142+/** Result of attempting to emit one token starting at [start]: the next scan index and
143+ * whether a token was consumed (vs. falling through to the default plain-text emit). */
144+private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
145+
146+private fun emitToken(
147+ builder: AnnotatedString.Builder,
148+ line: String,
149+ start: Int,
150+ lang: Language,
151+ keywords: Set<String>,
152+ palette: HighlightPalette,
153+ baseStyle: SpanStyle,
154+ mono: FontFamily,
155+): EmitResult {
156+ val c = line[start]
157+ if (isLineCommentStart(c, line, start, lang)) {
158+ builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
159+ append(line.substring(start))
160+ }
161+ return EmitResult(line.length, true)
162+ }
163+ if (c == '"' || c == '\'') {
164+ val end = findStringEnd(line, start, c)
165+ builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
166+ return EmitResult(end, true)
167+ }
168+ if (isNumberStart(c, line, start)) {
169+ val end = findNumberEnd(line, start)
170+ builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
171+ return EmitResult(end, true)
172+ }
173+ if (c.isLetter() || c == '_' || c == '@') {
174+ val end = findIdentifierEnd(line, start)
175+ val word = line.substring(start, end)
176+ when {
177+ c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
178+ word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
179+ else -> builder.withStyle(baseStyle) { append(word) }
180+ }
181+ return EmitResult(end, true)
182+ }
183+ if (lang == Language.MARKUP && isMarkupDelim(c)) {
184+ builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
185+ return EmitResult(start + 1, true)
186+ }
187+ return EmitResult(start + 1, false)
188+}
189+
190+private fun keywordsFor(lang: Language): Set<String> = when (lang) {
191+ Language.C_FAMILY -> cFamilyKeywords
192+ Language.PYTHON -> pythonKeywords
193+ Language.SHELL -> shellKeywords
194+ Language.MARKUP -> emptySet()
195+ Language.NONE -> emptySet()
196+}
197+
198+private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
199+ (c == '/' && i + 1 < line.length && line[i + 1] == '/') ||
200+ (c == '#' && lang != Language.MARKUP)
201+
202+private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
203+
204+private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
205+ c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
206+
207+/** Scan forward to the end of a string literal starting at [start] (the opening quote
208+ * is line[start]). Returns the index just past the closing quote (or line.length if
209+ * the string is unterminated on this line). Honors backslash escapes. */
210+private fun findStringEnd(line: String, start: Int, quote: Char): Int {
211+ var i = start + 1
212+ val n = line.length
213+ while (i < n && line[i] != quote) {
214+ if (line[i] == '\\' && i + 1 < n) i += 2 else i++
215+ }
216+ return if (i < n) i + 1 else n
217+}
218+
219+/** Scan forward to the end of a number literal starting at [start]. */
220+private fun findNumberEnd(line: String, start: Int): Int {
221+ var i = start + 1
222+ val n = line.length
223+ while (i < n && isNumberPartChar(line[i])) i++
224+ return i
225+}
226+
227+private fun isNumberPartChar(c: Char): Boolean =
228+ c.isDigit() || c == '.' || c == '_' || isNumberExpPart(c) || isHexDigit(c)
229+
230+private fun isNumberExpPart(c: Char): Boolean =
231+ c == 'x' || c == 'X' || c == 'e' || c == 'E' || c == '+' || c == '-'
232+
233+private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
234+
235+/** Scan forward to the end of an identifier starting at [start]. */
236+private fun findIdentifierEnd(line: String, start: Int): Int {
237+ var i = start
238+ if (line[i] == '@') i++
239+ val n = line.length
240+ while (i < n && isIdentifierPartChar(line[i])) i++
241+ return i
242+}
243+
244+private fun isIdentifierPartChar(c: Char): Boolean =
245+ c.isLetterOrDigit() || c == '_' || c == '$'
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+28−3
@@ -1,5 +1,8 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
36import androidx.compose.foundation.layout.Arrangement
47import androidx.compose.foundation.layout.Row
58import androidx.compose.foundation.layout.fillMaxWidth
@@ -9,6 +12,7 @@ import androidx.compose.material3.CircularProgressIndicator
912import androidx.compose.material3.MaterialTheme
1013import androidx.compose.material3.Surface
1114import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
1216import androidx.compose.runtime.Composable
1317import androidx.compose.ui.Alignment
1418import androidx.compose.ui.Modifier
@@ -24,11 +28,17 @@ import soy.iko.opencode.data.network.EventStreamClient
2428/**
2529 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
2630 * session list so a dropped stream is visible everywhere, not just mid-conversation.
31+ *
32+ * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
33+ * when [onRetry] is supplied, so the user can re-connect without hunting for the
34+ * chat screen's reconnect button or navigating to the server list. Transient
35+ * connecting/reconnecting states remain non-interactive (the system is already trying).
2736 */
2837@Composable
2938fun ConnectionBanner(
3039 state: EventStreamClient.ConnectionState,
3140 modifier: Modifier = Modifier,
41+ onRetry: (() -> Unit)? = null,
3242) {
3343 val text = when (state) {
3444 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
@@ -36,7 +46,11 @@ fun ConnectionBanner(
3646 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
3747 EventStreamClient.ConnectionState.Connected -> null
3848 }
39− if (text != null) {
49+ // 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
51+ // connection flaps, and was called out as a rough edge in the UX audit.
52+ AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
53+ if (text == null) return@AnimatedVisibility
4054 // Distinguish a hard failure (e.g. bad credentials) from transient
4155 // connecting/reconnecting states by switching to the error palette, so
4256 // the banner conveys urgency without relying on text alone.
@@ -57,7 +71,7 @@ fun ConnectionBanner(
5771 color = container,
5872 ) {
5973 Row(
60− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
74+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
6175 horizontalArrangement = Arrangement.Center,
6276 verticalAlignment = Alignment.CenterVertically,
6377 ) {
@@ -71,8 +85,19 @@ fun ConnectionBanner(
7185 text,
7286 style = MaterialTheme.typography.labelMedium,
7387 color = onContainer,
74− modifier = Modifier.padding(start = 6.dp),
88+ modifier = Modifier
89+ .padding(start = 6.dp)
90+ .weight(1f, fill = false),
7591 )
92+ // Inline retry on the Failed banner so the user can recover without
93+ // leaving the screen. Hidden during transient states (the system is
94+ // already reconnecting) and when no callback is wired (callers that
95+ // don't have a reconnect path, e.g. the file browser).
96+ if (isFailed && onRetry != null) {
97+ TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
98+ Text(stringResource(R.string.retry_now), color = onContainer)
99+ }
100+ }
76101 }
77102 }
78103 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+33−4
@@ -5,6 +5,7 @@ import androidx.compose.foundation.horizontalScroll
55import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
89import androidx.compose.foundation.layout.defaultMinSize
910import androidx.compose.foundation.layout.fillMaxSize
1011import androidx.compose.foundation.layout.fillMaxWidth
@@ -183,7 +184,11 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
183184@Composable
184185private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
185186 if (results.isEmpty()) {
186− Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
187+ EmptyFileState(
188+ icon = Icons.Filled.Search,
189+ message = stringResource(R.string.no_matches),
190+ modifier = Modifier.fillMaxWidth().padding(24.dp),
191+ )
187192 return
188193 }
189194 LazyColumn(modifier = Modifier.fillMaxSize()) {
@@ -211,10 +216,10 @@ private fun DirectoryListing(
211216 onOpenFile: (String) -> Unit,
212217) {
213218 if (state.entries.isEmpty()) {
214− Text(
215− stringResource(R.string.empty_folder),
219+ EmptyFileState(
220+ icon = Icons.Filled.Folder,
221+ message = stringResource(R.string.empty_folder),
216222 modifier = Modifier.fillMaxWidth().padding(24.dp),
217− color = MaterialTheme.colorScheme.onSurfaceVariant,
218223 )
219224 return
220225 }
@@ -237,6 +242,30 @@ private fun DirectoryListing(
237242 }
238243}
239244
245+/** Shared empty-state for the file browser: icon + message, matching the icon+text
246+ * pattern used by the session and server lists (the prior versions were bare Text
247+ * lines that read as status messages rather than intentional empty states). */
248+@Composable
249+private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
250+ Column(
251+ modifier = modifier,
252+ horizontalAlignment = Alignment.CenterHorizontally,
253+ ) {
254+ Icon(
255+ icon,
256+ contentDescription = null,
257+ modifier = Modifier.size(48.dp),
258+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
259+ )
260+ Spacer(Modifier.size(12.dp))
261+ Text(
262+ message,
263+ style = MaterialTheme.typography.bodyMedium,
264+ color = MaterialTheme.colorScheme.onSurfaceVariant,
265+ )
266+ }
267+}
268+
240269@Composable
241270private fun FileRow(
242271 icon: Boolean,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+108−45
@@ -2,6 +2,11 @@ package soy.iko.opencode.ui.file
22
33import android.content.Intent
44import soy.iko.opencode.ui.components.showToast
5+import androidx.compose.animation.AnimatedContent
6+import androidx.compose.animation.core.tween
7+import androidx.compose.animation.fadeIn
8+import androidx.compose.animation.fadeOut
9+import androidx.compose.animation.togetherWith
510import androidx.compose.foundation.horizontalScroll
611import androidx.compose.foundation.layout.Box
712import androidx.compose.foundation.layout.Column
@@ -22,6 +27,7 @@ import androidx.compose.material.icons.filled.Refresh
2227import androidx.compose.material.icons.filled.Share
2328import androidx.compose.material3.CircularProgressIndicator
2429import androidx.compose.material3.ExperimentalMaterial3Api
30+import androidx.compose.material3.FilterChip
2531import androidx.compose.material3.Icon
2632import androidx.compose.material3.IconButton
2733import androidx.compose.material3.MaterialTheme
@@ -31,7 +37,10 @@ import androidx.compose.material3.TextButton
3137import androidx.compose.material3.TopAppBar
3238import androidx.compose.runtime.Composable
3339import androidx.compose.runtime.getValue
40+import androidx.compose.runtime.mutableStateOf
3441import androidx.compose.runtime.remember
42+import androidx.compose.runtime.saveable.rememberSaveable
43+import androidx.compose.runtime.setValue
3544import androidx.compose.ui.Alignment
3645import androidx.compose.ui.Modifier
3746import androidx.compose.ui.platform.LocalContext
@@ -47,6 +56,8 @@ import soy.iko.opencode.di.AppContainer
4756import soy.iko.opencode.R
4857import soy.iko.opencode.ui.components.DiffView
4958import soy.iko.opencode.ui.components.copyToClipboard
59+import soy.iko.opencode.ui.components.highlightLine
60+import soy.iko.opencode.ui.components.rememberHighlightPalette
5061import soy.iko.opencode.ui.vmFactory
5162import soy.iko.opencode.util.runCatchingCancellable
5263
@@ -60,8 +71,20 @@ fun FileViewScreen(
6071 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
6172 val state by vm.state.collectAsStateWithLifecycle()
6273 val context = LocalContext.current
63− val filename = remember(path) { path.substringAfterLast('/') }
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) {
77+ val trimmed = path.trimEnd('/')
78+ trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
79+ }
6480 val shareLabel = stringResource(R.string.share)
81+ // Diff vs raw view toggle. Defaults to showing the diff when one exists (the common
82+ // case a developer opens a file for); the user can switch to the raw new content to
83+ // see the full file without +/- markers. Persisted across recomposition via
84+ // rememberSaveable so a rotation doesn't reset the user's choice.
85+ val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
86+ var showDiff by rememberSaveable(path) { mutableStateOf(true) }
87+ val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
6588
6689 Scaffold(
6790 topBar = {
@@ -103,51 +126,88 @@ fun FileViewScreen(
103126 },
104127 ) { padding ->
105128 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
106− when {
107− state.loading -> {
108− val loadingLabel = stringResource(R.string.loading)
109− CircularProgressIndicator(
110− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
111− )
112− }
113− state.error != null -> Column(
114− modifier = Modifier.align(Alignment.Center).padding(24.dp),
115− horizontalAlignment = Alignment.CenterHorizontally,
116− ) {
117− Text(
118− state.error ?: "",
119− color = MaterialTheme.colorScheme.error,
120− )
121− Spacer(Modifier.size(12.dp))
122− TextButton(onClick = { vm.reload() }) {
123− Text(stringResource(R.string.retry))
129+ // AnimatedContent cross-fades between the loading / error / content states
130+ // so the viewer doesn't snap abruptly when a reload finishes or fails. The
131+ // transition is short (180ms) and fade-only to avoid a slide that would
132+ // disorient when the content is the same file just re-fetched.
133+ AnimatedContent(
134+ targetState = state,
135+ transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
136+ label = "fileViewState",
137+ ) { s ->
138+ when {
139+ s.loading -> {
140+ val loadingLabel = stringResource(R.string.loading)
141+ CircularProgressIndicator(
142+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
143+ )
124144 }
125− }
126− state.content?.isBinary == true -> Text(
127− stringResource(R.string.binary_file),
128− modifier = Modifier.align(Alignment.Center).padding(24.dp),
129− color = MaterialTheme.colorScheme.onSurfaceVariant,
130− )
131− else -> {
132− val content = state.content
133− if (content?.diff != null && content.diff.isNotBlank()) {
134− DiffView(
135− diff = content.diff,
136− modifier = Modifier
137− .fillMaxSize()
138− .verticalScroll(rememberScrollState())
139− .padding(8.dp),
145+ s.error != null -> Column(
146+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
147+ horizontalAlignment = Alignment.CenterHorizontally,
148+ ) {
149+ Text(
150+ s.error ?: "",
151+ color = MaterialTheme.colorScheme.error,
140152 )
141− } else {
142− val text = content?.content.orEmpty()
143− if (text.isEmpty()) {
144− Text(
145− stringResource(R.string.empty_file),
146− modifier = Modifier.align(Alignment.Center).padding(24.dp),
147− color = MaterialTheme.colorScheme.onSurfaceVariant,
153+ Spacer(Modifier.size(12.dp))
154+ TextButton(onClick = { vm.reload() }) {
155+ Text(stringResource(R.string.retry))
156+ }
157+ }
158+ s.content?.isBinary == true -> Text(
159+ stringResource(R.string.binary_file),
160+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
161+ color = MaterialTheme.colorScheme.onSurfaceVariant,
162+ )
163+ else -> {
164+ val content = s.content
165+ // Diff/raw toggle: when both a diff and raw content are present,
166+ // let the user switch. The chip row sits above the content so
167+ // it's always reachable while scrolling.
168+ if (showToggle) {
169+ Row(
170+ modifier = Modifier
171+ .align(Alignment.TopCenter)
172+ .padding(top = 4.dp),
173+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
174+ ) {
175+ FilterChip(
176+ selected = showDiff,
177+ onClick = { showDiff = true },
178+ label = { Text(stringResource(R.string.show_diff)) },
179+ )
180+ FilterChip(
181+ selected = !showDiff,
182+ onClick = { showDiff = false },
183+ label = { Text(stringResource(R.string.show_raw)) },
184+ )
185+ }
186+ }
187+ if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
188+ DiffView(
189+ diff = content.diff,
190+ modifier = Modifier
191+ .fillMaxSize()
192+ .padding(top = if (showToggle) 48.dp else 0.dp)
193+ .verticalScroll(rememberScrollState())
194+ .padding(8.dp),
148195 )
149196 } else {
150− FileTextContent(text)
197+ val text = content?.content.orEmpty()
198+ if (text.isEmpty()) {
199+ Text(
200+ stringResource(R.string.empty_file),
201+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
202+ color = MaterialTheme.colorScheme.onSurfaceVariant,
203+ )
204+ } else {
205+ FileTextContent(
206+ text = text,
207+ filename = filename,
208+ topInset = if (showToggle) 48.dp else 0.dp,
209+ )
210+ }
151211 }
152212 }
153213 }
@@ -157,15 +217,17 @@ fun FileViewScreen(
157217}
158218
159219@Composable
160−private fun FileTextContent(text: String) {
220+private fun FileTextContent(text: String, filename: String, topInset: androidx.compose.ui.unit.Dp) {
161221 val lines = remember(text) { text.split("\n") }
162222 val gutterWidth = remember(lines.size) {
163223 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
164224 }
165225 val hScrollState = rememberScrollState()
226+ val palette = rememberHighlightPalette()
166227 LazyColumn(
167228 modifier = Modifier
168229 .fillMaxSize()
230+ .padding(top = topInset)
169231 .padding(8.dp),
170232 ) {
171233 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
@@ -177,11 +239,12 @@ private fun FileTextContent(text: String) {
177239 fontFamily = FontFamily.Monospace,
178240 color = MaterialTheme.colorScheme.onSurfaceVariant,
179241 )
242+ // Heuristic syntax highlighting per line. Falls back to plain text for
243+ // unknown extensions, so non-code files render unchanged.
180244 Text(
181− line,
245+ highlightLine(line, filename, palette),
182246 modifier = Modifier.padding(start = 8.dp),
183247 style = MaterialTheme.typography.bodySmall,
184− fontFamily = FontFamily.Monospace,
185248 )
186249 }
187250 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+134−42
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.server
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
@@ -28,11 +29,16 @@ import androidx.compose.material3.IconButton
2829import androidx.compose.material3.LinearProgressIndicator
2930import androidx.compose.material3.MaterialTheme
3031import androidx.compose.material3.Scaffold
32+import androidx.compose.material3.SnackbarDuration
3133import androidx.compose.material3.SnackbarHost
3234import androidx.compose.material3.SnackbarHostState
35+import androidx.compose.material3.SnackbarResult
36+import androidx.compose.material3.SwipeToDismissBox
37+import androidx.compose.material3.SwipeToDismissBoxValue
3338import androidx.compose.material3.Text
3439import androidx.compose.material3.TextButton
3540import androidx.compose.material3.TopAppBar
41+import androidx.compose.material3.rememberSwipeToDismissBoxState
3642import androidx.compose.runtime.Composable
3743import androidx.compose.runtime.LaunchedEffect
3844import androidx.compose.runtime.getValue
@@ -42,6 +48,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
4248import androidx.compose.runtime.setValue
4349import androidx.compose.ui.Alignment
4450import androidx.compose.ui.Modifier
51+import androidx.compose.ui.draw.clip
4552import androidx.compose.ui.platform.LocalHapticFeedback
4653import androidx.compose.ui.platform.testTag
4754import androidx.compose.ui.semantics.Role
@@ -73,6 +80,8 @@ fun ServerListScreen(
7380 val haptics = LocalHapticFeedback.current
7481 val snackbar = remember { SnackbarHostState() }
7582 val retryLabel = stringResource(R.string.retry)
83+ val undoLabel = stringResource(R.string.undo)
84+ val serverRemovedLabel = stringResource(R.string.server_removed)
7685 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
7786 val connectedId = activeConnection?.profile?.id
7887
@@ -83,12 +92,28 @@ fun ServerListScreen(
8392 } else {
8493 snackbar.showSnackbar(event.message)
8594 }
86− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed && event.profile != null) {
95+ if (result == SnackbarResult.ActionPerformed && event.profile != null) {
8796 vm.connect(event.profile, onConnected)
8897 }
8998 }
9099 }
91100
101+ // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
102+ // action is taken before the delay expires, the profile is kept and the delete
103+ // never fires — mirrors the session list's undo UX for consistency.
104+ LaunchedEffect(Unit) {
105+ vm.undoEvents.collect { profileId ->
106+ val result = snackbar.showSnackbar(
107+ message = serverRemovedLabel,
108+ actionLabel = undoLabel,
109+ duration = SnackbarDuration.Short,
110+ )
111+ if (result == SnackbarResult.ActionPerformed) {
112+ vm.undoDelete(profileId)
113+ }
114+ }
115+ }
116+
92117 // On cold start the container auto-reconnects to the last server; once it lands,
93118 // skip this screen and go straight to the session list. Consumed once so a later
94119 // manual visit to this screen doesn't bounce the user back out.
@@ -126,42 +151,44 @@ fun ServerListScreen(
126151 ) {
127152 items(profiles, key = { it.id }) { profile ->
128153 val isActive = profile.id == connectedId
129− Card(
130− modifier = Modifier
131− .fillMaxWidth()
132− .testTag("server_card")
133− .clickable(enabled = connectingId == null, role = Role.Button) {
134− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
135− vm.connect(profile, onConnected)
136− },
137− ) {
138− Row(
139− modifier = Modifier.fillMaxWidth().padding(16.dp),
140− verticalAlignment = Alignment.CenterVertically,
154+ if (isActive) {
155+ // The active server can't be swipe-deleted (deleting it also
156+ // disconnects, which deserves an explicit tap, not an
157+ // accidental swipe). Render a plain card without the swipe
158+ // affordance for the active row.
159+ Card(
160+ modifier = Modifier
161+ .fillMaxWidth()
162+ .testTag("server_card")
163+ .clickable(enabled = connectingId == null, role = Role.Button) {
164+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
165+ vm.connect(profile, onConnected)
166+ },
141167 ) {
142− Column(modifier = Modifier.weight(1f)) {
143− Text(
144− profile.displayLabel,
145− style = MaterialTheme.typography.titleMedium,
146− maxLines = 1,
147− overflow = TextOverflow.Ellipsis,
148− )
149− Text(
150− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
151− style = MaterialTheme.typography.bodySmall,
152− color = MaterialTheme.colorScheme.onSurfaceVariant,
153− maxLines = 1,
154− overflow = TextOverflow.Ellipsis,
155− )
156− if (isActive) {
168+ Row(
169+ modifier = Modifier.fillMaxWidth().padding(16.dp),
170+ verticalAlignment = Alignment.CenterVertically,
171+ ) {
172+ Column(modifier = Modifier.weight(1f)) {
173+ Text(
174+ profile.displayLabel,
175+ style = MaterialTheme.typography.titleMedium,
176+ maxLines = 1,
177+ overflow = TextOverflow.Ellipsis,
178+ )
179+ Text(
180+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
181+ style = MaterialTheme.typography.bodySmall,
182+ color = MaterialTheme.colorScheme.onSurfaceVariant,
183+ maxLines = 1,
184+ overflow = TextOverflow.Ellipsis,
185+ )
157186 Text(
158187 stringResource(R.string.connected),
159188 style = MaterialTheme.typography.labelSmall,
160189 color = MaterialTheme.colorScheme.primary,
161190 )
162191 }
163− }
164− if (isActive) {
165192 Icon(
166193 Icons.Filled.CheckCircle,
167194 contentDescription = stringResource(R.string.connected),
@@ -169,22 +196,85 @@ fun ServerListScreen(
169196 modifier = Modifier.size(22.dp),
170197 )
171198 // Keep edit available on the active server so credentials can be
172− // fixed without disconnecting first. Only delete is hidden, since
173− // deleting the active server also disconnects (SV1 warns about that).
199+ // fixed without disconnecting first. Only delete is hidden.
174200 IconButton(onClick = { onEditProfile(profile.id) }) {
175201 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
176202 }
177− } else if (connectingId == profile.id) {
178− val connectingLabel = stringResource(R.string.connecting)
179− CircularProgressIndicator(
180− modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
181− )
182− } else {
183− IconButton(onClick = { onEditProfile(profile.id) }) {
184− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
203+ }
204+ }
205+ } else {
206+ // Swipe end-to-start reveals a delete affordance and opens the
207+ // same confirmation dialog as the trash icon (matching the
208+ // session list). confirmValueChange always returns false (snap
209+ // back) so the dialog guards the actual deletion.
210+ val swipeState = rememberSwipeToDismissBoxState(
211+ confirmValueChange = {
212+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
213+ pendingDeleteId = profile.id
214+ false
215+ },
216+ )
217+ SwipeToDismissBox(
218+ state = swipeState,
219+ enableDismissFromStartToEnd = false,
220+ backgroundContent = {
221+ Box(
222+ modifier = Modifier
223+ .fillMaxSize()
224+ .clip(MaterialTheme.shapes.medium)
225+ .background(MaterialTheme.colorScheme.errorContainer)
226+ .padding(horizontal = 20.dp),
227+ contentAlignment = Alignment.CenterEnd,
228+ ) {
229+ Icon(
230+ Icons.Filled.Delete,
231+ contentDescription = stringResource(R.string.delete),
232+ tint = MaterialTheme.colorScheme.onErrorContainer,
233+ )
185234 }
186− IconButton(onClick = { pendingDeleteId = profile.id }) {
187− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
235+ },
236+ ) {
237+ Card(
238+ modifier = Modifier
239+ .fillMaxWidth()
240+ .testTag("server_card")
241+ .clickable(enabled = connectingId == null, role = Role.Button) {
242+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
243+ vm.connect(profile, onConnected)
244+ },
245+ ) {
246+ Row(
247+ modifier = Modifier.fillMaxWidth().padding(16.dp),
248+ verticalAlignment = Alignment.CenterVertically,
249+ ) {
250+ Column(modifier = Modifier.weight(1f)) {
251+ Text(
252+ profile.displayLabel,
253+ style = MaterialTheme.typography.titleMedium,
254+ maxLines = 1,
255+ overflow = TextOverflow.Ellipsis,
256+ )
257+ Text(
258+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
259+ style = MaterialTheme.typography.bodySmall,
260+ color = MaterialTheme.colorScheme.onSurfaceVariant,
261+ maxLines = 1,
262+ overflow = TextOverflow.Ellipsis,
263+ )
264+ }
265+ if (connectingId == profile.id) {
266+ val connectingLabel = stringResource(R.string.connecting)
267+ CircularProgressIndicator(
268+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
269+ )
270+ } else {
271+ IconButton(onClick = { onEditProfile(profile.id) }) {
272+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
273+ }
274+ IconButton(onClick = { pendingDeleteId = profile.id }) {
275+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
276+ }
277+ }
188278 }
189279 }
190280 }
@@ -210,6 +300,8 @@ fun ServerListScreen(
210300 TextButton(onClick = {
211301 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
212302 pendingDeleteId = null
303+ // delete() defers the actual removal so the undo snackbar can cancel
304+ // it; the row disappears on the next profiles emission.
213305 vm.delete(profile)
214306 }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
215307 },
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+41−4
@@ -8,6 +8,7 @@ import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.R
99import soy.iko.opencode.util.runCatchingCancellable
1010import kotlinx.coroutines.channels.BufferOverflow
11+import kotlinx.coroutines.delay
1112import kotlinx.coroutines.flow.MutableSharedFlow
1213import kotlinx.coroutines.flow.MutableStateFlow
1314import kotlinx.coroutines.flow.SharedFlow
@@ -42,6 +43,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
4243 )
4344 val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
4445
46+ /** One-shot events carrying the id of a server profile marked for deferred deletion,
47+ * so the UI can show an Undo snackbar. Mirrors the session list's undo pattern. */
48+ private val _undoEvents = MutableSharedFlow<String>(
49+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
50+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
51+ )
52+ val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
53+
54+ /** Profile awaiting the undo window to expire before its REST delete fires. */
55+ private var pendingDelete: ServerProfile? = null
56+
4557 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
4658 if (_connecting.value != null) return
4759 _connecting.value = profile.id
@@ -62,13 +74,38 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
6274 }
6375 }
6476
77+ /**
78+ * Mark [profile] for deferred deletion and emit an Undo event. The actual
79+ * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
80+ * [undoDelete] is called before it fires, the profile is restored. If the profile
81+ * is the active one, disconnect immediately (the user has already confirmed via the
82+ * dialog) so the UI reflects the disconnection right away — the profile row is
83+ * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
84+ */
6585 fun delete(profile: ServerProfile) {
86+ pendingDelete = profile
87+ if (container.activeConnection.value?.profile?.id == profile.id) {
88+ // disconnect() is suspend; run it on the VM scope so the optimistic removal
89+ // and undo emission aren't blocked on the connection close completing.
90+ viewModelScope.launch { container.disconnect() }
91+ }
92+ _undoEvents.tryEmit(profile.id)
6693 viewModelScope.launch {
67− if (container.activeConnection.value?.profile?.id == profile.id) {
68− container.disconnect()
94+ delay(NetworkConfig.undoServerDeleteDelayMs)
95+ val toDelete = pendingDelete
96+ if (toDelete != null && toDelete.id == profile.id) {
97+ pendingDelete = null
98+ runCatchingCancellable { container.profileStore.delete(profile.id) }
99+ .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
69100 }
70− runCatchingCancellable { container.profileStore.delete(profile.id) }
71− .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
101+ }
102+ }
103+
104+ /** Cancel a pending delete (Undo snackbar action). */
105+ fun undoDelete(profileId: String) {
106+ val pending = pendingDelete
107+ if (pending != null && pending.id == profileId) {
108+ pendingDelete = null
72109 }
73110 }
74111}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+194−126
@@ -91,6 +91,7 @@ fun SessionListScreen(
9191 onDisconnect: () -> Unit,
9292 onOpenFiles: () -> Unit,
9393 onOpenSettings: () -> Unit,
94+ onAddServer: () -> Unit,
9495) {
9596 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
9697 val state by vm.state.collectAsStateWithLifecycle()
@@ -159,6 +160,10 @@ fun SessionListScreen(
159160 showServerMenu = false
160161 vm.switchServer(profile)
161162 },
163+ onAddServer = {
164+ showServerMenu = false
165+ onAddServer()
166+ },
162167 )
163168 },
164169 actions = {
@@ -199,119 +204,20 @@ fun SessionListScreen(
199204 ConnectionBanner(
200205 state = connectionState,
201206 modifier = Modifier.align(Alignment.TopCenter),
207+ onRetry = { vm.retryConnection() },
208+ )
209+ SessionListBody(
210+ state = state,
211+ unread = unread,
212+ refreshing = refreshing,
213+ haptics = haptics,
214+ onRefresh = vm::refresh,
215+ onQueryChange = vm::setQuery,
216+ onOpenSession = onOpenSession,
217+ onCreateSession = { vm.createSession(onCreated = onOpenSession) },
218+ onRename = { pendingRenameId = it },
219+ onDelete = { pendingDeleteId = it },
202220 )
203− when {
204− state.loading -> {
205− val loadingLabel = stringResource(R.string.loading)
206− CircularProgressIndicator(
207− Modifier
208− .align(Alignment.Center)
209− .semantics { contentDescription = loadingLabel },
210− )
211− }
212− state.sessions.isEmpty() && state.error != null -> Column(
213− modifier = Modifier.align(Alignment.Center).padding(24.dp),
214− horizontalAlignment = Alignment.CenterHorizontally,
215− ) {
216− Text(
217− state.error ?: "",
218− color = MaterialTheme.colorScheme.error,
219− )
220− Spacer(Modifier.size(12.dp))
221− TextButton(onClick = { vm.refresh() }) {
222− Text(stringResource(R.string.retry))
223− }
224− }
225− state.sessions.isEmpty() -> EmptySessions(
226− onCreate = { vm.createSession(onCreated = onOpenSession) },
227− modifier = Modifier.align(Alignment.Center),
228− )
229− else -> Column(modifier = Modifier.fillMaxSize()) {
230− OutlinedTextField(
231− value = state.query,
232− onValueChange = vm::setQuery,
233− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
234− placeholder = { Text(stringResource(R.string.search_sessions)) },
235− label = { Text(stringResource(R.string.search_sessions)) },
236− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
237− singleLine = true,
238− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
239− )
240− val sessions = remember(state.sessions, state.query, state.previews) {
241− // Fast-path an empty query: filtered just returns sessions, so skip
242− // the recompute (and the fresh list reference) when only previews
243− // churned in the background. With a non-empty query the previews
244− // map affects the match set, so recompute on any of the three.
245− if (state.query.isBlank()) state.sessions else state.filtered
246− }
247− if (sessions.isEmpty()) {
248− Text(
249− stringResource(R.string.no_sessions_match, state.query),
250− modifier = Modifier.padding(24.dp),
251− color = MaterialTheme.colorScheme.onSurfaceVariant,
252− )
253− } else {
254− PullToRefreshBox(
255− isRefreshing = refreshing,
256− onRefresh = { vm.refresh() },
257− modifier = Modifier.fillMaxSize(),
258− ) {
259− LazyColumn(
260− modifier = Modifier.fillMaxSize(),
261− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
262− verticalArrangement = Arrangement.spacedBy(12.dp),
263− ) {
264− items(sessions, key = { it.id }) { session ->
265− // Swipe end-to-start reveals a delete affordance and opens the
266− // same confirmation dialog as the trash icon. We never commit
267− // the dismissal (always reset to Settled) so the card snaps back
268− // and the dialog guards against accidental data loss.
269− val swipeState = rememberSwipeToDismissBoxState(
270− confirmValueChange = {
271− // Haptic at the trigger so the swipe path matches the icon-path
272− // delete, which vibrates on confirm. Returns false (snap back) so
273− // the dialog guards the actual deletion.
274− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
275− pendingDeleteId = session.id
276− false
277− },
278− )
279− SwipeToDismissBox(
280− state = swipeState,
281− enableDismissFromStartToEnd = false,
282− backgroundContent = {
283− Box(
284− modifier = Modifier
285− .fillMaxSize()
286− .clip(MaterialTheme.shapes.medium)
287− .background(MaterialTheme.colorScheme.errorContainer)
288− .padding(horizontal = 20.dp),
289− contentAlignment = Alignment.CenterEnd,
290− ) {
291− Icon(
292− Icons.Filled.Delete,
293− contentDescription = stringResource(R.string.delete),
294− tint = MaterialTheme.colorScheme.onErrorContainer,
295− )
296− }
297− },
298− ) {
299− SessionCard(
300− session = session,
301− preview = state.previews[session.id],
302− unread = unread.contains(session.id),
303− onClick = { onOpenSession(session.id) },
304− onRename = { pendingRenameId = session.id },
305− onDelete = { pendingDeleteId = session.id },
306− modifier = Modifier.testTag("session_card"),
307− )
308− }
309− }
310− }
311− }
312− }
313− }
314− }
315221 }
316222 }
317223 }
@@ -366,6 +272,134 @@ fun SessionListScreen(
366272 }
367273}
368274
275+@OptIn(ExperimentalMaterial3Api::class)
276+@Composable
277+private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
278+ state: SessionListState,
279+ unread: Map<String, Int>,
280+ refreshing: Boolean,
281+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
282+ onRefresh: () -> Unit,
283+ onQueryChange: (String) -> Unit,
284+ onOpenSession: (String) -> Unit,
285+ onCreateSession: () -> Unit,
286+ onRename: (String) -> Unit,
287+ onDelete: (String) -> Unit,
288+) {
289+ when {
290+ state.loading -> {
291+ val loadingLabel = stringResource(R.string.loading)
292+ CircularProgressIndicator(
293+ Modifier
294+ .align(Alignment.Center)
295+ .semantics { contentDescription = loadingLabel },
296+ )
297+ }
298+ state.sessions.isEmpty() && state.error != null -> Column(
299+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
300+ horizontalAlignment = Alignment.CenterHorizontally,
301+ ) {
302+ Text(
303+ state.error ?: "",
304+ color = MaterialTheme.colorScheme.error,
305+ )
306+ Spacer(Modifier.size(12.dp))
307+ TextButton(onClick = onRefresh) {
308+ Text(stringResource(R.string.retry))
309+ }
310+ }
311+ state.sessions.isEmpty() -> EmptySessions(
312+ onCreate = onCreateSession,
313+ modifier = Modifier.align(Alignment.Center),
314+ )
315+ else -> Column(modifier = Modifier.fillMaxSize()) {
316+ OutlinedTextField(
317+ value = state.query,
318+ onValueChange = onQueryChange,
319+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
320+ placeholder = { Text(stringResource(R.string.search_sessions)) },
321+ label = { Text(stringResource(R.string.search_sessions)) },
322+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
323+ singleLine = true,
324+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
325+ )
326+ val sessions = remember(state.sessions, state.query, state.previews) {
327+ // Fast-path an empty query: filtered just returns sessions, so skip
328+ // the recompute (and the fresh list reference) when only previews
329+ // churned in the background. With a non-empty query the previews
330+ // map affects the match set, so recompute on any of the three.
331+ if (state.query.isBlank()) state.sessions else state.filtered
332+ }
333+ if (sessions.isEmpty()) {
334+ Text(
335+ stringResource(R.string.no_sessions_match, state.query),
336+ modifier = Modifier.padding(24.dp),
337+ color = MaterialTheme.colorScheme.onSurfaceVariant,
338+ )
339+ } else {
340+ PullToRefreshBox(
341+ isRefreshing = refreshing,
342+ onRefresh = onRefresh,
343+ modifier = Modifier.fillMaxSize(),
344+ ) {
345+ LazyColumn(
346+ modifier = Modifier.fillMaxSize(),
347+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
348+ verticalArrangement = Arrangement.spacedBy(12.dp),
349+ ) {
350+ items(sessions, key = { it.id }) { session ->
351+ // Swipe end-to-start reveals a delete affordance and opens the
352+ // same confirmation dialog as the trash icon. We never commit
353+ // the dismissal (always reset to Settled) so the card snaps back
354+ // and the dialog guards against accidental data loss.
355+ val swipeState = rememberSwipeToDismissBoxState(
356+ confirmValueChange = {
357+ // Haptic at the trigger so the swipe path matches the icon-path
358+ // delete, which vibrates on confirm. Returns false (snap back) so
359+ // the dialog guards the actual deletion.
360+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
361+ onDelete(session.id)
362+ false
363+ },
364+ )
365+ SwipeToDismissBox(
366+ state = swipeState,
367+ enableDismissFromStartToEnd = false,
368+ backgroundContent = {
369+ Box(
370+ modifier = Modifier
371+ .fillMaxSize()
372+ .clip(MaterialTheme.shapes.medium)
373+ .background(MaterialTheme.colorScheme.errorContainer)
374+ .padding(horizontal = 20.dp),
375+ contentAlignment = Alignment.CenterEnd,
376+ ) {
377+ Icon(
378+ Icons.Filled.Delete,
379+ contentDescription = stringResource(R.string.delete),
380+ tint = MaterialTheme.colorScheme.onErrorContainer,
381+ )
382+ }
383+ },
384+ ) {
385+ SessionCard(
386+ session = session,
387+ preview = state.previews[session.id],
388+ unreadCount = unread[session.id] ?: 0,
389+ onClick = { onOpenSession(session.id) },
390+ onRename = { onRename(session.id) },
391+ onDelete = { onDelete(session.id) },
392+ modifier = Modifier.testTag("session_card"),
393+ )
394+ }
395+ }
396+ }
397+ }
398+ }
399+ }
400+ }
401+}
402+
369403@Composable
370404private fun RenameSessionDialog(
371405 title: String,
@@ -402,7 +436,7 @@ private fun RenameSessionDialog(
402436private fun SessionCard(
403437 session: Session,
404438 preview: String?,
405− unread: Boolean,
439+ unreadCount: Int,
406440 onClick: () -> Unit,
407441 onRename: () -> Unit,
408442 onDelete: () -> Unit,
@@ -420,21 +454,42 @@ private fun SessionCard(
420454 ) {
421455 Column(modifier = Modifier.weight(1f)) {
422456 Row(verticalAlignment = Alignment.CenterVertically) {
423− if (unread) {
424− val unreadLabel = stringResource(R.string.unread)
425− Box(
426− modifier = Modifier
427− .padding(end = 8.dp)
428− .size(8.dp)
429− .clip(androidx.compose.foundation.shape.CircleShape)
430− .background(MaterialTheme.colorScheme.primary)
431− .semantics { contentDescription = unreadLabel },
432− )
457+ if (unreadCount > 0) {
458+ val unreadLabel = stringResource(R.string.unread_count, unreadCount)
459+ // Count badge: shows the number of unread messages so the user
460+ // can tell a single reply from a burst. Falls back to a dot for
461+ // a count of 1 (the common "one reply" case) to avoid clutter.
462+ if (unreadCount == 1) {
463+ Box(
464+ modifier = Modifier
465+ .padding(end = 8.dp)
466+ .size(8.dp)
467+ .clip(androidx.compose.foundation.shape.CircleShape)
468+ .background(MaterialTheme.colorScheme.primary)
469+ .semantics { contentDescription = unreadLabel },
470+ )
471+ } else {
472+ Box(
473+ modifier = Modifier
474+ .padding(end = 8.dp)
475+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
476+ .background(MaterialTheme.colorScheme.primary)
477+ .padding(horizontal = 6.dp, vertical = 1.dp)
478+ .semantics { contentDescription = unreadLabel },
479+ contentAlignment = Alignment.Center,
480+ ) {
481+ Text(
482+ unreadCount.toString(),
483+ style = MaterialTheme.typography.labelSmall,
484+ color = MaterialTheme.colorScheme.onPrimary,
485+ )
486+ }
487+ }
433488 }
434489 Text(
435490 session.displayTitle,
436491 style = MaterialTheme.typography.titleMedium,
437− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
492+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
438493 maxLines = 1,
439494 overflow = TextOverflow.Ellipsis,
440495 )
@@ -461,7 +516,7 @@ private fun SessionCard(
461516 Text(
462517 preview,
463518 style = MaterialTheme.typography.bodyMedium,
464− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
519+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
465520 maxLines = 2,
466521 overflow = TextOverflow.Ellipsis,
467522 )
@@ -500,7 +555,8 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
500555
501556/** Title + dropdown for quick-switching between saved servers. Highlights the active
502557 * server with a check mark and primary color so the user can tell which one they're
503− * on at a glance. */
558+ * on at a glance. Includes an "Add server" item so adding a new server doesn't require
559+ * navigating out to Settings → Manage servers — a long path for a common action. */
504560@Composable
505561private fun ServerSwitcherMenu(
506562 serverLabel: String,
@@ -511,6 +567,7 @@ private fun ServerSwitcherMenu(
511567 onExpand: () -> Unit,
512568 onDismiss: () -> Unit,
513569 onSelect: (ServerProfile) -> Unit,
570+ onAddServer: () -> Unit,
514571) {
515572 Column {
516573 Box {
@@ -569,6 +626,17 @@ private fun ServerSwitcherMenu(
569626 onClick = { onSelect(profile) },
570627 )
571628 }
629+ // Divider + Add server item so the user can add a server without leaving
630+ // the session list. The trailing icon reinforces the primary action.
631+ androidx.compose.material3.HorizontalDivider()
632+ DropdownMenuItem(
633+ text = { Text(stringResource(R.string.add_server)) },
634+ leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null) },
635+ onClick = {
636+ onDismiss()
637+ onAddServer()
638+ },
639+ )
572640 }
573641 }
574642 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−3
@@ -69,8 +69,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
6969 initialValue = EventStreamClient.ConnectionState.Disconnected,
7070 )
7171
72− /** Sessions with activity that arrived while not being viewed. */
73− val unread: StateFlow<Set<String>> = container.unread
72+ /** Sessions with activity that arrived while not being viewed, mapped to their
73+ * unread event count so the list can show "N unread" instead of a bare dot. */
74+ val unread: StateFlow<Map<String, Int>> = container.unread
7475
7576 val profiles: StateFlow<List<ServerProfile>> =
7677 container.profileStore.profiles.stateIn(
@@ -234,6 +235,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
234235 _state.update { it.copy(query = query) }
235236 }
236237
238+ /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
239+ * re-fetch the session list so the UI reflects the current server state. */
240+ fun retryConnection() {
241+ container.activeConnection.value?.events?.triggerReconnect()
242+ refresh()
243+ }
244+
237245 fun refresh() {
238246 val conn = container.activeConnection.value
239247 if (conn == null) {
@@ -436,7 +444,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
436444 val restored = runCatchingCancellable { container.connect(previousProfile) }
437445 // connect() clears unread state; restore what we saved so the
438446 // user doesn't lose unread badges on a failed server switch.
439− savedUnread.forEach { container.restoreUnread(it) }
447+ savedUnread.forEach { (id) -> container.restoreUnread(id) }
440448 if (restored.isSuccess) {
441449 // Clear stale sessions from the old server; the SSE observer
442450 // will reload from the restored connection via refresh().
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2
@@ -45,6 +45,7 @@ fun TwoPaneSessionChat(
4545 onOpenFiles: () -> Unit,
4646 onOpenSettings: () -> Unit,
4747 onDisconnect: () -> Unit,
48+ onAddServer: () -> Unit,
4849 modifier: Modifier = Modifier,
4950) {
5051 var selected by rememberSaveable { mutableStateOf<String?>(null) }
@@ -86,6 +87,7 @@ fun TwoPaneSessionChat(
8687 onDisconnect = onDisconnect,
8788 onOpenFiles = onOpenFiles,
8889 onOpenSettings = onOpenSettings,
90+ onAddServer = onAddServer,
8991 )
9092 }
9193
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+139−46
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.settings
22
33import android.content.Intent
44import android.util.Log
5+import androidx.compose.foundation.background
56import androidx.compose.foundation.clickable
67import androidx.compose.foundation.layout.Arrangement
78import androidx.compose.foundation.layout.Box
@@ -30,9 +31,16 @@ import androidx.compose.material3.Icon
3031import androidx.compose.material3.IconButton
3132import androidx.compose.material3.MaterialTheme
3233import androidx.compose.material3.Scaffold
34+import androidx.compose.material3.SnackbarDuration
35+import androidx.compose.material3.SnackbarHost
36+import androidx.compose.material3.SnackbarHostState
37+import androidx.compose.material3.SnackbarResult
38+import androidx.compose.material3.SwipeToDismissBox
39+import androidx.compose.material3.SwipeToDismissBoxValue
3340import androidx.compose.material3.Text
3441import androidx.compose.material3.TextButton
3542import androidx.compose.material3.TopAppBar
43+import androidx.compose.material3.rememberSwipeToDismissBoxState
3644import androidx.compose.runtime.Composable
3745import androidx.compose.runtime.CompositionLocalProvider
3846import androidx.compose.runtime.LaunchedEffect
@@ -43,22 +51,25 @@ import androidx.compose.runtime.rememberCoroutineScope
4351import androidx.compose.runtime.saveable.rememberSaveable
4452import androidx.compose.runtime.setValue
4553import kotlinx.coroutines.Dispatchers
54+import kotlinx.coroutines.delay
4655import kotlinx.coroutines.launch
4756import kotlinx.coroutines.withContext
4857import soy.iko.opencode.util.runCatchingCancellable
4958import soy.iko.opencode.ui.components.showToast
5059import androidx.compose.ui.Alignment
5160import androidx.compose.ui.Modifier
61+import androidx.compose.ui.draw.clip
5262import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalHapticFeedback
5364import androidx.compose.ui.res.stringResource
5465import androidx.compose.ui.semantics.Role
5566import androidx.compose.ui.semantics.contentDescription
5667import androidx.compose.ui.semantics.heading
57−import androidx.compose.ui.semantics.role
5868import androidx.compose.ui.semantics.semantics
5969import androidx.compose.ui.unit.dp
6070import androidx.lifecycle.compose.collectAsStateWithLifecycle
6171import soy.iko.opencode.R
72+import soy.iko.opencode.data.network.NetworkConfig
6273import soy.iko.opencode.data.repo.CrashLogger
6374import soy.iko.opencode.ui.components.LocalRelativeTimeTick
6475import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -74,6 +85,13 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
7485 var showClearAll by rememberSaveable { mutableStateOf(false) }
7586 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
7687 val shareScope = rememberCoroutineScope()
88+ val haptics = LocalHapticFeedback.current
89+ val snackbar = remember { SnackbarHostState() }
90+ val undoLabel = stringResource(R.string.undo)
91+ val reportDeletedLabel = stringResource(R.string.report_deleted)
92+ // Track report names whose deletion has been deferred for an undo window, so a
93+ // swipe-triggered delete can be cancelled before the file is actually removed.
94+ val deferredDeletes = remember { mutableStateOf<Set<String>>(emptySet()) }
7795 val shareLabel = stringResource(R.string.share)
7896 val timeTick = rememberRelativeTimeTick()
7997
@@ -97,6 +115,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
97115 },
98116 )
99117 },
118+ snackbarHost = { SnackbarHost(snackbar) },
100119 ) { padding ->
101120 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
102121 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
@@ -115,54 +134,83 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
115134 .semantics { heading() },
116135 )
117136 if (reports.isEmpty()) {
118− Text(
119− stringResource(R.string.no_crash_reports),
120− style = MaterialTheme.typography.bodyMedium,
121− color = MaterialTheme.colorScheme.onSurfaceVariant,
122− modifier = Modifier.padding(24.dp),
137+ EmptyCrashReports(
138+ modifier = Modifier.fillMaxWidth().padding(24.dp),
123139 )
124140 } else {
125141 LazyColumn(modifier = Modifier.fillMaxSize()) {
126142 items(reports, key = { it.fileName }) { report ->
127− Row(
128− modifier = Modifier
129− .fillMaxWidth()
130− .clickable(role = Role.Button) { viewing = report.fileName }
131− .padding(horizontal = 16.dp, vertical = 12.dp),
132− verticalAlignment = Alignment.CenterVertically,
133− horizontalArrangement = Arrangement.spacedBy(12.dp),
143+ // Swipe end-to-start reveals a delete affordance. The delete is
144+ // deferred for an undo window (matching the session/server lists);
145+ // confirmValueChange snaps back so the row remains while the undo
146+ // snackbar is shown, and is removed only when the window expires.
147+ val swipeState = rememberSwipeToDismissBoxState(
148+ confirmValueChange = {
149+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
150+ pendingReportDelete = report.fileName
151+ false
152+ },
153+ )
154+ SwipeToDismissBox(
155+ state = swipeState,
156+ enableDismissFromStartToEnd = false,
157+ backgroundContent = {
158+ Box(
159+ modifier = Modifier
160+ .fillMaxSize()
161+ .clip(MaterialTheme.shapes.medium)
162+ .background(MaterialTheme.colorScheme.errorContainer)
163+ .padding(horizontal = 20.dp),
164+ contentAlignment = Alignment.CenterEnd,
165+ ) {
166+ Icon(
167+ Icons.Filled.DeleteSweep,
168+ contentDescription = stringResource(R.string.delete),
169+ tint = MaterialTheme.colorScheme.onErrorContainer,
170+ )
171+ }
172+ },
134173 ) {
135− Icon(
136− Icons.Filled.BugReport,
137− contentDescription = null,
138− tint = MaterialTheme.colorScheme.error,
139− )
140− Column(modifier = Modifier.weight(1f)) {
141− Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
142− Text(
143− rememberRelativeTime(report.timestamp),
144− style = MaterialTheme.typography.bodySmall,
145− color = MaterialTheme.colorScheme.onSurfaceVariant,
174+ Row(
175+ modifier = Modifier
176+ .fillMaxWidth()
177+ .clickable(role = Role.Button) { viewing = report.fileName }
178+ .padding(horizontal = 16.dp, vertical = 12.dp),
179+ verticalAlignment = Alignment.CenterVertically,
180+ horizontalArrangement = Arrangement.spacedBy(12.dp),
181+ ) {
182+ Icon(
183+ Icons.Filled.BugReport,
184+ contentDescription = null,
185+ tint = MaterialTheme.colorScheme.error,
146186 )
147− }
148− IconButton(onClick = {
149− shareScope.launch {
150− val content = withContext(Dispatchers.IO) {
151− logger.readReport(report.fileName).orEmpty()
152− }
153− val send = Intent(Intent.ACTION_SEND).apply {
154− type = "text/plain"
155− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
156− putExtra(Intent.EXTRA_TEXT, content)
157− }
158− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
159− .onFailure {
160− Log.w("Diagnostics", "Failed to share crash report", it)
161− showToast(context, context.getString(R.string.no_share_app))
187+ Column(modifier = Modifier.weight(1f)) {
188+ Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
189+ Text(
190+ rememberRelativeTime(report.timestamp),
191+ style = MaterialTheme.typography.bodySmall,
192+ color = MaterialTheme.colorScheme.onSurfaceVariant,
193+ )
194+ }
195+ IconButton(onClick = {
196+ shareScope.launch {
197+ val content = withContext(Dispatchers.IO) {
198+ logger.readReport(report.fileName).orEmpty()
199+ }
200+ val send = Intent(Intent.ACTION_SEND).apply {
201+ type = "text/plain"
202+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
203+ putExtra(Intent.EXTRA_TEXT, content)
162204 }
205+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
206+ .onFailure {
207+ Log.w("Diagnostics", "Failed to share crash report", it)
208+ showToast(context, context.getString(R.string.no_share_app))
209+ }
210+ }
211+ }) {
212+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
163213 }
164− }) {
165− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
166214 }
167215 }
168216 HorizontalDivider()
@@ -191,14 +239,13 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
191239 },
192240 dismissButton = {
193241 Row {
194− val shareLabel2 = stringResource(R.string.share)
195242 TextButton(onClick = {
196243 val send = Intent(Intent.ACTION_SEND).apply {
197244 type = "text/plain"
198− putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
245+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
199246 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
200247 }
201− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel2)) }
248+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
202249 .onFailure {
203250 Log.w("Diagnostics", "Failed to share crash report", it)
204251 showToast(context, context.getString(R.string.no_share_app))
@@ -245,16 +292,38 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
245292 )
246293 }
247294
248− if (pendingReportDelete != null) {
295+ pendingReportDelete?.let { name ->
249296 AlertDialog(
250297 onDismissRequest = { pendingReportDelete = null },
251298 title = { Text(stringResource(R.string.delete_report_title)) },
252299 text = { Text(stringResource(R.string.delete_report_text)) },
253300 confirmButton = {
254301 TextButton(onClick = {
255− pendingReportDelete?.let { logger.deleteReport(it) }
256302 pendingReportDelete = null
257303 viewing = null
304+ // Defer the actual delete so the Undo snackbar can cancel it. The
305+ // report stays in the list until the window expires; the undo
306+ // action removes the name from the deferred set.
307+ deferredDeletes.value = deferredDeletes.value + name
308+ shareScope.launch {
309+ delay(NetworkConfig.undoReportDeleteDelayMs)
310+ if (name in deferredDeletes.value) {
311+ deferredDeletes.value = deferredDeletes.value - name
312+ logger.deleteReport(name)
313+ }
314+ }
315+ // showSnackbar is suspend; run it on the screen's scope so the
316+ // undo action can race the deferred delete window.
317+ shareScope.launch {
318+ val result = snackbar.showSnackbar(
319+ message = reportDeletedLabel,
320+ actionLabel = undoLabel,
321+ duration = SnackbarDuration.Short,
322+ )
323+ if (result == SnackbarResult.ActionPerformed) {
324+ deferredDeletes.value = deferredDeletes.value - name
325+ }
326+ }
258327 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
259328 },
260329 dismissButton = {
@@ -280,3 +349,27 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
280349 )
281350 }
282351}
352+
353+/** Empty state for the crash reports list. Rendered with an icon + text to match the
354+ * empty-state pattern used by the session and server lists (the prior version was a
355+ * bare `Text` line that read as a status message rather than an intentional state). */
356+@Composable
357+private fun EmptyCrashReports(modifier: Modifier = Modifier) {
358+ Column(
359+ modifier = modifier,
360+ horizontalAlignment = Alignment.CenterHorizontally,
361+ ) {
362+ Icon(
363+ Icons.Filled.BugReport,
364+ contentDescription = null,
365+ modifier = Modifier.size(48.dp),
366+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
367+ )
368+ Spacer(Modifier.size(12.dp))
369+ Text(
370+ stringResource(R.string.no_crash_reports),
371+ style = MaterialTheme.typography.bodyMedium,
372+ color = MaterialTheme.colorScheme.onSurfaceVariant,
373+ )
374+ }
375+}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+40−1
@@ -42,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope
4242import androidx.compose.ui.Alignment
4343import androidx.compose.ui.Modifier
4444import androidx.compose.ui.draw.clip
45+import androidx.compose.ui.graphics.Color
4546import androidx.compose.ui.platform.LocalContext
4647import androidx.compose.ui.res.stringResource
4748import androidx.compose.ui.semantics.Role
@@ -56,6 +57,8 @@ import soy.iko.opencode.data.repo.CrashLogger
5657import soy.iko.opencode.data.repo.ThemeMode
5758import soy.iko.opencode.di.AppContainer
5859import soy.iko.opencode.R
60+import soy.iko.opencode.ui.theme.LightPaletteSwatches
61+import soy.iko.opencode.ui.theme.DarkPaletteSwatches
5962import soy.iko.opencode.util.runCatchingCancellable
6063
6164@OptIn(ExperimentalMaterial3Api::class)
@@ -220,6 +223,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
220223
221224@Composable
222225private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
226+ // Preview swatches so the user can see what each mode looks like before selecting,
227+ // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
228+ // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
229+ val swatches: List<Color> = when (mode) {
230+ ThemeMode.SYSTEM -> {
231+ val dark = isSystemInDarkThemeStatic()
232+ if (dark) DarkSwatches else LightSwatches
233+ }
234+ ThemeMode.LIGHT -> LightSwatches
235+ ThemeMode.DARK -> DarkSwatches
236+ }
223237 Row(
224238 modifier = Modifier
225239 .fillMaxWidth()
@@ -236,13 +250,38 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
236250 ThemeMode.DARK -> stringResource(R.string.theme_dark)
237251 },
238252 style = MaterialTheme.typography.bodyLarge,
253+ modifier = Modifier.weight(1f),
239254 )
255+ // Three small color dots previewing the palette for this mode.
256+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
257+ swatches.forEach { color ->
258+ Box(
259+ modifier = Modifier
260+ .size(14.dp)
261+ .clip(CircleShape)
262+ .background(color),
263+ )
264+ }
265+ }
240266 }
241267}
242268
269+// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
270+// the character of each scheme at a glance without rendering a full sample surface.
271+private val DarkSwatches: List<Color> = DarkPaletteSwatches
272+private val LightSwatches: List<Color> = LightPaletteSwatches
273+
274+// Composable-side read of the system dark setting for the "System" theme row preview.
275+// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
276+// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
277+// recomposition surprises.
278+@Composable
279+private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
280+
243281/** Small circular count badge used to indicate pending crash reports. */
244282@Composable
245283private fun Badge(count: Int) {
284+ val overflow = stringResource(R.string.crash_count_overflow)
246285 Box(
247286 modifier = Modifier
248287 .size(20.dp)
@@ -251,7 +290,7 @@ private fun Badge(count: Int) {
251290 contentAlignment = Alignment.Center,
252291 ) {
253292 Text(
254− if (count > 99) "99+" else count.toString(),
293+ if (count > 99) overflow else count.toString(),
255294 style = MaterialTheme.typography.labelSmall,
256295 color = MaterialTheme.colorScheme.onError,
257296 textAlign = TextAlign.Center,
app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.foundation.shape.RoundedCornerShape
4+import androidx.compose.material3.Shapes
5+import androidx.compose.ui.unit.dp
6+
7+/**
8+ * Material 3 shape scale tuned for the chat UI. Bubbles, cards, and sheets use rounded
9+ * corners that read as friendly and conversational; the previous code scattered bare
10+ * `RoundedCornerShape(16.dp)` literals across message bubbles and cards — centralizing
11+ * them here lets the shape scale change in one place and keeps components consistent.
12+ */
13+val OpencodeShapes = Shapes(
14+ extraSmall = RoundedCornerShape(4.dp),
15+ small = RoundedCornerShape(8.dp),
16+ medium = RoundedCornerShape(16.dp),
17+ large = RoundedCornerShape(24.dp),
18+ extraLarge = RoundedCornerShape(32.dp),
19+)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+84
@@ -17,18 +17,82 @@ import androidx.compose.ui.platform.LocalContext
1717import androidx.compose.ui.platform.LocalView
1818import androidx.core.view.WindowCompat
1919
20+/**
21+ * Tokyo Night — a dark, muted blue/purple palette. Every Material 3 color role is set
22+ * explicitly (not just primary/secondary/tertiary) so components that read `error`,
23+ * `primaryContainer`, `surfaceVariant`, `outline`, etc. match the brand instead of
24+ * falling back to stock M3 defaults that clash with it.
25+ *
26+ * Source palette (Tokyo Night):
27+ * bg #1A1B26 fg #C0CAF5 blue #7AA2F7 cyan #7DCFFF
28+ * green #9ECE6A magenta #BB9AF7 red #F7768E yellow #E0AF68
29+ * comment #565F89 gutter #414868
30+ */
2031private val DarkColors = darkColorScheme(
2132 primary = Color(0xFF7AA2F7),
33+ onPrimary = Color(0xFF112036),
34+ primaryContainer = Color(0xFF2A3A5C),
35+ onPrimaryContainer = Color(0xFFD6E2FF),
2236 secondary = Color(0xFF9ECE6A),
37+ onSecondary = Color(0xFF13260A),
38+ secondaryContainer = Color(0xFF2B3B1F),
39+ onSecondaryContainer = Color(0xFFD2F0A0),
2340 tertiary = Color(0xFFBB9AF7),
41+ onTertiary = Color(0xFF221036),
42+ tertiaryContainer = Color(0xFF3A2A55),
43+ onTertiaryContainer = Color(0xFFE6D6FF),
44+ error = Color(0xFFF7768E),
45+ onError = Color(0xFF370610),
46+ errorContainer = Color(0xFF5C1A24),
47+ onErrorContainer = Color(0xFFFFD9DE),
2448 background = Color(0xFF1A1B26),
49+ onBackground = Color(0xFFC0CAF5),
2550 surface = Color(0xFF1F2335),
51+ onSurface = Color(0xFFC0CAF5),
52+ surfaceVariant = Color(0xFF2D3247),
53+ onSurfaceVariant = Color(0xFF9AA5CE),
54+ surfaceTint = Color(0xFF7AA2F7),
55+ inverseSurface = Color(0xFFC0CAF5),
56+ inverseOnSurface = Color(0xFF1A1B26),
57+ outline = Color(0xFF565F89),
58+ outlineVariant = Color(0xFF414868),
59+ scrim = Color(0xFF000000),
2660)
2761
62+/**
63+ * Tokyo Night Light — the day-mode counterpart. Pairs the same hue family with a soft
64+ * off-white background so the brand identity survives a light setting instead of
65+ * snapping to stock M3 purple-grey.
66+ */
2867private val LightColors = lightColorScheme(
2968 primary = Color(0xFF34548A),
69+ onPrimary = Color(0xFFFFFFFF),
70+ primaryContainer = Color(0xFFD6E2FF),
71+ onPrimaryContainer = Color(0xFF001A41),
3072 secondary = Color(0xFF485E30),
73+ onSecondary = Color(0xFFFFFFFF),
74+ secondaryContainer = Color(0xFFC8F0A0),
75+ onSecondaryContainer = Color(0xFF0E2000),
3176 tertiary = Color(0xFF5A4A78),
77+ onTertiary = Color(0xFFFFFFFF),
78+ tertiaryContainer = Color(0xFFE6D6FF),
79+ onTertiaryContainer = Color(0xFF180038),
80+ error = Color(0xFF9B1C2E),
81+ onError = Color(0xFFFFFFFF),
82+ errorContainer = Color(0xFFFFD9DE),
83+ onErrorContainer = Color(0xFF410008),
84+ background = Color(0xFFF6F7FB),
85+ onBackground = Color(0xFF1A1B26),
86+ surface = Color(0xFFF1F3FA),
87+ onSurface = Color(0xFF1A1B26),
88+ surfaceVariant = Color(0xFFE0E4F0),
89+ onSurfaceVariant = Color(0xFF444B6E),
90+ surfaceTint = Color(0xFF34548A),
91+ inverseSurface = Color(0xFF2D3247),
92+ inverseOnSurface = Color(0xFFEEF0FB),
93+ outline = Color(0xFF747DAA),
94+ outlineVariant = Color(0xFFC3C8DA),
95+ scrim = Color(0xFF000000),
3296)
3397
3498@Composable
@@ -53,10 +117,30 @@ fun OpencodeTheme(
53117 }
54118 MaterialTheme(
55119 colorScheme = colorScheme,
120+ typography = OpencodeTypography,
121+ shapes = OpencodeShapes,
56122 content = content,
57123 )
58124}
59125
126+/**
127+ * Preview swatches for the Tokyo Night palettes, exposed so the Settings theme picker
128+ * can show color dots without reaching into the (otherwise private) full color schemes.
129+ * Each list is [primary, secondary, tertiary, background].
130+ */
131+val DarkPaletteSwatches: List<Color> = listOf(
132+ DarkColors.primary,
133+ DarkColors.secondary,
134+ DarkColors.tertiary,
135+ DarkColors.background,
136+)
137+val LightPaletteSwatches: List<Color> = listOf(
138+ LightColors.primary,
139+ LightColors.secondary,
140+ LightColors.tertiary,
141+ LightColors.background,
142+)
143+
60144private tailrec fun Context.findActivity(): Activity? = when (this) {
61145 is Activity -> this
62146 is ContextWrapper -> baseContext.findActivity()
app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktnew file+87
@@ -0,0 +1,87 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.material3.Typography
4+import androidx.compose.ui.text.TextStyle
5+import androidx.compose.ui.text.font.FontFamily
6+import androidx.compose.ui.text.font.FontWeight
7+import androidx.compose.ui.unit.sp
8+
9+/**
10+ * Material 3 typography tuned for chat readability. Keeps the M3 default role names so
11+ * every component that reads [MaterialTheme.typography] gets the adjusted styles
12+ * automatically; only the values that materially affect readability of a long,
13+ * streaming conversation are overridden.
14+ *
15+ * The chat body (markdown text) uses [bodyLarge]/[bodyMedium] with a slightly larger
16+ * line height than the M3 default so token reflow during streaming doesn't feel
17+ * cramped, and a comfortable 15–16sp size. Monospace code uses [labelMedium].
18+ */
19+val OpencodeTypography = Typography(
20+ displaySmall = TextStyle(
21+ fontFamily = FontFamily.Default,
22+ fontWeight = FontWeight.Normal,
23+ fontSize = 30.sp,
24+ lineHeight = 40.sp,
25+ letterSpacing = 0.sp,
26+ ),
27+ headlineMedium = TextStyle(
28+ fontFamily = FontFamily.Default,
29+ fontWeight = FontWeight.SemiBold,
30+ fontSize = 26.sp,
31+ lineHeight = 34.sp,
32+ ),
33+ titleLarge = TextStyle(
34+ fontFamily = FontFamily.Default,
35+ fontWeight = FontWeight.SemiBold,
36+ fontSize = 20.sp,
37+ lineHeight = 26.sp,
38+ ),
39+ titleMedium = TextStyle(
40+ fontFamily = FontFamily.Default,
41+ fontWeight = FontWeight.Medium,
42+ fontSize = 16.sp,
43+ lineHeight = 22.sp,
44+ letterSpacing = 0.1.sp,
45+ ),
46+ bodyLarge = TextStyle(
47+ fontFamily = FontFamily.Default,
48+ fontWeight = FontWeight.Normal,
49+ fontSize = 16.sp,
50+ lineHeight = 24.sp,
51+ letterSpacing = 0.3.sp,
52+ ),
53+ bodyMedium = TextStyle(
54+ fontFamily = FontFamily.Default,
55+ fontWeight = FontWeight.Normal,
56+ fontSize = 14.sp,
57+ lineHeight = 21.sp,
58+ letterSpacing = 0.2.sp,
59+ ),
60+ bodySmall = TextStyle(
61+ fontFamily = FontFamily.Default,
62+ fontWeight = FontWeight.Normal,
63+ fontSize = 12.sp,
64+ lineHeight = 18.sp,
65+ letterSpacing = 0.3.sp,
66+ ),
67+ labelLarge = TextStyle(
68+ fontFamily = FontFamily.Default,
69+ fontWeight = FontWeight.Medium,
70+ fontSize = 14.sp,
71+ lineHeight = 20.sp,
72+ letterSpacing = 0.1.sp,
73+ ),
74+ labelMedium = TextStyle(
75+ fontFamily = FontFamily.Monospace,
76+ fontWeight = FontWeight.Normal,
77+ fontSize = 12.sp,
78+ lineHeight = 18.sp,
79+ ),
80+ labelSmall = TextStyle(
81+ fontFamily = FontFamily.Default,
82+ fontWeight = FontWeight.Medium,
83+ fontSize = 11.sp,
84+ lineHeight = 16.sp,
85+ letterSpacing = 0.4.sp,
86+ ),
87+)
app/src/main/res/values/strings.xml+14−2
@@ -1,7 +1,8 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
33 <string name="app_name">opencode</string>
4− <string name="unknown_version">0.1.0</string>
4+ <!-- Non-numeric fallback so a PackageManager failure can\'t show a stale version number. -->
5+ <string name="unknown_version">—</string>
56
67 <!-- Common -->
78 <string name="back">Back</string>
@@ -37,6 +38,7 @@
3738 <string name="reconnect">Reconnect</string>
3839 <string name="not_connected">Not connected.</string>
3940 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
41+ <string name="retry_now">Retry now</string>
4042
4143 <!-- Server list -->
4244 <string name="servers_title">opencode servers</string>
@@ -50,6 +52,7 @@
5052 <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
5153 <string name="server_auth_short"> • auth</string>
5254 <string name="server_auth_basic"> • Basic auth</string>
55+ <string name="server_removed">Server removed</string>
5356
5457 <!-- Server edit -->
5558 <string name="label_optional">Label (optional)</string>
@@ -77,6 +80,7 @@
7780 <!-- Session list -->
7881 <string name="new_session">New session</string>
7982 <string name="unread">Unread</string>
83+ <string name="unread_count">%1$d unread</string>
8084 <string name="no_sessions_yet">No sessions yet</string>
8185 <string name="no_sessions_hint">Start a conversation with opencode.</string>
8286 <string name="search_sessions">Search sessions…</string>
@@ -97,6 +101,10 @@
97101 <string name="message_placeholder">Message opencode…</string>
98102 <string name="empty_chat_title">Start a conversation</string>
99103 <string name="empty_chat_hint">Type a message below to begin.</string>
104+ <string name="empty_chat_suggest_1">Explain what this codebase does</string>
105+ <string name="empty_chat_suggest_2">Find and fix a bug</string>
106+ <string name="empty_chat_suggest_3">Add a feature</string>
107+ <string name="draft_chars_remaining">%1$d / %2$d</string>
100108 <string name="load_messages_failed">Couldn\'t load messages.</string>
101109 <string name="empty_detail_pane">Select a session to view the conversation.</string>
102110 <string name="working">working…</string>
@@ -176,6 +184,8 @@
176184 <string name="folder">Folder: %1$s</string>
177185 <string name="file_label">File: %1$s</string>
178186 <string name="file">File</string>
187+ <string name="show_diff">Show diff</string>
188+ <string name="show_raw">Show raw</string>
179189
180190 <!-- Message metadata -->
181191 <string name="image">image</string>
@@ -195,7 +205,7 @@
195205 <string name="diagnostics">Diagnostics</string>
196206 <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
197207 <string name="crash_reports">Crash reports</string>
198− <string name="no_crash_reports">No crashes recorded. \ud83d\udc4d</string>
208+ <string name="no_crash_reports">No crashes recorded.</string>
199209 <string name="clear_all">Clear all</string>
200210 <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
201211 <string name="crash_report">Crash report</string>
@@ -204,6 +214,8 @@
204214 <string name="delete_report_text">This report will be permanently deleted.</string>
205215 <string name="report_load_failed">Failed to load crash report.</string>
206216 <string name="crash_count_format">%1$d</string>
217+ <string name="crash_count_overflow">99+</string>
218+ <string name="report_deleted">Crash report deleted</string>
207219
208220 <!-- Notifications -->
209221 <string name="notif_channel_status">Agent activity</string>
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+2−2
@@ -303,13 +303,13 @@ class FakeOpencodeConnection(
303303class FakeAppContainer : AppContainer() {
304304 val fakeDraftStore = FakeDraftStore()
305305 val fakeProfileStore = FakeProfileStore()
306− val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
306+ val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
307307 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
308308
309309 override val draftStore: DraftStore = fakeDraftStore
310310 override val profileStore: ProfileStore = fakeProfileStore
311311 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
312− override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
312+ override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
313313
314314 var connectResult: OpencodeConnection? = null
315315 /** When non-null, [connect] throws this instead of returning a connection. */