git.iko.soy/opencode-android
on master help sign in
UX/UI: back-press confirmation when generating, auto-scroll to new sessions, code block rendering, character counter for long drafts

Bug fixes: rebuild linked text on context change, reset connected on 401 re-probe, cancel deleteSessionJob on SelectSession

commit29df00367942cc9383f24a26f2e35db2bd952a90 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 03:36 (3 months ago) parent3716ac2
5 files changed+92−7
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+31−1 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+30−6 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+15 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+11
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5
@@ -124,6 +124,7 @@ class OpencodeCore(
124124 connectJob?.cancel()
125125 abortJob?.cancel()
126126 sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
127128 loadMessagesJob?.cancel()
128129 loadMessagesJob = scope.launch { loadMessages(event.id) }
129130 }
@@ -291,6 +292,10 @@ class OpencodeCore(
291292 when {
292293 resp.code == 401 -> {
293294 loading = false
295+ // A prior connection may have succeeded (connected = true); a
296+ // re-probe returning 401 (e.g. expired credentials) means we're
297+ // no longer authenticated, so drop the connected flag.
298+ connected = false
294299 // Always reveal the auth fields so the user can see/edit
295300 // their credentials — even when the ones supplied were wrong.
296301 authRequired = true
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+31−1
@@ -7,13 +7,19 @@ import androidx.compose.animation.slideOutHorizontally
77import androidx.compose.animation.togetherWith
88import androidx.compose.foundation.layout.fillMaxSize
99import androidx.compose.foundation.layout.padding
10+import androidx.compose.material3.AlertDialog
1011import androidx.compose.material3.Scaffold
1112import androidx.compose.material3.SnackbarDuration
1213import androidx.compose.material3.SnackbarHost
1314import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
1417import androidx.compose.runtime.Composable
1518import androidx.compose.runtime.LaunchedEffect
19+import androidx.compose.runtime.getValue
20+import androidx.compose.runtime.mutableStateOf
1621import androidx.compose.runtime.remember
22+import androidx.compose.runtime.setValue
1723import androidx.compose.ui.Modifier
1824import soy.iko.opencode.core.Event
1925import soy.iko.opencode.core.Screen
@@ -25,6 +31,7 @@ import soy.iko.opencode.ui.screens.SessionsScreen
2531@Composable
2632fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
2733 val snackbar = remember { SnackbarHostState() }
34+ var showStopConfirm by remember { mutableStateOf(false) }
2835
2936 LaunchedEffect(state.error) {
3037 state.error?.let {
@@ -40,12 +47,35 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
4047
4148 BackHandler(enabled = state.screen != Screen.Connect) {
4249 when (state.screen) {
43− Screen.Chat -> dispatch(Event.NavigateToSessions)
50+ // Leaving mid-generation would silently drop the reply, so confirm first.
51+ Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
4452 Screen.Sessions -> dispatch(Event.NavigateToConnect)
4553 Screen.Connect -> Unit
4654 }
4755 }
4856
57+ if (showStopConfirm) {
58+ AlertDialog(
59+ onDismissRequest = { showStopConfirm = false },
60+ title = { Text("Stop generation?") },
61+ text = { Text("Leaving will stop the current generation.") },
62+ confirmButton = {
63+ TextButton(onClick = {
64+ showStopConfirm = false
65+ dispatch(Event.CancelGeneration)
66+ dispatch(Event.NavigateToSessions)
67+ }) {
68+ Text("Stop and leave")
69+ }
70+ },
71+ dismissButton = {
72+ TextButton(onClick = { showStopConfirm = false }) {
73+ Text("Stay")
74+ }
75+ },
76+ )
77+ }
78+
4979 Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
5080 AnimatedContent(
5181 targetState = state.screen,
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+30−6
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.LinkAnnotation
4747import androidx.compose.ui.text.SpanStyle
4848import androidx.compose.ui.text.TextLinkStyles
4949import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
5051import androidx.compose.ui.text.font.FontStyle
5152import androidx.compose.ui.text.withLink
5253import androidx.compose.ui.text.withStyle
@@ -131,8 +132,9 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
131132 SelectionContainer {
132133 if (message.text.isNotEmpty()) {
133134 val linkColor = MaterialTheme.colorScheme.primary
134− val annotatedText = remember(message.text, message.streaming, linkColor) {
135− buildLinkedText(message.text, message.streaming, linkColor, context)
135+ val codeBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
136+ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
137+ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
136138 }
137139 Text(
138140 text = annotatedText,
@@ -174,16 +176,39 @@ private fun copyToClipboard(context: Context, text: String) {
174176private val urlRegex = Regex("""https?://[^\s]+""")
175177
176178/**
177− * Render [text] as an AnnotatedString where each http(s) URL becomes a clickable link
178− * that opens in the browser. Falls back to plain text when no URLs are present. The
179− * streaming cursor (" ▌") is appended outside any link annotation.
179+ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
180+ * prose and code segments: code segments are rendered monospace on a tinted
181+ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
182+ * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
180183 */
181184private fun buildLinkedText(
182185 text: String,
183186 streaming: Boolean,
184187 linkColor: Color,
188+ codeBackground: Color,
185189 context: Context,
186190): AnnotatedString = buildAnnotatedString {
191+ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
192+ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
193+ val segments = text.split("```")
194+ segments.forEachIndexed { index, segment ->
195+ if (index % 2 == 1) {
196+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
197+ append(segment)
198+ }
199+ } else {
200+ appendLinkedText(segment, linkColor, context)
201+ }
202+ }
203+ if (streaming) append(" ▌")
204+}
205+
206+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
207+private fun AnnotatedString.Builder.appendLinkedText(
208+ text: String,
209+ linkColor: Color,
210+ context: Context,
211+) {
187212 val matches = urlRegex.findAll(text).toList()
188213 var cursor = 0
189214 for (match in matches) {
@@ -207,7 +232,6 @@ private fun buildLinkedText(
207232 cursor = match.range.last + 1
208233 }
209234 append(text.substring(cursor))
210− if (streaming) append(" ▌")
211235}
212236
213237private fun shareText(context: Context, text: String) {
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+15
@@ -455,6 +455,21 @@ private fun ChatInputBar(
455455 enabled = !loading,
456456 placeholder = { Text("Message opencode…") },
457457 maxLines = 5,
458+ // Surface a running count once the draft gets long, so the user has a
459+ // sense of scale before sending a wall of text.
460+ supportingText = if (draft.length > 500) {
461+ {
462+ Text(
463+ text = "${draft.length} characters",
464+ style = MaterialTheme.typography.labelSmall,
465+ color = MaterialTheme.colorScheme.onSurfaceVariant,
466+ textAlign = TextAlign.End,
467+ modifier = Modifier.fillMaxWidth(),
468+ )
469+ }
470+ } else {
471+ null
472+ },
458473 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
459474 keyboardActions = KeyboardActions(onSend = {
460475 if (!generating && !loading && draft.isNotBlank()) onSend()
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+11
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
2121import androidx.compose.foundation.layout.size
2222import androidx.compose.foundation.lazy.LazyColumn
2323import androidx.compose.foundation.lazy.items
24+import androidx.compose.foundation.lazy.rememberLazyListState
2425import androidx.compose.material.icons.Icons
2526import androidx.compose.material.icons.automirrored.filled.ArrowBack
2627import androidx.compose.material.icons.filled.Add
@@ -72,6 +73,15 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
7273 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
7374 var isPullRefreshing by remember { mutableStateOf(false) }
7475 var searchQuery by remember { mutableStateOf("") }
76+ val sessionsListState = rememberLazyListState()
77+
78+ // Bring a newly created session (which lands at the top of the list) into view.
79+ // Only scroll when the list actually grows, not on every reload of the same sessions.
80+ var prevSize by remember { mutableStateOf(state.sessions.size) }
81+ LaunchedEffect(state.sessions.size) {
82+ if (state.sessions.size > prevSize) sessionsListState.animateScrollToItem(0)
83+ prevSize = state.sessions.size
84+ }
7585
7686 // The pull-to-refresh spinner should only reflect a user-initiated pull, not
7787 // every loading operation. Clear it once the underlying load completes.
@@ -169,6 +179,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
169179 state = rememberPullToRefreshState(),
170180 ) {
171181 LazyColumn(
182+ state = sessionsListState,
172183 modifier = Modifier.fillMaxSize(),
173184 contentPadding = PaddingValues(bottom = 88.dp),
174185 ) {