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
e131d1eiko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13
@@ -66,6 +66,14 @@ object NetworkConfig {
66 * Undo snackbar lets the user cancel it. */
66 * Undo snackbar lets the user cancel it. */
67 const val undoDeleteDelayMs = 5_000L
67 const val undoDeleteDelayMs = 5_000L
68
68
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+
69 // --- In-memory message store (SessionRepository.MessageStore) ---
77 // --- In-memory message store (SessionRepository.MessageStore) ---
70
78
71 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
79 /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
@@ -91,6 +99,11 @@ object NetworkConfig {
91 * an unbounded string) while leaving plenty of room for long prompts. */
99 * an unbounded string) while leaving plenty of room for long prompts. */
92 const val maxDraftLengthChars = 32_000
100 const val maxDraftLengthChars = 32_000
93
101
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+
94 // --- Profile store (ServerEditViewModel) ---
107 // --- Profile store (ServerEditViewModel) ---
95
108
96 /** Timeout for loading a profile from DataStore before giving up. */
109 /** 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(
122 private val _currentSession = MutableStateFlow<String?>(null)
122 private val _currentSession = MutableStateFlow<String?>(null)
123 open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
123 open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
124
124
125− /** Session ids that received activity while not being viewed. */
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. */
126− private val _unread = MutableStateFlow<Set<String>>(emptySet())
129+ private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
127− open val unread: StateFlow<Set<String>> = _unread.asStateFlow()
130+ open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
128
131
129 /** True when any assistant run is actively streaming across all sessions. Drives the
132 /** True when any assistant run is actively streaming across all sessions. Drives the
130 * disconnect confirmation on the session list (disconnecting mid-run kills it). */
133 * disconnect confirmation on the session list (disconnecting mid-run kills it). */
@@ -139,10 +142,12 @@ open class AppContainer private constructor(
139 }
142 }
140 }
143 }
141
144
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. */
143 open fun restoreUnread(id: String) {
148 open fun restoreUnread(id: String) {
144 if (id != _currentSession.value) {
149 if (id != _currentSession.value) {
145− _unread.update { it + id }
150+ _unread.update { it + (id to (it[id] ?: 1)) }
146 }
151 }
147 }
152 }
148
153
@@ -245,7 +250,9 @@ open class AppContainer private constructor(
245 }
250 }
246 val sid = sessionOf(event) ?: return@collect
251 val sid = sessionOf(event) ?: return@collect
247 if (sid != _currentSession.value) {
252 if (sid != _currentSession.value) {
253+ // Increment the unread count for this session so the badge can
254+ // show "N unread" instead of just a dot.
248− _unread.update { it + sid }
255+ _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
249 }
256 }
250 // Track sessions actively streaming so we know which idle events
257 // Track sessions actively streaming so we know which idle events
251 // represent a finished run worth notifying about.
258 // represent a finished run worth notifying about.
@@ -338,7 +345,7 @@ open class AppContainer private constructor(
338 _activeConnection.value = null
345 _activeConnection.value = null
339 activeRuns.clear()
346 activeRuns.clear()
340 _anyRunActive.value = false
347 _anyRunActive.value = false
341− _unread.value = emptySet()
348+ _unread.value = emptyMap()
342 val now = System.currentTimeMillis()
349 val now = System.currentTimeMillis()
343 val resolved = profileStore.resolve(profile)
350 val resolved = profileStore.resolve(profile)
344 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
351 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) {
106 scope.launch { runCatchingCancellable { container.disconnect() } }
106 scope.launch { runCatchingCancellable { container.disconnect() } }
107 navController.popBackStack(Routes.SERVERS, inclusive = false)
107 navController.popBackStack(Routes.SERVERS, inclusive = false)
108 },
108 },
109+ onAddServer = { navController.navigate(Routes.serverEdit()) },
109 )
110 )
110 } else {
111 } else {
111 SessionListScreen(
112 SessionListScreen(
@@ -126,6 +127,7 @@ fun OpencodeApp(container: AppContainer) {
126 },
127 },
127 onOpenFiles = { navController.navigate(Routes.FILES) },
128 onOpenFiles = { navController.navigate(Routes.FILES) },
128 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
129 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
130+ onAddServer = { navController.navigate(Routes.serverEdit()) },
129 )
131 )
130 }
132 }
131 }
133 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+63−1
@@ -49,6 +49,7 @@ import androidx.compose.material3.Surface
49import androidx.compose.material3.Text
49import androidx.compose.material3.Text
50import androidx.compose.material3.TextButton
50import androidx.compose.material3.TextButton
51import androidx.compose.material3.TopAppBar
51import androidx.compose.material3.TopAppBar
52+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52import androidx.compose.runtime.Composable
53import androidx.compose.runtime.Composable
53import androidx.compose.runtime.CompositionLocalProvider
54import androidx.compose.runtime.CompositionLocalProvider
54import androidx.compose.runtime.DisposableEffect
55import androidx.compose.runtime.DisposableEffect
@@ -392,6 +393,12 @@ fun ChatScreen(
392 ConnectionBanner(
393 ConnectionBanner(
393 state = connectionState,
394 state = connectionState,
394 modifier = Modifier.align(Alignment.TopCenter),
395 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() },
395 )
402 )
396 if (loading && messages.isEmpty()) {
403 if (loading && messages.isEmpty()) {
397 val loadingLabel = stringResource(R.string.loading)
404 val loadingLabel = stringResource(R.string.loading)
@@ -433,9 +440,20 @@ fun ChatScreen(
433 }
440 }
434 } else if (messages.isEmpty()) {
441 } else if (messages.isEmpty()) {
435 EmptyConversation(
442 EmptyConversation(
443+ onSuggestion = { vm.updateDraft(it) },
436 modifier = Modifier.align(Alignment.Center),
444 modifier = Modifier.align(Alignment.Center),
437 )
445 )
438 } else {
446 } 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+ ) {
439 val lastMessageId = messages.lastOrNull()?.info?.id
457 val lastMessageId = messages.lastOrNull()?.info?.id
440 LazyColumn(
458 LazyColumn(
441 state = listState,
459 state = listState,
@@ -464,6 +482,7 @@ fun ChatScreen(
464 }
482 }
465 }
483 }
466 }
484 }
485+ }
467 // Jump-to-latest affordance when the user has scrolled away during a stream.
486 // Jump-to-latest affordance when the user has scrolled away during a stream.
468 AnimatedVisibility(
487 AnimatedVisibility(
469 visible = !isPinnedToBottom && messages.isNotEmpty(),
488 visible = !isPinnedToBottom && messages.isNotEmpty(),
@@ -627,6 +646,25 @@ private fun ChatInputBar(
627 placeholder = { Text(stringResource(R.string.message_placeholder)) },
646 placeholder = { Text(stringResource(R.string.message_placeholder)) },
628 enabled = enabled,
647 enabled = enabled,
629 maxLines = 6,
648 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+ },
630 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
668 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
631 keyboardActions = KeyboardActions(onSend = {
669 keyboardActions = KeyboardActions(onSend = {
632 if (enabled && value.isNotBlank()) {
670 if (enabled && value.isNotBlank()) {
@@ -653,7 +691,20 @@ private fun ChatInputBar(
653}
691}
654
692
655@Composable
693@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+ }
657 Column(
708 Column(
658 modifier = modifier.padding(32.dp),
709 modifier = modifier.padding(32.dp),
659 horizontalAlignment = Alignment.CenterHorizontally,
710 horizontalAlignment = Alignment.CenterHorizontally,
@@ -675,6 +726,17 @@ private fun EmptyConversation(modifier: Modifier = Modifier) {
675 style = MaterialTheme.typography.bodyMedium,
726 style = MaterialTheme.typography.bodyMedium,
676 color = MaterialTheme.colorScheme.onSurfaceVariant,
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
677 )
728 )
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+ }
678 }
740 }
679}
741}
680
742
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1−2
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.shape.RoundedCornerShape
11import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.filled.ContentCopy
11import androidx.compose.material.icons.filled.ContentCopy
13import androidx.compose.material3.Icon
12import androidx.compose.material3.Icon
@@ -63,7 +62,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
63 Column(
62 Column(
64 modifier = Modifier
63 modifier = Modifier
65 .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
64 .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
66− .clip(RoundedCornerShape(16.dp))
65+ .clip(MaterialTheme.shapes.medium)
67 .background(MaterialTheme.colorScheme.primaryContainer)
66 .background(MaterialTheme.colorScheme.primaryContainer)
68 .padding(12.dp),
67 .padding(12.dp),
69 verticalArrangement = Arrangement.spacedBy(6.dp),
68 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 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
3import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
@@ -9,6 +12,7 @@ import androidx.compose.material3.CircularProgressIndicator
9import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
13import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
14import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
12import androidx.compose.runtime.Composable
16import androidx.compose.runtime.Composable
13import androidx.compose.ui.Alignment
17import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
@@ -24,11 +28,17 @@ import soy.iko.opencode.data.network.EventStreamClient
24/**
28/**
25 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
29 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
26 * session list so a dropped stream is visible everywhere, not just mid-conversation.
30 * 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).
27 */
36 */
28@Composable
37@Composable
29fun ConnectionBanner(
38fun ConnectionBanner(
30 state: EventStreamClient.ConnectionState,
39 state: EventStreamClient.ConnectionState,
31 modifier: Modifier = Modifier,
40 modifier: Modifier = Modifier,
41+ onRetry: (() -> Unit)? = null,
32) {
42) {
33 val text = when (state) {
43 val text = when (state) {
34 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
44 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
@@ -36,7 +46,11 @@ fun ConnectionBanner(
36 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
46 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
37 EventStreamClient.ConnectionState.Connected -> null
47 EventStreamClient.ConnectionState.Connected -> null
38 }
48 }
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
40 // Distinguish a hard failure (e.g. bad credentials) from transient
54 // Distinguish a hard failure (e.g. bad credentials) from transient
41 // connecting/reconnecting states by switching to the error palette, so
55 // connecting/reconnecting states by switching to the error palette, so
42 // the banner conveys urgency without relying on text alone.
56 // the banner conveys urgency without relying on text alone.
@@ -57,7 +71,7 @@ fun ConnectionBanner(
57 color = container,
71 color = container,
58 ) {
72 ) {
59 Row(
73 Row(
60− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
74+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
61 horizontalArrangement = Arrangement.Center,
75 horizontalArrangement = Arrangement.Center,
62 verticalAlignment = Alignment.CenterVertically,
76 verticalAlignment = Alignment.CenterVertically,
63 ) {
77 ) {
@@ -71,8 +85,19 @@ fun ConnectionBanner(
71 text,
85 text,
72 style = MaterialTheme.typography.labelMedium,
86 style = MaterialTheme.typography.labelMedium,
73 color = onContainer,
87 color = onContainer,
74− modifier = Modifier.padding(start = 6.dp),
88+ modifier = Modifier
89+ .padding(start = 6.dp)
90+ .weight(1f, fill = false),
75 )
91 )
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+ }
76 }
101 }
77 }
102 }
78 }
103 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+33−4
@@ -5,6 +5,7 @@ import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.fillMaxWidth
@@ -183,7 +184,11 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
183@Composable
184@Composable
184private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
185private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
185 if (results.isEmpty()) {
186 if (results.isEmpty()) {
187+ EmptyFileState(
188+ icon = Icons.Filled.Search,
186− Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
189+ message = stringResource(R.string.no_matches),
190+ modifier = Modifier.fillMaxWidth().padding(24.dp),
191+ )
187 return
192 return
188 }
193 }
189 LazyColumn(modifier = Modifier.fillMaxSize()) {
194 LazyColumn(modifier = Modifier.fillMaxSize()) {
@@ -211,10 +216,10 @@ private fun DirectoryListing(
211 onOpenFile: (String) -> Unit,
216 onOpenFile: (String) -> Unit,
212) {
217) {
213 if (state.entries.isEmpty()) {
218 if (state.entries.isEmpty()) {
214− Text(
219+ EmptyFileState(
220+ icon = Icons.Filled.Folder,
215− stringResource(R.string.empty_folder),
221+ message = stringResource(R.string.empty_folder),
216 modifier = Modifier.fillMaxWidth().padding(24.dp),
222 modifier = Modifier.fillMaxWidth().padding(24.dp),
217− color = MaterialTheme.colorScheme.onSurfaceVariant,
218 )
223 )
219 return
224 return
220 }
225 }
@@ -237,6 +242,30 @@ private fun DirectoryListing(
237 }
242 }
238}
243}
239
244
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+
240@Composable
269@Composable
241private fun FileRow(
270private fun FileRow(
242 icon: Boolean,
271 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
2
2
3import android.content.Intent
3import android.content.Intent
4import soy.iko.opencode.ui.components.showToast
4import 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
5import androidx.compose.foundation.horizontalScroll
10import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Column
@@ -22,6 +27,7 @@ import androidx.compose.material.icons.filled.Refresh
22import androidx.compose.material.icons.filled.Share
27import androidx.compose.material.icons.filled.Share
23import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.ExperimentalMaterial3Api
30+import androidx.compose.material3.FilterChip
25import androidx.compose.material3.Icon
31import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
32import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.MaterialTheme
@@ -31,7 +37,10 @@ import androidx.compose.material3.TextButton
31import androidx.compose.material3.TopAppBar
37import androidx.compose.material3.TopAppBar
32import androidx.compose.runtime.Composable
38import androidx.compose.runtime.Composable
33import androidx.compose.runtime.getValue
39import androidx.compose.runtime.getValue
40+import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.remember
41import androidx.compose.runtime.remember
42+import androidx.compose.runtime.saveable.rememberSaveable
43+import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
44import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
45import androidx.compose.ui.Modifier
37import androidx.compose.ui.platform.LocalContext
46import androidx.compose.ui.platform.LocalContext
@@ -47,6 +56,8 @@ import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.R
56import soy.iko.opencode.R
48import soy.iko.opencode.ui.components.DiffView
57import soy.iko.opencode.ui.components.DiffView
49import soy.iko.opencode.ui.components.copyToClipboard
58import soy.iko.opencode.ui.components.copyToClipboard
59+import soy.iko.opencode.ui.components.highlightLine
60+import soy.iko.opencode.ui.components.rememberHighlightPalette
50import soy.iko.opencode.ui.vmFactory
61import soy.iko.opencode.ui.vmFactory
51import soy.iko.opencode.util.runCatchingCancellable
62import soy.iko.opencode.util.runCatchingCancellable
52
63
@@ -60,8 +71,20 @@ fun FileViewScreen(
60 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
71 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
61 val state by vm.state.collectAsStateWithLifecycle()
72 val state by vm.state.collectAsStateWithLifecycle()
62 val context = LocalContext.current
73 val context = LocalContext.current
74+ // Trim a trailing slash so "dir/" doesn't yield an empty filename; fall back to the
75+ // raw path if there's no basename at all (e.g. the root "/").
63− val filename = remember(path) { path.substringAfterLast('/') }
76+ val filename = remember(path) {
77+ val trimmed = path.trimEnd('/')
78+ trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
79+ }
64 val shareLabel = stringResource(R.string.share)
80 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()
65
88
66 Scaffold(
89 Scaffold(
67 topBar = {
90 topBar = {
@@ -103,51 +126,88 @@ fun FileViewScreen(
103 },
126 },
104 ) { padding ->
127 ) { padding ->
105 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
128 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
106− when {
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 {
107− state.loading -> {
139+ s.loading -> {
108− val loadingLabel = stringResource(R.string.loading)
140+ val loadingLabel = stringResource(R.string.loading)
109− CircularProgressIndicator(
141+ CircularProgressIndicator(
110− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
142+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
111− )
143+ )
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))
124 }
144 }
125− }
145+ s.error != null -> Column(
126− state.content?.isBinary == true -> Text(
127− stringResource(R.string.binary_file),
128− modifier = Modifier.align(Alignment.Center).padding(24.dp),
146+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
147+ horizontalAlignment = Alignment.CenterHorizontally,
148+ ) {
149+ Text(
150+ s.error ?: "",
129− color = MaterialTheme.colorScheme.onSurfaceVariant,
151+ color = MaterialTheme.colorScheme.error,
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),
140 )
152 )
141− } else {
153+ Spacer(Modifier.size(12.dp))
142− val text = content?.content.orEmpty()
154+ TextButton(onClick = { vm.reload() }) {
143− if (text.isEmpty()) {
155+ Text(stringResource(R.string.retry))
144− Text(
156+ }
157+ }
158+ s.content?.isBinary == true -> Text(
145− stringResource(R.string.empty_file),
159+ stringResource(R.string.binary_file),
146− modifier = Modifier.align(Alignment.Center).padding(24.dp),
160+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
147− color = MaterialTheme.colorScheme.onSurfaceVariant,
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),
148 )
195 )
149 } else {
196 } else {
197+ val text = content?.content.orEmpty()
198+ if (text.isEmpty()) {
199+ Text(
200+ stringResource(R.string.empty_file),
201+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
202+ color = MaterialTheme.colorScheme.onSurfaceVariant,
203+ )
204+ } else {
150− FileTextContent(text)
205+ FileTextContent(
206+ text = text,
207+ filename = filename,
208+ topInset = if (showToggle) 48.dp else 0.dp,
209+ )
210+ }
151 }
211 }
152 }
212 }
153 }
213 }
@@ -157,15 +217,17 @@ fun FileViewScreen(
157}
217}
158
218
159@Composable
219@Composable
160−private fun FileTextContent(text: String) {
220+private fun FileTextContent(text: String, filename: String, topInset: androidx.compose.ui.unit.Dp) {
161 val lines = remember(text) { text.split("\n") }
221 val lines = remember(text) { text.split("\n") }
162 val gutterWidth = remember(lines.size) {
222 val gutterWidth = remember(lines.size) {
163 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
223 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
164 }
224 }
165 val hScrollState = rememberScrollState()
225 val hScrollState = rememberScrollState()
226+ val palette = rememberHighlightPalette()
166 LazyColumn(
227 LazyColumn(
167 modifier = Modifier
228 modifier = Modifier
168 .fillMaxSize()
229 .fillMaxSize()
230+ .padding(top = topInset)
169 .padding(8.dp),
231 .padding(8.dp),
170 ) {
232 ) {
171 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
233 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
@@ -177,11 +239,12 @@ private fun FileTextContent(text: String) {
177 fontFamily = FontFamily.Monospace,
239 fontFamily = FontFamily.Monospace,
178 color = MaterialTheme.colorScheme.onSurfaceVariant,
240 color = MaterialTheme.colorScheme.onSurfaceVariant,
179 )
241 )
242+ // Heuristic syntax highlighting per line. Falls back to plain text for
243+ // unknown extensions, so non-code files render unchanged.
180 Text(
244 Text(
181− line,
245+ highlightLine(line, filename, palette),
182 modifier = Modifier.padding(start = 8.dp),
246 modifier = Modifier.padding(start = 8.dp),
183 style = MaterialTheme.typography.bodySmall,
247 style = MaterialTheme.typography.bodySmall,
184− fontFamily = FontFamily.Monospace,
185 )
248 )
186 }
249 }
187 }
250 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+134−42
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.server
1package soy.iko.opencode.ui.server
2
2
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
@@ -28,11 +29,16 @@ import androidx.compose.material3.IconButton
28import androidx.compose.material3.LinearProgressIndicator
29import androidx.compose.material3.LinearProgressIndicator
29import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.Scaffold
31import androidx.compose.material3.Scaffold
32+import androidx.compose.material3.SnackbarDuration
31import androidx.compose.material3.SnackbarHost
33import androidx.compose.material3.SnackbarHost
32import androidx.compose.material3.SnackbarHostState
34import androidx.compose.material3.SnackbarHostState
35+import androidx.compose.material3.SnackbarResult
36+import androidx.compose.material3.SwipeToDismissBox
37+import androidx.compose.material3.SwipeToDismissBoxValue
33import androidx.compose.material3.Text
38import androidx.compose.material3.Text
34import androidx.compose.material3.TextButton
39import androidx.compose.material3.TextButton
35import androidx.compose.material3.TopAppBar
40import androidx.compose.material3.TopAppBar
41+import androidx.compose.material3.rememberSwipeToDismissBoxState
36import androidx.compose.runtime.Composable
42import androidx.compose.runtime.Composable
37import androidx.compose.runtime.LaunchedEffect
43import androidx.compose.runtime.LaunchedEffect
38import androidx.compose.runtime.getValue
44import androidx.compose.runtime.getValue
@@ -42,6 +48,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
42import androidx.compose.runtime.setValue
48import androidx.compose.runtime.setValue
43import androidx.compose.ui.Alignment
49import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
50import androidx.compose.ui.Modifier
51+import androidx.compose.ui.draw.clip
45import androidx.compose.ui.platform.LocalHapticFeedback
52import androidx.compose.ui.platform.LocalHapticFeedback
46import androidx.compose.ui.platform.testTag
53import androidx.compose.ui.platform.testTag
47import androidx.compose.ui.semantics.Role
54import androidx.compose.ui.semantics.Role
@@ -73,6 +80,8 @@ fun ServerListScreen(
73 val haptics = LocalHapticFeedback.current
80 val haptics = LocalHapticFeedback.current
74 val snackbar = remember { SnackbarHostState() }
81 val snackbar = remember { SnackbarHostState() }
75 val retryLabel = stringResource(R.string.retry)
82 val retryLabel = stringResource(R.string.retry)
83+ val undoLabel = stringResource(R.string.undo)
84+ val serverRemovedLabel = stringResource(R.string.server_removed)
76 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
85 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
77 val connectedId = activeConnection?.profile?.id
86 val connectedId = activeConnection?.profile?.id
78
87
@@ -83,12 +92,28 @@ fun ServerListScreen(
83 } else {
92 } else {
84 snackbar.showSnackbar(event.message)
93 snackbar.showSnackbar(event.message)
85 }
94 }
86− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed && event.profile != null) {
95+ if (result == SnackbarResult.ActionPerformed && event.profile != null) {
87 vm.connect(event.profile, onConnected)
96 vm.connect(event.profile, onConnected)
88 }
97 }
89 }
98 }
90 }
99 }
91
100
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+
92 // On cold start the container auto-reconnects to the last server; once it lands,
117 // On cold start the container auto-reconnects to the last server; once it lands,
93 // skip this screen and go straight to the session list. Consumed once so a later
118 // skip this screen and go straight to the session list. Consumed once so a later
94 // manual visit to this screen doesn't bounce the user back out.
119 // manual visit to this screen doesn't bounce the user back out.
@@ -126,42 +151,44 @@ fun ServerListScreen(
126 ) {
151 ) {
127 items(profiles, key = { it.id }) { profile ->
152 items(profiles, key = { it.id }) { profile ->
128 val isActive = profile.id == connectedId
153 val isActive = profile.id == connectedId
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.
129− Card(
159+ Card(
130− modifier = Modifier
160+ modifier = Modifier
131− .fillMaxWidth()
161+ .fillMaxWidth()
132− .testTag("server_card")
162+ .testTag("server_card")
133− .clickable(enabled = connectingId == null, role = Role.Button) {
163+ .clickable(enabled = connectingId == null, role = Role.Button) {
134− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
164+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
135− vm.connect(profile, onConnected)
165+ vm.connect(profile, onConnected)
136− },
166+ },
137− ) {
138− Row(
139− modifier = Modifier.fillMaxWidth().padding(16.dp),
140− verticalAlignment = Alignment.CenterVertically,
141 ) {
167 ) {
168+ Row(
169+ modifier = Modifier.fillMaxWidth().padding(16.dp),
170+ verticalAlignment = Alignment.CenterVertically,
171+ ) {
142− Column(modifier = Modifier.weight(1f)) {
172+ Column(modifier = Modifier.weight(1f)) {
143− Text(
173+ Text(
144− profile.displayLabel,
174+ profile.displayLabel,
145− style = MaterialTheme.typography.titleMedium,
175+ style = MaterialTheme.typography.titleMedium,
146− maxLines = 1,
176+ maxLines = 1,
147− overflow = TextOverflow.Ellipsis,
177+ overflow = TextOverflow.Ellipsis,
148− )
178+ )
149− Text(
179+ Text(
150− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
180+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
151− style = MaterialTheme.typography.bodySmall,
181+ style = MaterialTheme.typography.bodySmall,
152− color = MaterialTheme.colorScheme.onSurfaceVariant,
182+ color = MaterialTheme.colorScheme.onSurfaceVariant,
153− maxLines = 1,
183+ maxLines = 1,
154− overflow = TextOverflow.Ellipsis,
184+ overflow = TextOverflow.Ellipsis,
155− )
185+ )
156− if (isActive) {
157 Text(
186 Text(
158 stringResource(R.string.connected),
187 stringResource(R.string.connected),
159 style = MaterialTheme.typography.labelSmall,
188 style = MaterialTheme.typography.labelSmall,
160 color = MaterialTheme.colorScheme.primary,
189 color = MaterialTheme.colorScheme.primary,
161 )
190 )
162 }
191 }
163− }
164− if (isActive) {
165 Icon(
192 Icon(
166 Icons.Filled.CheckCircle,
193 Icons.Filled.CheckCircle,
167 contentDescription = stringResource(R.string.connected),
194 contentDescription = stringResource(R.string.connected),
@@ -169,22 +196,85 @@ fun ServerListScreen(
169 modifier = Modifier.size(22.dp),
196 modifier = Modifier.size(22.dp),
170 )
197 )
171 // Keep edit available on the active server so credentials can be
198 // Keep edit available on the active server so credentials can be
172− // fixed without disconnecting first. Only delete is hidden, since
199+ // fixed without disconnecting first. Only delete is hidden.
173− // deleting the active server also disconnects (SV1 warns about that).
174 IconButton(onClick = { onEditProfile(profile.id) }) {
200 IconButton(onClick = { onEditProfile(profile.id) }) {
175 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
201 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
176 }
202 }
177− } else if (connectingId == profile.id) {
203+ }
178− val connectingLabel = stringResource(R.string.connecting)
204+ }
179− CircularProgressIndicator(
205+ } else {
180− modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
206+ // Swipe end-to-start reveals a delete affordance and opens the
181− )
207+ // same confirmation dialog as the trash icon (matching the
182− } else {
208+ // session list). confirmValueChange always returns false (snap
183− IconButton(onClick = { onEditProfile(profile.id) }) {
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,
184− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
231+ contentDescription = stringResource(R.string.delete),
232+ tint = MaterialTheme.colorScheme.onErrorContainer,
233+ )
185 }
234 }
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+ }
186− IconButton(onClick = { pendingDeleteId = profile.id }) {
274+ IconButton(onClick = { pendingDeleteId = profile.id }) {
187− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
275+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
276+ }
277+ }
188 }
278 }
189 }
279 }
190 }
280 }
@@ -210,6 +300,8 @@ fun ServerListScreen(
210 TextButton(onClick = {
300 TextButton(onClick = {
211 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
301 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
212 pendingDeleteId = null
302 pendingDeleteId = null
303+ // delete() defers the actual removal so the undo snackbar can cancel
304+ // it; the row disappears on the next profiles emission.
213 vm.delete(profile)
305 vm.delete(profile)
214 }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
306 }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
215 },
307 },
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+41−4
@@ -8,6 +8,7 @@ import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.R
8import soy.iko.opencode.R
9import soy.iko.opencode.util.runCatchingCancellable
9import soy.iko.opencode.util.runCatchingCancellable
10import kotlinx.coroutines.channels.BufferOverflow
10import kotlinx.coroutines.channels.BufferOverflow
11+import kotlinx.coroutines.delay
11import kotlinx.coroutines.flow.MutableSharedFlow
12import kotlinx.coroutines.flow.MutableSharedFlow
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.SharedFlow
14import kotlinx.coroutines.flow.SharedFlow
@@ -42,6 +43,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
42 )
43 )
43 val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
44 val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
44
45
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+
45 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
57 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
46 if (_connecting.value != null) return
58 if (_connecting.value != null) return
47 _connecting.value = profile.id
59 _connecting.value = profile.id
@@ -62,13 +74,38 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
62 }
74 }
63 }
75 }
64
76
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+ */
65 fun delete(profile: ServerProfile) {
85 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)
66 viewModelScope.launch {
93 viewModelScope.launch {
67− if (container.activeConnection.value?.profile?.id == profile.id) {
94+ delay(NetworkConfig.undoServerDeleteDelayMs)
68− container.disconnect()
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)) }
69 }
100 }
70− runCatchingCancellable { container.profileStore.delete(profile.id) }
101+ }
71− .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
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
72 }
109 }
73 }
110 }
74}
111}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+194−126
@@ -91,6 +91,7 @@ fun SessionListScreen(
91 onDisconnect: () -> Unit,
91 onDisconnect: () -> Unit,
92 onOpenFiles: () -> Unit,
92 onOpenFiles: () -> Unit,
93 onOpenSettings: () -> Unit,
93 onOpenSettings: () -> Unit,
94+ onAddServer: () -> Unit,
94) {
95) {
95 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
96 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
96 val state by vm.state.collectAsStateWithLifecycle()
97 val state by vm.state.collectAsStateWithLifecycle()
@@ -159,6 +160,10 @@ fun SessionListScreen(
159 showServerMenu = false
160 showServerMenu = false
160 vm.switchServer(profile)
161 vm.switchServer(profile)
161 },
162 },
163+ onAddServer = {
164+ showServerMenu = false
165+ onAddServer()
166+ },
162 )
167 )
163 },
168 },
164 actions = {
169 actions = {
@@ -199,119 +204,20 @@ fun SessionListScreen(
199 ConnectionBanner(
204 ConnectionBanner(
200 state = connectionState,
205 state = connectionState,
201 modifier = Modifier.align(Alignment.TopCenter),
206 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 },
202 )
220 )
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− }
315 }
221 }
316 }
222 }
317 }
223 }
@@ -366,6 +272,134 @@ fun SessionListScreen(
366 }
272 }
367}
273}
368
274
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+
369@Composable
403@Composable
370private fun RenameSessionDialog(
404private fun RenameSessionDialog(
371 title: String,
405 title: String,
@@ -402,7 +436,7 @@ private fun RenameSessionDialog(
402private fun SessionCard(
436private fun SessionCard(
403 session: Session,
437 session: Session,
404 preview: String?,
438 preview: String?,
405− unread: Boolean,
439+ unreadCount: Int,
406 onClick: () -> Unit,
440 onClick: () -> Unit,
407 onRename: () -> Unit,
441 onRename: () -> Unit,
408 onDelete: () -> Unit,
442 onDelete: () -> Unit,
@@ -420,21 +454,42 @@ private fun SessionCard(
420 ) {
454 ) {
421 Column(modifier = Modifier.weight(1f)) {
455 Column(modifier = Modifier.weight(1f)) {
422 Row(verticalAlignment = Alignment.CenterVertically) {
456 Row(verticalAlignment = Alignment.CenterVertically) {
423− if (unread) {
457+ if (unreadCount > 0) {
424− val unreadLabel = stringResource(R.string.unread)
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) {
425− Box(
463+ Box(
426− modifier = Modifier
464+ modifier = Modifier
427− .padding(end = 8.dp)
465+ .padding(end = 8.dp)
428− .size(8.dp)
466+ .size(8.dp)
429− .clip(androidx.compose.foundation.shape.CircleShape)
467+ .clip(androidx.compose.foundation.shape.CircleShape)
430− .background(MaterialTheme.colorScheme.primary)
468+ .background(MaterialTheme.colorScheme.primary)
431− .semantics { contentDescription = unreadLabel },
469+ .semantics { contentDescription = unreadLabel },
432− )
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+ }
433 }
488 }
434 Text(
489 Text(
435 session.displayTitle,
490 session.displayTitle,
436 style = MaterialTheme.typography.titleMedium,
491 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,
438 maxLines = 1,
493 maxLines = 1,
439 overflow = TextOverflow.Ellipsis,
494 overflow = TextOverflow.Ellipsis,
440 )
495 )
@@ -461,7 +516,7 @@ private fun SessionCard(
461 Text(
516 Text(
462 preview,
517 preview,
463 style = MaterialTheme.typography.bodyMedium,
518 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,
465 maxLines = 2,
520 maxLines = 2,
466 overflow = TextOverflow.Ellipsis,
521 overflow = TextOverflow.Ellipsis,
467 )
522 )
@@ -500,7 +555,8 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
500
555
501/** Title + dropdown for quick-switching between saved servers. Highlights the active
556/** Title + dropdown for quick-switching between saved servers. Highlights the active
502 * server with a check mark and primary color so the user can tell which one they're
557 * 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. */
504@Composable
560@Composable
505private fun ServerSwitcherMenu(
561private fun ServerSwitcherMenu(
506 serverLabel: String,
562 serverLabel: String,
@@ -511,6 +567,7 @@ private fun ServerSwitcherMenu(
511 onExpand: () -> Unit,
567 onExpand: () -> Unit,
512 onDismiss: () -> Unit,
568 onDismiss: () -> Unit,
513 onSelect: (ServerProfile) -> Unit,
569 onSelect: (ServerProfile) -> Unit,
570+ onAddServer: () -> Unit,
514) {
571) {
515 Column {
572 Column {
516 Box {
573 Box {
@@ -569,6 +626,17 @@ private fun ServerSwitcherMenu(
569 onClick = { onSelect(profile) },
626 onClick = { onSelect(profile) },
570 )
627 )
571 }
628 }
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+ )
572 }
640 }
573 }
641 }
574 }
642 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−3
@@ -69,8 +69,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
69 initialValue = EventStreamClient.ConnectionState.Disconnected,
69 initialValue = EventStreamClient.ConnectionState.Disconnected,
70 )
70 )
71
71
72− /** Sessions with activity that arrived while not being viewed. */
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. */
73− val unread: StateFlow<Set<String>> = container.unread
74+ val unread: StateFlow<Map<String, Int>> = container.unread
74
75
75 val profiles: StateFlow<List<ServerProfile>> =
76 val profiles: StateFlow<List<ServerProfile>> =
76 container.profileStore.profiles.stateIn(
77 container.profileStore.profiles.stateIn(
@@ -234,6 +235,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
234 _state.update { it.copy(query = query) }
235 _state.update { it.copy(query = query) }
235 }
236 }
236
237
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+
237 fun refresh() {
245 fun refresh() {
238 val conn = container.activeConnection.value
246 val conn = container.activeConnection.value
239 if (conn == null) {
247 if (conn == null) {
@@ -436,7 +444,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
436 val restored = runCatchingCancellable { container.connect(previousProfile) }
444 val restored = runCatchingCancellable { container.connect(previousProfile) }
437 // connect() clears unread state; restore what we saved so the
445 // connect() clears unread state; restore what we saved so the
438 // user doesn't lose unread badges on a failed server switch.
446 // user doesn't lose unread badges on a failed server switch.
439− savedUnread.forEach { container.restoreUnread(it) }
447+ savedUnread.forEach { (id) -> container.restoreUnread(id) }
440 if (restored.isSuccess) {
448 if (restored.isSuccess) {
441 // Clear stale sessions from the old server; the SSE observer
449 // Clear stale sessions from the old server; the SSE observer
442 // will reload from the restored connection via refresh().
450 // 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(
45 onOpenFiles: () -> Unit,
45 onOpenFiles: () -> Unit,
46 onOpenSettings: () -> Unit,
46 onOpenSettings: () -> Unit,
47 onDisconnect: () -> Unit,
47 onDisconnect: () -> Unit,
48+ onAddServer: () -> Unit,
48 modifier: Modifier = Modifier,
49 modifier: Modifier = Modifier,
49) {
50) {
50 var selected by rememberSaveable { mutableStateOf<String?>(null) }
51 var selected by rememberSaveable { mutableStateOf<String?>(null) }
@@ -86,6 +87,7 @@ fun TwoPaneSessionChat(
86 onDisconnect = onDisconnect,
87 onDisconnect = onDisconnect,
87 onOpenFiles = onOpenFiles,
88 onOpenFiles = onOpenFiles,
88 onOpenSettings = onOpenSettings,
89 onOpenSettings = onOpenSettings,
90+ onAddServer = onAddServer,
89 )
91 )
90 }
92 }
91
93
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+139−46
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.settings
2
2
3import android.content.Intent
3import android.content.Intent
4import android.util.Log
4import android.util.Log
5+import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Box
@@ -30,9 +31,16 @@ import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
31import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.Scaffold
33import 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
33import androidx.compose.material3.Text
40import androidx.compose.material3.Text
34import androidx.compose.material3.TextButton
41import androidx.compose.material3.TextButton
35import androidx.compose.material3.TopAppBar
42import androidx.compose.material3.TopAppBar
43+import androidx.compose.material3.rememberSwipeToDismissBoxState
36import androidx.compose.runtime.Composable
44import androidx.compose.runtime.Composable
37import androidx.compose.runtime.CompositionLocalProvider
45import androidx.compose.runtime.CompositionLocalProvider
38import androidx.compose.runtime.LaunchedEffect
46import androidx.compose.runtime.LaunchedEffect
@@ -43,22 +51,25 @@ import androidx.compose.runtime.rememberCoroutineScope
43import androidx.compose.runtime.saveable.rememberSaveable
51import androidx.compose.runtime.saveable.rememberSaveable
44import androidx.compose.runtime.setValue
52import androidx.compose.runtime.setValue
45import kotlinx.coroutines.Dispatchers
53import kotlinx.coroutines.Dispatchers
54+import kotlinx.coroutines.delay
46import kotlinx.coroutines.launch
55import kotlinx.coroutines.launch
47import kotlinx.coroutines.withContext
56import kotlinx.coroutines.withContext
48import soy.iko.opencode.util.runCatchingCancellable
57import soy.iko.opencode.util.runCatchingCancellable
49import soy.iko.opencode.ui.components.showToast
58import soy.iko.opencode.ui.components.showToast
50import androidx.compose.ui.Alignment
59import androidx.compose.ui.Alignment
51import androidx.compose.ui.Modifier
60import androidx.compose.ui.Modifier
61+import androidx.compose.ui.draw.clip
52import androidx.compose.ui.platform.LocalContext
62import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalHapticFeedback
53import androidx.compose.ui.res.stringResource
64import androidx.compose.ui.res.stringResource
54import androidx.compose.ui.semantics.Role
65import androidx.compose.ui.semantics.Role
55import androidx.compose.ui.semantics.contentDescription
66import androidx.compose.ui.semantics.contentDescription
56import androidx.compose.ui.semantics.heading
67import androidx.compose.ui.semantics.heading
57−import androidx.compose.ui.semantics.role
58import androidx.compose.ui.semantics.semantics
68import androidx.compose.ui.semantics.semantics
59import androidx.compose.ui.unit.dp
69import androidx.compose.ui.unit.dp
60import androidx.lifecycle.compose.collectAsStateWithLifecycle
70import androidx.lifecycle.compose.collectAsStateWithLifecycle
61import soy.iko.opencode.R
71import soy.iko.opencode.R
72+import soy.iko.opencode.data.network.NetworkConfig
62import soy.iko.opencode.data.repo.CrashLogger
73import soy.iko.opencode.data.repo.CrashLogger
63import soy.iko.opencode.ui.components.LocalRelativeTimeTick
74import soy.iko.opencode.ui.components.LocalRelativeTimeTick
64import soy.iko.opencode.ui.components.rememberRelativeTime
75import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -74,6 +85,13 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
74 var showClearAll by rememberSaveable { mutableStateOf(false) }
85 var showClearAll by rememberSaveable { mutableStateOf(false) }
75 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
86 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
76 val shareScope = rememberCoroutineScope()
87 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()) }
77 val shareLabel = stringResource(R.string.share)
95 val shareLabel = stringResource(R.string.share)
78 val timeTick = rememberRelativeTimeTick()
96 val timeTick = rememberRelativeTimeTick()
79
97
@@ -97,6 +115,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
97 },
115 },
98 )
116 )
99 },
117 },
118+ snackbarHost = { SnackbarHost(snackbar) },
100 ) { padding ->
119 ) { padding ->
101 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
120 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
102 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
121 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
@@ -115,54 +134,83 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
115 .semantics { heading() },
134 .semantics { heading() },
116 )
135 )
117 if (reports.isEmpty()) {
136 if (reports.isEmpty()) {
118− Text(
137+ EmptyCrashReports(
119− stringResource(R.string.no_crash_reports),
120− style = MaterialTheme.typography.bodyMedium,
121− color = MaterialTheme.colorScheme.onSurfaceVariant,
122− modifier = Modifier.padding(24.dp),
138+ modifier = Modifier.fillMaxWidth().padding(24.dp),
123 )
139 )
124 } else {
140 } else {
125 LazyColumn(modifier = Modifier.fillMaxSize()) {
141 LazyColumn(modifier = Modifier.fillMaxSize()) {
126 items(reports, key = { it.fileName }) { report ->
142 items(reports, key = { it.fileName }) { report ->
127− Row(
143+ // Swipe end-to-start reveals a delete affordance. The delete is
128− modifier = Modifier
144+ // deferred for an undo window (matching the session/server lists);
129− .fillMaxWidth()
145+ // confirmValueChange snaps back so the row remains while the undo
130− .clickable(role = Role.Button) { viewing = report.fileName }
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)
131− .padding(horizontal = 16.dp, vertical = 12.dp),
163+ .padding(horizontal = 20.dp),
132− verticalAlignment = Alignment.CenterVertically,
164+ contentAlignment = Alignment.CenterEnd,
133− horizontalArrangement = Arrangement.spacedBy(12.dp),
165+ ) {
166+ Icon(
167+ Icons.Filled.DeleteSweep,
168+ contentDescription = stringResource(R.string.delete),
169+ tint = MaterialTheme.colorScheme.onErrorContainer,
170+ )
171+ }
172+ },
134 ) {
173 ) {
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+ ) {
135− Icon(
182+ Icon(
136− Icons.Filled.BugReport,
183+ Icons.Filled.BugReport,
137− contentDescription = null,
184+ contentDescription = null,
138− tint = MaterialTheme.colorScheme.error,
185+ 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,
146 )
186 )
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+ )
147− }
194+ }
148− IconButton(onClick = {
195+ IconButton(onClick = {
149− shareScope.launch {
196+ shareScope.launch {
150− val content = withContext(Dispatchers.IO) {
197+ val content = withContext(Dispatchers.IO) {
151− logger.readReport(report.fileName).orEmpty()
198+ logger.readReport(report.fileName).orEmpty()
152− }
199+ }
153− val send = Intent(Intent.ACTION_SEND).apply {
200+ val send = Intent(Intent.ACTION_SEND).apply {
154− type = "text/plain"
201+ type = "text/plain"
155− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
202+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
156− putExtra(Intent.EXTRA_TEXT, content)
203+ 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))
162 }
204 }
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))
163 }
213 }
164− }) {
165− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
166 }
214 }
167 }
215 }
168 HorizontalDivider()
216 HorizontalDivider()
@@ -191,14 +239,13 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
191 },
239 },
192 dismissButton = {
240 dismissButton = {
193 Row {
241 Row {
194− val shareLabel2 = stringResource(R.string.share)
195 TextButton(onClick = {
242 TextButton(onClick = {
196 val send = Intent(Intent.ACTION_SEND).apply {
243 val send = Intent(Intent.ACTION_SEND).apply {
197 type = "text/plain"
244 type = "text/plain"
198− putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
245+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
199 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
246 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
200 }
247 }
201− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel2)) }
248+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
202 .onFailure {
249 .onFailure {
203 Log.w("Diagnostics", "Failed to share crash report", it)
250 Log.w("Diagnostics", "Failed to share crash report", it)
204 showToast(context, context.getString(R.string.no_share_app))
251 showToast(context, context.getString(R.string.no_share_app))
@@ -245,16 +292,38 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
245 )
292 )
246 }
293 }
247
294
248− if (pendingReportDelete != null) {
295+ pendingReportDelete?.let { name ->
249 AlertDialog(
296 AlertDialog(
250 onDismissRequest = { pendingReportDelete = null },
297 onDismissRequest = { pendingReportDelete = null },
251 title = { Text(stringResource(R.string.delete_report_title)) },
298 title = { Text(stringResource(R.string.delete_report_title)) },
252 text = { Text(stringResource(R.string.delete_report_text)) },
299 text = { Text(stringResource(R.string.delete_report_text)) },
253 confirmButton = {
300 confirmButton = {
254 TextButton(onClick = {
301 TextButton(onClick = {
255− pendingReportDelete?.let { logger.deleteReport(it) }
256 pendingReportDelete = null
302 pendingReportDelete = null
257 viewing = null
303 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+ }
258 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
327 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
259 },
328 },
260 dismissButton = {
329 dismissButton = {
@@ -280,3 +349,27 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
280 )
349 )
281 }
350 }
282}
351}
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
42import androidx.compose.ui.Alignment
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.draw.clip
44import androidx.compose.ui.draw.clip
45+import androidx.compose.ui.graphics.Color
45import androidx.compose.ui.platform.LocalContext
46import androidx.compose.ui.platform.LocalContext
46import androidx.compose.ui.res.stringResource
47import androidx.compose.ui.res.stringResource
47import androidx.compose.ui.semantics.Role
48import androidx.compose.ui.semantics.Role
@@ -56,6 +57,8 @@ import soy.iko.opencode.data.repo.CrashLogger
56import soy.iko.opencode.data.repo.ThemeMode
57import soy.iko.opencode.data.repo.ThemeMode
57import soy.iko.opencode.di.AppContainer
58import soy.iko.opencode.di.AppContainer
58import soy.iko.opencode.R
59import soy.iko.opencode.R
60+import soy.iko.opencode.ui.theme.LightPaletteSwatches
61+import soy.iko.opencode.ui.theme.DarkPaletteSwatches
59import soy.iko.opencode.util.runCatchingCancellable
62import soy.iko.opencode.util.runCatchingCancellable
60
63
61@OptIn(ExperimentalMaterial3Api::class)
64@OptIn(ExperimentalMaterial3Api::class)
@@ -220,6 +223,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
220
223
221@Composable
224@Composable
222private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
225private 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+ }
223 Row(
237 Row(
224 modifier = Modifier
238 modifier = Modifier
225 .fillMaxWidth()
239 .fillMaxWidth()
@@ -236,13 +250,38 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
236 ThemeMode.DARK -> stringResource(R.string.theme_dark)
250 ThemeMode.DARK -> stringResource(R.string.theme_dark)
237 },
251 },
238 style = MaterialTheme.typography.bodyLarge,
252 style = MaterialTheme.typography.bodyLarge,
253+ modifier = Modifier.weight(1f),
239 )
254 )
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+ }
240 }
266 }
241}
267}
242
268
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+
243/** Small circular count badge used to indicate pending crash reports. */
281/** Small circular count badge used to indicate pending crash reports. */
244@Composable
282@Composable
245private fun Badge(count: Int) {
283private fun Badge(count: Int) {
284+ val overflow = stringResource(R.string.crash_count_overflow)
246 Box(
285 Box(
247 modifier = Modifier
286 modifier = Modifier
248 .size(20.dp)
287 .size(20.dp)
@@ -251,7 +290,7 @@ private fun Badge(count: Int) {
251 contentAlignment = Alignment.Center,
290 contentAlignment = Alignment.Center,
252 ) {
291 ) {
253 Text(
292 Text(
254− if (count > 99) "99+" else count.toString(),
293+ if (count > 99) overflow else count.toString(),
255 style = MaterialTheme.typography.labelSmall,
294 style = MaterialTheme.typography.labelSmall,
256 color = MaterialTheme.colorScheme.onError,
295 color = MaterialTheme.colorScheme.onError,
257 textAlign = TextAlign.Center,
296 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
17import androidx.compose.ui.platform.LocalView
17import androidx.compose.ui.platform.LocalView
18import androidx.core.view.WindowCompat
18import androidx.core.view.WindowCompat
19
19
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+ */
20private val DarkColors = darkColorScheme(
31private val DarkColors = darkColorScheme(
21 primary = Color(0xFF7AA2F7),
32 primary = Color(0xFF7AA2F7),
33+ onPrimary = Color(0xFF112036),
34+ primaryContainer = Color(0xFF2A3A5C),
35+ onPrimaryContainer = Color(0xFFD6E2FF),
22 secondary = Color(0xFF9ECE6A),
36 secondary = Color(0xFF9ECE6A),
37+ onSecondary = Color(0xFF13260A),
38+ secondaryContainer = Color(0xFF2B3B1F),
39+ onSecondaryContainer = Color(0xFFD2F0A0),
23 tertiary = Color(0xFFBB9AF7),
40 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),
24 background = Color(0xFF1A1B26),
48 background = Color(0xFF1A1B26),
49+ onBackground = Color(0xFFC0CAF5),
25 surface = Color(0xFF1F2335),
50 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),
26)
60)
27
61
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+ */
28private val LightColors = lightColorScheme(
67private val LightColors = lightColorScheme(
29 primary = Color(0xFF34548A),
68 primary = Color(0xFF34548A),
69+ onPrimary = Color(0xFFFFFFFF),
70+ primaryContainer = Color(0xFFD6E2FF),
71+ onPrimaryContainer = Color(0xFF001A41),
30 secondary = Color(0xFF485E30),
72 secondary = Color(0xFF485E30),
73+ onSecondary = Color(0xFFFFFFFF),
74+ secondaryContainer = Color(0xFFC8F0A0),
75+ onSecondaryContainer = Color(0xFF0E2000),
31 tertiary = Color(0xFF5A4A78),
76 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),
32)
96)
33
97
34@Composable
98@Composable
@@ -53,10 +117,30 @@ fun OpencodeTheme(
53 }
117 }
54 MaterialTheme(
118 MaterialTheme(
55 colorScheme = colorScheme,
119 colorScheme = colorScheme,
120+ typography = OpencodeTypography,
121+ shapes = OpencodeShapes,
56 content = content,
122 content = content,
57 )
123 )
58}
124}
59
125
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+
60private tailrec fun Context.findActivity(): Activity? = when (this) {
144private tailrec fun Context.findActivity(): Activity? = when (this) {
61 is Activity -> this
145 is Activity -> this
62 is ContextWrapper -> baseContext.findActivity()
146 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 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3 <string name="app_name">opencode</string>
3 <string name="app_name">opencode</string>
4+ <!-- Non-numeric fallback so a PackageManager failure can\'t show a stale version number. -->
4− <string name="unknown_version">0.1.0</string>
5+ <string name="unknown_version">—</string>
5
6
6 <!-- Common -->
7 <!-- Common -->
7 <string name="back">Back</string>
8 <string name="back">Back</string>
@@ -37,6 +38,7 @@
37 <string name="reconnect">Reconnect</string>
38 <string name="reconnect">Reconnect</string>
38 <string name="not_connected">Not connected.</string>
39 <string name="not_connected">Not connected.</string>
39 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
40 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
41+ <string name="retry_now">Retry now</string>
40
42
41 <!-- Server list -->
43 <!-- Server list -->
42 <string name="servers_title">opencode servers</string>
44 <string name="servers_title">opencode servers</string>
@@ -50,6 +52,7 @@
50 <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
52 <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
51 <string name="server_auth_short"> • auth</string>
53 <string name="server_auth_short"> • auth</string>
52 <string name="server_auth_basic"> • Basic auth</string>
54 <string name="server_auth_basic"> • Basic auth</string>
55+ <string name="server_removed">Server removed</string>
53
56
54 <!-- Server edit -->
57 <!-- Server edit -->
55 <string name="label_optional">Label (optional)</string>
58 <string name="label_optional">Label (optional)</string>
@@ -77,6 +80,7 @@
77 <!-- Session list -->
80 <!-- Session list -->
78 <string name="new_session">New session</string>
81 <string name="new_session">New session</string>
79 <string name="unread">Unread</string>
82 <string name="unread">Unread</string>
83+ <string name="unread_count">%1$d unread</string>
80 <string name="no_sessions_yet">No sessions yet</string>
84 <string name="no_sessions_yet">No sessions yet</string>
81 <string name="no_sessions_hint">Start a conversation with opencode.</string>
85 <string name="no_sessions_hint">Start a conversation with opencode.</string>
82 <string name="search_sessions">Search sessions…</string>
86 <string name="search_sessions">Search sessions…</string>
@@ -97,6 +101,10 @@
97 <string name="message_placeholder">Message opencode…</string>
101 <string name="message_placeholder">Message opencode…</string>
98 <string name="empty_chat_title">Start a conversation</string>
102 <string name="empty_chat_title">Start a conversation</string>
99 <string name="empty_chat_hint">Type a message below to begin.</string>
103 <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>
100 <string name="load_messages_failed">Couldn\'t load messages.</string>
108 <string name="load_messages_failed">Couldn\'t load messages.</string>
101 <string name="empty_detail_pane">Select a session to view the conversation.</string>
109 <string name="empty_detail_pane">Select a session to view the conversation.</string>
102 <string name="working">working…</string>
110 <string name="working">working…</string>
@@ -176,6 +184,8 @@
176 <string name="folder">Folder: %1$s</string>
184 <string name="folder">Folder: %1$s</string>
177 <string name="file_label">File: %1$s</string>
185 <string name="file_label">File: %1$s</string>
178 <string name="file">File</string>
186 <string name="file">File</string>
187+ <string name="show_diff">Show diff</string>
188+ <string name="show_raw">Show raw</string>
179
189
180 <!-- Message metadata -->
190 <!-- Message metadata -->
181 <string name="image">image</string>
191 <string name="image">image</string>
@@ -195,7 +205,7 @@
195 <string name="diagnostics">Diagnostics</string>
205 <string name="diagnostics">Diagnostics</string>
196 <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
206 <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
197 <string name="crash_reports">Crash reports</string>
207 <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>
199 <string name="clear_all">Clear all</string>
209 <string name="clear_all">Clear all</string>
200 <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
210 <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
201 <string name="crash_report">Crash report</string>
211 <string name="crash_report">Crash report</string>
@@ -204,6 +214,8 @@
204 <string name="delete_report_text">This report will be permanently deleted.</string>
214 <string name="delete_report_text">This report will be permanently deleted.</string>
205 <string name="report_load_failed">Failed to load crash report.</string>
215 <string name="report_load_failed">Failed to load crash report.</string>
206 <string name="crash_count_format">%1$d</string>
216 <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>
207
219
208 <!-- Notifications -->
220 <!-- Notifications -->
209 <string name="notif_channel_status">Agent activity</string>
221 <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(
303class FakeAppContainer : AppContainer() {
303class FakeAppContainer : AppContainer() {
304 val fakeDraftStore = FakeDraftStore()
304 val fakeDraftStore = FakeDraftStore()
305 val fakeProfileStore = FakeProfileStore()
305 val fakeProfileStore = FakeProfileStore()
306− val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
306+ val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
307 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
307 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
308
308
309 override val draftStore: DraftStore = fakeDraftStore
309 override val draftStore: DraftStore = fakeDraftStore
310 override val profileStore: ProfileStore = fakeProfileStore
310 override val profileStore: ProfileStore = fakeProfileStore
311 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
311 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()
313
313
314 var connectResult: OpencodeConnection? = null
314 var connectResult: OpencodeConnection? = null
315 /** When non-null, [connect] throws this instead of returning a connection. */
315 /** When non-null, [connect] throws this instead of returning a connection. */