git.iko.soy/opencode-android
on master help sign in
UX/UI: copy session ID on long-press, auto-link URLs in messages, scroll-to-bottom accounts for typing indicator

Bug fixes: cancel connectJob on SelectSession, track abort job, guard create/delete/load against disconnected state

commit3716ac22aec4df414b4f521892f0ebc8523d6358 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 03:26 (3 months ago) parent5244821
4 files changed+115−6
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+14−1 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+50−1 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+30−3 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+21−1
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+14−1
@@ -69,6 +69,9 @@ class OpencodeCore(
6969 // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
7070 // can cancel it before its response handler touches stale message state.
7171 private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
7275 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
7376 private val removedMessageIds = mutableSetOf<String>()
7477
@@ -98,6 +101,7 @@ class OpencodeCore(
98101 }
99102
100103 Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
101105 loading = true; emit()
102106 loadSessionsJob?.cancel()
103107 loadSessionsJob = scope.launch { loadSessions() }
@@ -117,18 +121,22 @@ class OpencodeCore(
117121 generating = false
118122 error = null
119123 emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
120126 sendMessageJob?.cancel()
121127 loadMessagesJob?.cancel()
122128 loadMessagesJob = scope.launch { loadMessages(event.id) }
123129 }
124130
125131 Event.CreateSession -> {
132+ if (!connected) { error = "Not connected"; emit(); return }
126133 loading = true; error = null; emit()
127134 createSessionJob?.cancel()
128135 createSessionJob = scope.launch { createSession() }
129136 }
130137
131138 is Event.DeleteSession -> {
139+ if (!connected) { error = "Not connected"; emit(); return }
132140 val id = event.id
133141 // Delete on the server first; only drop it from the UI once that
134142 // succeeds, so a failed request leaves the session recoverable.
@@ -167,7 +175,10 @@ class OpencodeCore(
167175 messages.forEach { it.streaming = false }
168176 emit()
169177 val id = currentSessionId ?: return
170− scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
178+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
179+ // another so we don't stack duplicate abort requests.
180+ abortJob?.cancel()
181+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
171182 }
172183
173184 Event.NavigateToSessions -> {
@@ -180,6 +191,7 @@ class OpencodeCore(
180191 loading = true
181192 emit()
182193 connectJob?.cancel()
194+ abortJob?.cancel()
183195 createSessionJob?.cancel()
184196 deleteSessionJob?.cancel()
185197 sendMessageJob?.cancel()
@@ -202,6 +214,7 @@ class OpencodeCore(
202214 // SSE connection can't flash the reconnecting banner on the next connect.
203215 sseConnected = true
204216 connectJob?.cancel()
217+ abortJob?.cancel()
205218 createSessionJob?.cancel()
206219 deleteSessionJob?.cancel()
207220 sendMessageJob?.cancel()
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+50−1
@@ -4,6 +4,7 @@ import android.content.ClipData
44import android.content.ClipboardManager
55import android.content.Context
66import android.content.Intent
7+import android.net.Uri
78import android.widget.Toast
89import androidx.compose.foundation.ExperimentalFoundationApi
910import androidx.compose.foundation.background
@@ -41,10 +42,15 @@ import androidx.compose.ui.Modifier
4142import androidx.compose.ui.draw.clip
4243import androidx.compose.ui.graphics.Color
4344import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
4447import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
4549import androidx.compose.ui.text.buildAnnotatedString
4650import androidx.compose.ui.text.font.FontStyle
51+import androidx.compose.ui.text.withLink
4752import androidx.compose.ui.text.withStyle
53+import androidx.compose.ui.text.style.TextDecoration
4854import androidx.compose.ui.text.style.TextOverflow
4955import androidx.compose.ui.unit.dp
5056import androidx.compose.ui.unit.sp
@@ -124,8 +130,12 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
124130
125131 SelectionContainer {
126132 if (message.text.isNotEmpty()) {
133+ val linkColor = MaterialTheme.colorScheme.primary
134+ val annotatedText = remember(message.text, message.streaming, linkColor) {
135+ buildLinkedText(message.text, message.streaming, linkColor, context)
136+ }
127137 Text(
128− text = message.text + if (message.streaming) " ▌" else "",
138+ text = annotatedText,
129139 color = textColor,
130140 // Give assistant replies (often long, code-heavy) extra line
131141 // spacing for readability; user bubbles keep the default.
@@ -161,6 +171,45 @@ private fun copyToClipboard(context: Context, text: String) {
161171 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
162172}
163173
174+private val urlRegex = Regex("""https?://[^\s]+""")
175+
176+/**
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.
180+ */
181+private fun buildLinkedText(
182+ text: String,
183+ streaming: Boolean,
184+ linkColor: Color,
185+ context: Context,
186+): AnnotatedString = buildAnnotatedString {
187+ val matches = urlRegex.findAll(text).toList()
188+ var cursor = 0
189+ for (match in matches) {
190+ append(text.substring(cursor, match.range.first))
191+ val url = match.value
192+ withLink(
193+ LinkAnnotation.Clickable(
194+ tag = url,
195+ styles = TextLinkStyles(
196+ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
197+ ),
198+ linkInteractionListener = {
199+ runCatching {
200+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
201+ }
202+ },
203+ ),
204+ ) {
205+ append(url)
206+ }
207+ cursor = match.range.last + 1
208+ }
209+ append(text.substring(cursor))
210+ if (streaming) append(" ▌")
211+}
212+
164213private fun shareText(context: Context, text: String) {
165214 val intent = Intent(Intent.ACTION_SEND).apply {
166215 type = "text/plain"
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+30−3
@@ -1,5 +1,9 @@
11package soy.iko.opencode.ui.screens
22
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
37import androidx.compose.animation.AnimatedVisibility
48import androidx.compose.animation.core.RepeatMode
59import androidx.compose.animation.core.animateFloat
@@ -10,8 +14,10 @@ import androidx.compose.animation.fadeIn
1014import androidx.compose.animation.fadeOut
1115import androidx.compose.animation.scaleIn
1216import androidx.compose.animation.scaleOut
17+import androidx.compose.foundation.ExperimentalFoundationApi
1318import androidx.compose.foundation.background
1419import androidx.compose.foundation.clickable
20+import androidx.compose.foundation.combinedClickable
1521import androidx.compose.foundation.layout.Arrangement
1622import androidx.compose.foundation.layout.Box
1723import androidx.compose.foundation.layout.BoxScope
@@ -65,6 +71,7 @@ import androidx.compose.ui.Alignment
6571import androidx.compose.ui.Modifier
6672import androidx.compose.ui.draw.clip
6773import androidx.compose.ui.hapticfeedback.HapticFeedbackType
74+import androidx.compose.ui.platform.LocalContext
6875import androidx.compose.ui.platform.LocalDensity
6976import androidx.compose.ui.platform.LocalHapticFeedback
7077import androidx.compose.ui.text.font.FontStyle
@@ -77,11 +84,12 @@ import soy.iko.opencode.core.Event
7784import soy.iko.opencode.core.UiState
7885import soy.iko.opencode.ui.components.MessageBubble
7986
80−@OptIn(ExperimentalMaterial3Api::class)
87+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
8188@Composable
8289fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
8390 val listState = rememberLazyListState()
8491 val coroutineScope = rememberCoroutineScope()
92+ val context = LocalContext.current
8593
8694 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
8795 // sit over the top of the IME. Reading the ime inset bottom during composition
@@ -134,7 +142,15 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
134142 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
135143 maxLines = 1,
136144 overflow = TextOverflow.Ellipsis,
137− modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
145+ modifier = Modifier.combinedClickable(
146+ onClick = { dispatch(Event.NavigateToSessions) },
147+ onLongClick = {
148+ state.currentSessionId?.let { id ->
149+ copyToClipboard(context, id)
150+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
151+ }
152+ },
153+ ),
138154 )
139155 },
140156 navigationIcon = {
@@ -219,7 +235,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
219235 onClick = {
220236 coroutineScope.launch {
221237 if (state.messages.isNotEmpty()) {
222− listState.animateScrollToItem(state.messages.size - 1)
238+ // When the typing indicator is showing it occupies the item
239+ // just past the last message, so scroll one further to reach it.
240+ val target = if (state.generating && state.messages.none { it.streaming })
241+ state.messages.size
242+ else
243+ state.messages.size - 1
244+ listState.animateScrollToItem(target)
223245 }
224246 }
225247 },
@@ -263,6 +285,11 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
263285 }
264286}
265287
288+private fun copyToClipboard(context: Context, text: String) {
289+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
290+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
291+}
292+
266293private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
267294
268295/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+21−1
@@ -1,7 +1,13 @@
11package soy.iko.opencode.ui.screens
22
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.ExperimentalFoundationApi
38import androidx.compose.foundation.background
49import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
511import androidx.compose.foundation.layout.Arrangement
612import androidx.compose.foundation.layout.Box
713import androidx.compose.foundation.layout.Column
@@ -51,6 +57,7 @@ import androidx.compose.runtime.setValue
5157import androidx.compose.ui.Alignment
5258import androidx.compose.ui.Modifier
5359import androidx.compose.ui.hapticfeedback.HapticFeedbackType
60+import androidx.compose.ui.platform.LocalContext
5461import androidx.compose.ui.platform.LocalHapticFeedback
5562import androidx.compose.ui.text.style.TextOverflow
5663import androidx.compose.ui.unit.dp
@@ -200,13 +207,21 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
200207 }
201208}
202209
210+@OptIn(ExperimentalFoundationApi::class)
203211@Composable
204212private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
213+ val context = LocalContext.current
205214 Row(
206215 modifier = Modifier
207216 .fillMaxWidth()
208217 .background(MaterialTheme.colorScheme.surface)
209− .clickable(onClick = onOpen)
218+ .combinedClickable(
219+ onClick = onOpen,
220+ onLongClick = {
221+ copyToClipboard(context, session.id)
222+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
223+ },
224+ )
210225 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
211226 verticalAlignment = Alignment.CenterVertically,
212227 ) {
@@ -299,6 +314,11 @@ private fun ReconnectingBanner() {
299314 }
300315}
301316
317+private fun copyToClipboard(context: Context, text: String) {
318+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
319+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
320+}
321+
302322/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
303323private fun shortSessionId(id: String): String =
304324 if (id.length > 12) id.take(8) + "…" else id