git.iko.soy/opencode-android
on master help sign in
UX/UI: message and session count in TopAppBars, long-press share menu
5244821iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+19−2
@@ -63,6 +63,12 @@ class OpencodeCore(
63 // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
63 // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64 // otherwise launch two concurrent health probes; cancel the prior one first.
64 // otherwise launch two concurrent health probes; cancel the prior one first.
65 private var connectJob: Job? = null
65 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
66 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
72 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
67 private val removedMessageIds = mutableSetOf<String>()
73 private val removedMessageIds = mutableSetOf<String>()
68
74
@@ -111,6 +117,7 @@ class OpencodeCore(
111 generating = false
117 generating = false
112 error = null
118 error = null
113 emit()
119 emit()
120+ sendMessageJob?.cancel()
114 loadMessagesJob?.cancel()
121 loadMessagesJob?.cancel()
115 loadMessagesJob = scope.launch { loadMessages(event.id) }
122 loadMessagesJob = scope.launch { loadMessages(event.id) }
116 }
123 }
@@ -126,7 +133,8 @@ class OpencodeCore(
126 // Delete on the server first; only drop it from the UI once that
133 // Delete on the server first; only drop it from the UI once that
127 // succeeds, so a failed request leaves the session recoverable.
134 // succeeds, so a failed request leaves the session recoverable.
128 loading = true; error = null; emit()
135 loading = true; error = null; emit()
136+ deleteSessionJob?.cancel()
129− scope.launch {
137+ deleteSessionJob = scope.launch {
130 http.delete("$serverUrl/session/$id", authHeader()).fold(
138 http.delete("$serverUrl/session/$id", authHeader()).fold(
131 onSuccess = { resp ->
139 onSuccess = { resp ->
132 if (resp.code in 200..299) {
140 if (resp.code in 200..299) {
@@ -171,7 +179,10 @@ class OpencodeCore(
171 generating = false
179 generating = false
172 loading = true
180 loading = true
173 emit()
181 emit()
182+ connectJob?.cancel()
174 createSessionJob?.cancel()
183 createSessionJob?.cancel()
184+ deleteSessionJob?.cancel()
185+ sendMessageJob?.cancel()
175 loadMessagesJob?.cancel()
186 loadMessagesJob?.cancel()
176 loadSessionsJob?.cancel()
187 loadSessionsJob?.cancel()
177 loadSessionsJob = scope.launch { loadSessions() }
188 loadSessionsJob = scope.launch { loadSessions() }
@@ -190,7 +201,10 @@ class OpencodeCore(
190 // Reset to the default so a late-firing close callback from the old
201 // Reset to the default so a late-firing close callback from the old
191 // SSE connection can't flash the reconnecting banner on the next connect.
202 // SSE connection can't flash the reconnecting banner on the next connect.
192 sseConnected = true
203 sseConnected = true
204+ connectJob?.cancel()
193 createSessionJob?.cancel()
205 createSessionJob?.cancel()
206+ deleteSessionJob?.cancel()
207+ sendMessageJob?.cancel()
194 loadMessagesJob?.cancel()
208 loadMessagesJob?.cancel()
195 closeSse()
209 closeSse()
196 emit()
210 emit()
@@ -230,7 +244,8 @@ class OpencodeCore(
230 emit()
244 emit()
231
245
232 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
246 val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
247+ sendMessageJob?.cancel()
233− scope.launch {
248+ sendMessageJob = scope.launch {
234 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
249 val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
235 result.fold(
250 result.fold(
236 onSuccess = { resp ->
251 onSuccess = { resp ->
@@ -284,6 +299,7 @@ class OpencodeCore(
284 }
299 }
285 else -> {
300 else -> {
286 loading = false
301 loading = false
302+ connected = false
287 error = "Server returned status ${resp.code}"
303 error = "Server returned status ${resp.code}"
288 emit()
304 emit()
289 }
305 }
@@ -291,6 +307,7 @@ class OpencodeCore(
291 },
307 },
292 onFailure = { e ->
308 onFailure = { e ->
293 loading = false
309 loading = false
310+ connected = false
294 error = "Connection failed: ${e.message}"
311 error = "Connection failed: ${e.message}"
295 emit()
312 emit()
296 },
313 },
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+31−2
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.components
3import android.content.ClipData
3import android.content.ClipData
4import android.content.ClipboardManager
4import android.content.ClipboardManager
5import android.content.Context
5import android.content.Context
6+import android.content.Intent
6import android.widget.Toast
7import android.widget.Toast
7import androidx.compose.foundation.ExperimentalFoundationApi
8import androidx.compose.foundation.ExperimentalFoundationApi
8import androidx.compose.foundation.background
9import androidx.compose.foundation.background
@@ -25,6 +26,8 @@ import androidx.compose.material.icons.filled.ExpandLess
25import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.Schedule
27import androidx.compose.material.icons.filled.Schedule
27import androidx.compose.material.icons.filled.SmartToy
28import androidx.compose.material.icons.filled.SmartToy
29+import androidx.compose.material3.DropdownMenu
30+import androidx.compose.material3.DropdownMenuItem
28import androidx.compose.material3.Icon
31import androidx.compose.material3.Icon
29import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.Text
33import androidx.compose.material3.Text
@@ -67,6 +70,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
67 )
70 )
68
71
69 val context = LocalContext.current
72 val context = LocalContext.current
73+ var showMenu by remember { mutableStateOf(false) }
70
74
71 Row(
75 Row(
72 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
76 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:
90 onClick = {},
94 onClick = {},
91 onLongClick = {
95 onLongClick = {
92 if (message.text.isNotEmpty()) {
96 if (message.text.isNotEmpty()) {
93− copyToClipboard(context, message.text)
97+ showMenu = true
94− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
95 }
98 }
96 },
99 },
97 )
100 )
98 .padding(horizontal = 14.dp, vertical = 10.dp),
101 .padding(horizontal = 14.dp, vertical = 10.dp),
99 ) {
102 ) {
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+
100 message.reasoning?.let { ReasoningBlock(it, context) }
121 message.reasoning?.let { ReasoningBlock(it, context) }
101
122
102 message.tools.forEach { ToolRow(it) }
123 message.tools.forEach { ToolRow(it) }
@@ -140,6 +161,14 @@ private fun copyToClipboard(context: Context, text: String) {
140 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
161 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
141}
162}
142
163
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+
143@Composable
172@Composable
144private fun ReasoningBlock(text: String, context: Context) {
173private fun ReasoningBlock(text: String, context: Context) {
145 var expanded by remember { mutableStateOf(false) }
174 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) {
129 topBar = {
129 topBar = {
130 TopAppBar(
130 TopAppBar(
131 title = {
131 title = {
132+ val title = state.currentSessionTitle.ifEmpty { "Chat" }
132 Text(
133 Text(
133− text = state.currentSessionTitle.ifEmpty { "Chat" },
134+ text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
134 maxLines = 1,
135 maxLines = 1,
135 overflow = TextOverflow.Ellipsis,
136 overflow = TextOverflow.Ellipsis,
136 modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
137 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) {
98 Scaffold(
98 Scaffold(
99 topBar = {
99 topBar = {
100 TopAppBar(
100 TopAppBar(
101− title = { Text("Sessions") },
101+ title = { Text(if (state.sessions.isNotEmpty()) "Sessions (${state.sessions.size})" else "Sessions") },
102 navigationIcon = {
102 navigationIcon = {
103 IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
103 IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")