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
3716ac2iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+14−1
@@ -69,6 +69,9 @@ class OpencodeCore(
69 // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
69 // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70 // can cancel it before its response handler touches stale message state.
70 // can cancel it before its response handler touches stale message state.
71 private var sendMessageJob: Job? = null
71 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
72 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
75 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
73 private val removedMessageIds = mutableSetOf<String>()
76 private val removedMessageIds = mutableSetOf<String>()
74
77
@@ -98,6 +101,7 @@ class OpencodeCore(
98 }
101 }
99
102
100 Event.LoadSessions -> {
103 Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
101 loading = true; emit()
105 loading = true; emit()
102 loadSessionsJob?.cancel()
106 loadSessionsJob?.cancel()
103 loadSessionsJob = scope.launch { loadSessions() }
107 loadSessionsJob = scope.launch { loadSessions() }
@@ -117,18 +121,22 @@ class OpencodeCore(
117 generating = false
121 generating = false
118 error = null
122 error = null
119 emit()
123 emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
120 sendMessageJob?.cancel()
126 sendMessageJob?.cancel()
121 loadMessagesJob?.cancel()
127 loadMessagesJob?.cancel()
122 loadMessagesJob = scope.launch { loadMessages(event.id) }
128 loadMessagesJob = scope.launch { loadMessages(event.id) }
123 }
129 }
124
130
125 Event.CreateSession -> {
131 Event.CreateSession -> {
132+ if (!connected) { error = "Not connected"; emit(); return }
126 loading = true; error = null; emit()
133 loading = true; error = null; emit()
127 createSessionJob?.cancel()
134 createSessionJob?.cancel()
128 createSessionJob = scope.launch { createSession() }
135 createSessionJob = scope.launch { createSession() }
129 }
136 }
130
137
131 is Event.DeleteSession -> {
138 is Event.DeleteSession -> {
139+ if (!connected) { error = "Not connected"; emit(); return }
132 val id = event.id
140 val id = event.id
133 // Delete on the server first; only drop it from the UI once that
141 // Delete on the server first; only drop it from the UI once that
134 // succeeds, so a failed request leaves the session recoverable.
142 // succeeds, so a failed request leaves the session recoverable.
@@ -167,7 +175,10 @@ class OpencodeCore(
167 messages.forEach { it.streaming = false }
175 messages.forEach { it.streaming = false }
168 emit()
176 emit()
169 val id = currentSessionId ?: return
177 val id = currentSessionId ?: return
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()
170− scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
181+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
171 }
182 }
172
183
173 Event.NavigateToSessions -> {
184 Event.NavigateToSessions -> {
@@ -180,6 +191,7 @@ class OpencodeCore(
180 loading = true
191 loading = true
181 emit()
192 emit()
182 connectJob?.cancel()
193 connectJob?.cancel()
194+ abortJob?.cancel()
183 createSessionJob?.cancel()
195 createSessionJob?.cancel()
184 deleteSessionJob?.cancel()
196 deleteSessionJob?.cancel()
185 sendMessageJob?.cancel()
197 sendMessageJob?.cancel()
@@ -202,6 +214,7 @@ class OpencodeCore(
202 // SSE connection can't flash the reconnecting banner on the next connect.
214 // SSE connection can't flash the reconnecting banner on the next connect.
203 sseConnected = true
215 sseConnected = true
204 connectJob?.cancel()
216 connectJob?.cancel()
217+ abortJob?.cancel()
205 createSessionJob?.cancel()
218 createSessionJob?.cancel()
206 deleteSessionJob?.cancel()
219 deleteSessionJob?.cancel()
207 sendMessageJob?.cancel()
220 sendMessageJob?.cancel()
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+50−1
@@ -4,6 +4,7 @@ import android.content.ClipData
4import android.content.ClipboardManager
4import android.content.ClipboardManager
5import android.content.Context
5import android.content.Context
6import android.content.Intent
6import android.content.Intent
7+import android.net.Uri
7import android.widget.Toast
8import android.widget.Toast
8import androidx.compose.foundation.ExperimentalFoundationApi
9import androidx.compose.foundation.ExperimentalFoundationApi
9import androidx.compose.foundation.background
10import androidx.compose.foundation.background
@@ -41,10 +42,15 @@ import androidx.compose.ui.Modifier
41import androidx.compose.ui.draw.clip
42import androidx.compose.ui.draw.clip
42import androidx.compose.ui.graphics.Color
43import androidx.compose.ui.graphics.Color
43import androidx.compose.ui.platform.LocalContext
44import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
44import androidx.compose.ui.text.SpanStyle
47import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
45import androidx.compose.ui.text.buildAnnotatedString
49import androidx.compose.ui.text.buildAnnotatedString
46import androidx.compose.ui.text.font.FontStyle
50import androidx.compose.ui.text.font.FontStyle
51+import androidx.compose.ui.text.withLink
47import androidx.compose.ui.text.withStyle
52import androidx.compose.ui.text.withStyle
53+import androidx.compose.ui.text.style.TextDecoration
48import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.text.style.TextOverflow
49import androidx.compose.ui.unit.dp
55import androidx.compose.ui.unit.dp
50import androidx.compose.ui.unit.sp
56import androidx.compose.ui.unit.sp
@@ -124,8 +130,12 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
124
130
125 SelectionContainer {
131 SelectionContainer {
126 if (message.text.isNotEmpty()) {
132 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+ }
127 Text(
137 Text(
128− text = message.text + if (message.streaming) " ▌" else "",
138+ text = annotatedText,
129 color = textColor,
139 color = textColor,
130 // Give assistant replies (often long, code-heavy) extra line
140 // Give assistant replies (often long, code-heavy) extra line
131 // spacing for readability; user bubbles keep the default.
141 // spacing for readability; user bubbles keep the default.
@@ -161,6 +171,45 @@ private fun copyToClipboard(context: Context, text: String) {
161 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
171 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
162}
172}
163
173
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+
164private fun shareText(context: Context, text: String) {
213private fun shareText(context: Context, text: String) {
165 val intent = Intent(Intent.ACTION_SEND).apply {
214 val intent = Intent(Intent.ACTION_SEND).apply {
166 type = "text/plain"
215 type = "text/plain"
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+30−3
@@ -1,5 +1,9 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
3import androidx.compose.animation.AnimatedVisibility
7import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.core.RepeatMode
8import androidx.compose.animation.core.RepeatMode
5import androidx.compose.animation.core.animateFloat
9import androidx.compose.animation.core.animateFloat
@@ -10,8 +14,10 @@ import androidx.compose.animation.fadeIn
10import androidx.compose.animation.fadeOut
14import androidx.compose.animation.fadeOut
11import androidx.compose.animation.scaleIn
15import androidx.compose.animation.scaleIn
12import androidx.compose.animation.scaleOut
16import androidx.compose.animation.scaleOut
17+import androidx.compose.foundation.ExperimentalFoundationApi
13import androidx.compose.foundation.background
18import androidx.compose.foundation.background
14import androidx.compose.foundation.clickable
19import androidx.compose.foundation.clickable
20+import androidx.compose.foundation.combinedClickable
15import androidx.compose.foundation.layout.Arrangement
21import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
22import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.BoxScope
23import androidx.compose.foundation.layout.BoxScope
@@ -65,6 +71,7 @@ import androidx.compose.ui.Alignment
65import androidx.compose.ui.Modifier
71import androidx.compose.ui.Modifier
66import androidx.compose.ui.draw.clip
72import androidx.compose.ui.draw.clip
67import androidx.compose.ui.hapticfeedback.HapticFeedbackType
73import androidx.compose.ui.hapticfeedback.HapticFeedbackType
74+import androidx.compose.ui.platform.LocalContext
68import androidx.compose.ui.platform.LocalDensity
75import androidx.compose.ui.platform.LocalDensity
69import androidx.compose.ui.platform.LocalHapticFeedback
76import androidx.compose.ui.platform.LocalHapticFeedback
70import androidx.compose.ui.text.font.FontStyle
77import androidx.compose.ui.text.font.FontStyle
@@ -77,11 +84,12 @@ import soy.iko.opencode.core.Event
77import soy.iko.opencode.core.UiState
84import soy.iko.opencode.core.UiState
78import soy.iko.opencode.ui.components.MessageBubble
85import soy.iko.opencode.ui.components.MessageBubble
79
86
80−@OptIn(ExperimentalMaterial3Api::class)
87+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
81@Composable
88@Composable
82fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
89fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
83 val listState = rememberLazyListState()
90 val listState = rememberLazyListState()
84 val coroutineScope = rememberCoroutineScope()
91 val coroutineScope = rememberCoroutineScope()
92+ val context = LocalContext.current
85
93
86 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
94 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
87 // sit over the top of the IME. Reading the ime inset bottom during composition
95 // 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) {
134 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
142 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
135 maxLines = 1,
143 maxLines = 1,
136 overflow = TextOverflow.Ellipsis,
144 overflow = TextOverflow.Ellipsis,
145+ modifier = Modifier.combinedClickable(
137− modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
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+ ),
138 )
154 )
139 },
155 },
140 navigationIcon = {
156 navigationIcon = {
@@ -219,7 +235,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
219 onClick = {
235 onClick = {
220 coroutineScope.launch {
236 coroutineScope.launch {
221 if (state.messages.isNotEmpty()) {
237 if (state.messages.isNotEmpty()) {
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
222− listState.animateScrollToItem(state.messages.size - 1)
243+ state.messages.size - 1
244+ listState.animateScrollToItem(target)
223 }
245 }
224 }
246 }
225 },
247 },
@@ -263,6 +285,11 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
263 }
285 }
264}
286}
265
287
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+
266private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
293private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
267
294
268/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
295/** 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 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
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
3import androidx.compose.foundation.background
8import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
9import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Column
@@ -51,6 +57,7 @@ import androidx.compose.runtime.setValue
51import androidx.compose.ui.Alignment
57import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
58import androidx.compose.ui.Modifier
53import androidx.compose.ui.hapticfeedback.HapticFeedbackType
59import androidx.compose.ui.hapticfeedback.HapticFeedbackType
60+import androidx.compose.ui.platform.LocalContext
54import androidx.compose.ui.platform.LocalHapticFeedback
61import androidx.compose.ui.platform.LocalHapticFeedback
55import androidx.compose.ui.text.style.TextOverflow
62import androidx.compose.ui.text.style.TextOverflow
56import androidx.compose.ui.unit.dp
63import androidx.compose.ui.unit.dp
@@ -200,13 +207,21 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
200 }
207 }
201}
208}
202
209
210+@OptIn(ExperimentalFoundationApi::class)
203@Composable
211@Composable
204private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
212private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
213+ val context = LocalContext.current
205 Row(
214 Row(
206 modifier = Modifier
215 modifier = Modifier
207 .fillMaxWidth()
216 .fillMaxWidth()
208 .background(MaterialTheme.colorScheme.surface)
217 .background(MaterialTheme.colorScheme.surface)
218+ .combinedClickable(
209− .clickable(onClick = onOpen)
219+ onClick = onOpen,
220+ onLongClick = {
221+ copyToClipboard(context, session.id)
222+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
223+ },
224+ )
210 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
225 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
211 verticalAlignment = Alignment.CenterVertically,
226 verticalAlignment = Alignment.CenterVertically,
212 ) {
227 ) {
@@ -299,6 +314,11 @@ private fun ReconnectingBanner() {
299 }
314 }
300}
315}
301
316
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+
302/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
322/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
303private fun shortSessionId(id: String): String =
323private fun shortSessionId(id: String): String =
304 if (id.length > 12) id.take(8) + "…" else id
324 if (id.length > 12) id.take(8) + "…" else id