git.iko.soy/opencode-android
on master help sign in
UX/UI: message and session count in TopAppBars, long-press share menu

Bug fixes: track delete/send/connect jobs, cancel all jobs on navigation, reset connected on probe failure

commit5244821da60d6dbfc8bf2193a480b2b9c3d69c30 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 03:13 (3 months ago) parentf4a0a6f
4 files changed+53−6
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+19−2 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+31−2 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+1−1
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+19−2
@@ -63,6 +63,12 @@ class OpencodeCore(
6363 // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
6464 // otherwise launch two concurrent health probes; cancel the prior one first.
6565 private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
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.
71+ private var sendMessageJob: Job? = null
6672 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
6773 private val removedMessageIds = mutableSetOf<String>()
6874
@@ -111,6 +117,7 @@ class OpencodeCore(
111117 generating = false
112118 error = null
113119 emit()
120+ sendMessageJob?.cancel()
114121 loadMessagesJob?.cancel()
115122 loadMessagesJob = scope.launch { loadMessages(event.id) }
116123 }
@@ -126,7 +133,8 @@ class OpencodeCore(
126133 // Delete on the server first; only drop it from the UI once that
127134 // succeeds, so a failed request leaves the session recoverable.
128135 loading = true; error = null; emit()
129− scope.launch {
136+ deleteSessionJob?.cancel()
137+ deleteSessionJob = scope.launch {
130138 http.delete("$serverUrl/session/$id", authHeader()).fold(
131139 onSuccess = { resp ->
132140 if (resp.code in 200..299) {
@@ -171,7 +179,10 @@ class OpencodeCore(
171179 generating = false
172180 loading = true
173181 emit()
182+ connectJob?.cancel()
174183 createSessionJob?.cancel()
184+ deleteSessionJob?.cancel()
185+ sendMessageJob?.cancel()
175186 loadMessagesJob?.cancel()
176187 loadSessionsJob?.cancel()
177188 loadSessionsJob = scope.launch { loadSessions() }
@@ -190,7 +201,10 @@ class OpencodeCore(
190201 // Reset to the default so a late-firing close callback from the old
191202 // SSE connection can't flash the reconnecting banner on the next connect.
192203 sseConnected = true
204+ connectJob?.cancel()
193205 createSessionJob?.cancel()
206+ deleteSessionJob?.cancel()
207+ sendMessageJob?.cancel()
194208 loadMessagesJob?.cancel()
195209 closeSse()
196210 emit()
@@ -230,7 +244,8 @@ class OpencodeCore(
230244 emit()
231245
232246 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
233− scope.launch {
247+ sendMessageJob?.cancel()
248+ sendMessageJob = scope.launch {
234249 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
235250 result.fold(
236251 onSuccess = { resp ->
@@ -284,6 +299,7 @@ class OpencodeCore(
284299 }
285300 else -> {
286301 loading = false
302+ connected = false
287303 error = "Server returned status ${resp.code}"
288304 emit()
289305 }
@@ -291,6 +307,7 @@ class OpencodeCore(
291307 },
292308 onFailure = { e ->
293309 loading = false
310+ connected = false
294311 error = "Connection failed: ${e.message}"
295312 emit()
296313 },
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+31−2
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.components
33import android.content.ClipData
44import android.content.ClipboardManager
55import android.content.Context
6+import android.content.Intent
67import android.widget.Toast
78import androidx.compose.foundation.ExperimentalFoundationApi
89import androidx.compose.foundation.background
@@ -25,6 +26,8 @@ import androidx.compose.material.icons.filled.ExpandLess
2526import androidx.compose.material.icons.filled.ExpandMore
2627import androidx.compose.material.icons.filled.Schedule
2728import androidx.compose.material.icons.filled.SmartToy
29+import androidx.compose.material3.DropdownMenu
30+import androidx.compose.material3.DropdownMenuItem
2831import androidx.compose.material3.Icon
2932import androidx.compose.material3.MaterialTheme
3033import androidx.compose.material3.Text
@@ -67,6 +70,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
6770 )
6871
6972 val context = LocalContext.current
73+ var showMenu by remember { mutableStateOf(false) }
7074
7175 Row(
7276 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
@@ -90,13 +94,30 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
9094 onClick = {},
9195 onLongClick = {
9296 if (message.text.isNotEmpty()) {
93− copyToClipboard(context, message.text)
94− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
97+ showMenu = true
9598 }
9699 },
97100 )
98101 .padding(horizontal = 14.dp, vertical = 10.dp),
99102 ) {
103+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
104+ DropdownMenuItem(
105+ text = { Text("Copy") },
106+ onClick = {
107+ copyToClipboard(context, message.text)
108+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
109+ showMenu = false
110+ },
111+ )
112+ DropdownMenuItem(
113+ text = { Text("Share") },
114+ onClick = {
115+ shareText(context, message.text)
116+ showMenu = false
117+ },
118+ )
119+ }
120+
100121 message.reasoning?.let { ReasoningBlock(it, context) }
101122
102123 message.tools.forEach { ToolRow(it) }
@@ -140,6 +161,14 @@ private fun copyToClipboard(context: Context, text: String) {
140161 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
141162}
142163
164+private fun shareText(context: Context, text: String) {
165+ val intent = Intent(Intent.ACTION_SEND).apply {
166+ type = "text/plain"
167+ putExtra(Intent.EXTRA_TEXT, text)
168+ }
169+ context.startActivity(Intent.createChooser(intent, null))
170+}
171+
143172@Composable
144173private fun ReasoningBlock(text: String, context: Context) {
145174 var expanded by remember { mutableStateOf(false) }
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2−1
@@ -129,8 +129,9 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
129129 topBar = {
130130 TopAppBar(
131131 title = {
132+ val title = state.currentSessionTitle.ifEmpty { "Chat" }
132133 Text(
133− text = state.currentSessionTitle.ifEmpty { "Chat" },
134+ text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
134135 maxLines = 1,
135136 overflow = TextOverflow.Ellipsis,
136137 modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+1−1
@@ -98,7 +98,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
9898 Scaffold(
9999 topBar = {
100100 TopAppBar(
101− title = { Text("Sessions") },
101+ title = { Text(if (state.sessions.isNotEmpty()) "Sessions (${state.sessions.size})" else "Sessions") },
102102 navigationIcon = {
103103 IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
104104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")