git.iko.soy/opencode-android
on master help sign in
UX/UI improvements: copy-on-longpress, timestamps, smart auto-scroll, delete confirmation, collapsible reasoning

Bug fixes: loading state timing, user message timestamp, SSE exponential backoff, delete session error handling

commit8205c19d8b39e38e6796669010edc605b725d563 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 20:51 (3 months ago) parent537d504
5 files changed+184−21
CLAUDE.md+40 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+31−12 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+73−6 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+9−2 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+31−1
CLAUDE.mdnew file+40
@@ -0,0 +1,40 @@
1+# opencode-android
2+
3+Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4+
5+## Architecture
6+
7+- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8+- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9+- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10+- Three screens: Connect → Sessions → Chat
11+
12+## Key Files
13+
14+- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15+- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16+- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17+- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18+- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19+- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20+- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21+
22+## Build
23+
24+```sh
25+source .buildenv.sh
26+nix develop -c ./gradlew assembleDebug
27+```
28+
29+## Code Conventions
30+
31+- Kotlin + Jetpack Compose (Material 3)
32+- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33+- All state mutations on main dispatcher (viewModelScope)
34+- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35+- Keep the Rust core and Kotlin port behaviourally identical
36+
37+## Version Control
38+
39+- Uses `jj` (jujutsu) colocated with git
40+- Push to master: `jj git push --change @` to create a bookmark, or commit then push
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+31−12
@@ -47,6 +47,7 @@ class OpencodeCore(
4747
4848 private var sse: SseClient? = null
4949 private var sseClosedIntentionally = false
50+ private var reconnectAttempts = 0
5051
5152 // ── Event entry point ──────────────────────────────────────────────────────
5253 fun dispatch(event: Event) {
@@ -81,14 +82,27 @@ class OpencodeCore(
8182 Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
8283
8384 is Event.DeleteSession -> {
84− sessions.removeAll { it.id == event.id }
85− if (isCurrent(event.id)) {
86− currentSessionId = null
87− messages.clear()
88− screen = Screen.Sessions
85+ val id = event.id
86+ // Delete on the server first; only drop it from the UI once that
87+ // succeeds, so a failed request leaves the session recoverable.
88+ scope.launch {
89+ http.delete("$serverUrl/session/$id", authHeader()).fold(
90+ onSuccess = { resp ->
91+ if (resp.code in 200..299) {
92+ sessions.removeAll { it.id == id }
93+ if (isCurrent(id)) {
94+ currentSessionId = null
95+ messages.clear()
96+ screen = Screen.Sessions
97+ }
98+ } else {
99+ error = "Failed to delete session"
100+ }
101+ },
102+ onFailure = { error = "Failed to delete session" },
103+ )
104+ emit()
89105 }
90− emit()
91− scope.launch { http.delete("$serverUrl/session/${event.id}", authHeader()) }
92106 }
93107
94108 is Event.DraftChanged -> { draft = event.text; emit() }
@@ -137,7 +151,7 @@ class OpencodeCore(
137151 val session = currentSessionId ?: return
138152 if (text.isEmpty()) return
139153 localSeq += 1
140− val user = MsgState("local-$localSeq", "user", 0).apply {
154+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
141155 status = MessageStatus.Pending
142156 textParts["local"] = text
143157 }
@@ -207,7 +221,6 @@ class OpencodeCore(
207221 }
208222
209223 private suspend fun loadSessions() {
210− loading = false
211224 http.get("$serverUrl/session", authHeader()).fold(
212225 onSuccess = { resp ->
213226 if (resp.code in 200..299) {
@@ -219,11 +232,11 @@ class OpencodeCore(
219232 },
220233 onFailure = { e -> error = "Request failed: ${e.message}" },
221234 )
235+ loading = false
222236 emit()
223237 }
224238
225239 private suspend fun createSession() {
226− loading = false
227240 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
228241 onSuccess = { resp ->
229242 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
@@ -232,6 +245,7 @@ class OpencodeCore(
232245 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
233246 }
234247 emit()
248+ // SelectSession takes over the loading state (loads messages).
235249 dispatch(Event.SelectSession(session.id))
236250 return
237251 }
@@ -239,6 +253,7 @@ class OpencodeCore(
239253 },
240254 onFailure = { e -> error = "Request failed: ${e.message}" },
241255 )
256+ loading = false
242257 emit()
243258 }
244259
@@ -273,7 +288,7 @@ class OpencodeCore(
273288 url = "$serverUrl/event",
274289 auth = authHeader(),
275290 onEvent = { json -> scope.launch { handleServerEvent(json) } },
276− onStateChange = { open -> if (!open) scheduleReconnect() },
291+ onStateChange = { open -> if (open) reconnectAttempts = 0 else scheduleReconnect() },
277292 )
278293 sse = client
279294 client.connect()
@@ -287,8 +302,12 @@ class OpencodeCore(
287302
288303 private fun scheduleReconnect() {
289304 if (sseClosedIntentionally || !connected) return
305+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
306+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
307+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
308+ if (delayMs < 30_000L) reconnectAttempts += 1
290309 scope.launch {
291− delay(2000)
310+ delay(delayMs)
292311 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
293312 }
294313 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+73−6
@@ -1,6 +1,13 @@
11package soy.iko.opencode.ui.components
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
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
411import androidx.compose.foundation.layout.Arrangement
512import androidx.compose.foundation.layout.Column
613import androidx.compose.foundation.layout.Row
@@ -11,27 +18,41 @@ import androidx.compose.foundation.shape.RoundedCornerShape
1118import androidx.compose.material.icons.Icons
1219import androidx.compose.material.icons.filled.Build
1320import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
1423import androidx.compose.material.icons.filled.Schedule
1524import androidx.compose.material3.Icon
1625import androidx.compose.material3.MaterialTheme
1726import androidx.compose.material3.Text
1827import androidx.compose.runtime.Composable
28+import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.mutableStateOf
30+import androidx.compose.runtime.remember
31+import androidx.compose.runtime.setValue
1932import androidx.compose.ui.Alignment
2033import androidx.compose.ui.Modifier
2134import androidx.compose.ui.draw.clip
2235import androidx.compose.ui.graphics.Color
36+import androidx.compose.ui.platform.LocalContext
2337import androidx.compose.ui.text.font.FontStyle
2438import androidx.compose.ui.unit.dp
39+import java.text.SimpleDateFormat
40+import java.util.Date
41+import java.util.Locale
2542import soy.iko.opencode.core.MessageStatus
2643import soy.iko.opencode.core.MessageView
2744import soy.iko.opencode.core.ToolView
2845import soy.iko.opencode.ui.theme.MonoStyle
2946
47+private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
48+
49+@OptIn(ExperimentalFoundationApi::class)
3050@Composable
3151fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
3252 val isUser = message.isUser
3353 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
3454 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
55+ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
3556 val shape = RoundedCornerShape(
3657 topStart = 16.dp,
3758 topEnd = 16.dp,
@@ -39,6 +60,8 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
3960 bottomEnd = if (isUser) 4.dp else 16.dp,
4061 )
4162
63+ val context = LocalContext.current
64+
4265 Row(
4366 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
4467 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
@@ -48,6 +71,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
4871 .widthIn(max = 320.dp)
4972 .clip(shape)
5073 .background(bubbleColor)
74+ .combinedClickable(
75+ onClick = {},
76+ onLongClick = {
77+ if (message.text.isNotEmpty()) {
78+ copyToClipboard(context, message.text)
79+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
80+ }
81+ },
82+ )
5183 .padding(horizontal = 14.dp, vertical = 10.dp),
5284 ) {
5385 message.reasoning?.let { ReasoningBlock(it) }
@@ -64,6 +96,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
6496 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
6597 }
6698
99+ if (message.time > 0) {
100+ Text(
101+ text = timeFormat.format(Date(message.time)),
102+ style = MaterialTheme.typography.labelSmall,
103+ color = mutedColor,
104+ modifier = Modifier.padding(top = 4.dp),
105+ )
106+ }
107+
67108 if (isUser && message.status != MessageStatus.Sent) {
68109 StatusLine(message.status)
69110 }
@@ -71,14 +112,40 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
71112 }
72113}
73114
115+private fun copyToClipboard(context: Context, text: String) {
116+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
117+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
118+}
119+
74120@Composable
75121private fun ReasoningBlock(text: String) {
76− Text(
77− text = text,
78− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
79− color = MaterialTheme.colorScheme.onSurfaceVariant,
80− modifier = Modifier.padding(bottom = 6.dp),
81− )
122+ var expanded by remember { mutableStateOf(false) }
123+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
124+ Row(
125+ verticalAlignment = Alignment.CenterVertically,
126+ modifier = Modifier.clickable { expanded = !expanded },
127+ ) {
128+ Text(
129+ text = if (expanded) "Show less" else "Reasoning",
130+ style = MaterialTheme.typography.bodySmall,
131+ color = MaterialTheme.colorScheme.onSurfaceVariant,
132+ )
133+ Icon(
134+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
135+ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
136+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
137+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
138+ )
139+ }
140+ if (expanded) {
141+ Text(
142+ text = text,
143+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
144+ color = MaterialTheme.colorScheme.onSurfaceVariant,
145+ modifier = Modifier.padding(top = 4.dp),
146+ )
147+ }
148+ }
82149}
83150
84151@Composable
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+9−2
@@ -43,11 +43,18 @@ import soy.iko.opencode.ui.components.MessageBubble
4343fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
4444 val listState = rememberLazyListState()
4545
46− // Keep the newest content in view as it streams in.
46+ // Keep the newest content in view as it streams in, but only when the user is
47+ // already near the bottom. If they've scrolled up to read history, leave it alone.
4748 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
4849 LaunchedEffect(state.messages.size, lastLen, state.generating) {
4950 if (state.messages.isNotEmpty()) {
50− listState.animateScrollToItem(state.messages.size - 1)
51+ val layoutInfo = listState.layoutInfo
52+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
53+ val totalItems = layoutInfo.totalItemsCount
54+ val nearBottom = totalItems == 0 || lastVisibleIndex >= totalItems - 3
55+ if (nearBottom) {
56+ listState.animateScrollToItem(state.messages.size - 1)
57+ }
5158 }
5259 }
5360
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+31−1
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
1616import androidx.compose.material.icons.filled.ChatBubbleOutline
1717import androidx.compose.material.icons.filled.DeleteOutline
1818import androidx.compose.material.icons.filled.Refresh
19+import androidx.compose.material3.AlertDialog
1920import androidx.compose.material3.ExperimentalMaterial3Api
2021import androidx.compose.material3.ExtendedFloatingActionButton
2122import androidx.compose.material3.HorizontalDivider
@@ -25,8 +26,13 @@ import androidx.compose.material3.LinearProgressIndicator
2526import androidx.compose.material3.MaterialTheme
2627import androidx.compose.material3.Scaffold
2728import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
2830import androidx.compose.material3.TopAppBar
2931import androidx.compose.runtime.Composable
32+import androidx.compose.runtime.getValue
33+import androidx.compose.runtime.mutableStateOf
34+import androidx.compose.runtime.remember
35+import androidx.compose.runtime.setValue
3036import androidx.compose.ui.Alignment
3137import androidx.compose.ui.Modifier
3238import androidx.compose.ui.text.style.TextOverflow
@@ -38,6 +44,30 @@ import soy.iko.opencode.core.UiState
3844@OptIn(ExperimentalMaterial3Api::class)
3945@Composable
4046fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
47+ var pendingDeleteId by remember { mutableStateOf<String?>(null) }
48+
49+ pendingDeleteId?.let { deleteId ->
50+ val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
51+ AlertDialog(
52+ onDismissRequest = { pendingDeleteId = null },
53+ title = { Text("Delete session?") },
54+ text = { Text(pendingSession?.title ?: deleteId) },
55+ confirmButton = {
56+ TextButton(onClick = {
57+ dispatch(Event.DeleteSession(deleteId))
58+ pendingDeleteId = null
59+ }) {
60+ Text("Delete")
61+ }
62+ },
63+ dismissButton = {
64+ TextButton(onClick = { pendingDeleteId = null }) {
65+ Text("Cancel")
66+ }
67+ },
68+ )
69+ }
70+
4171 Scaffold(
4272 topBar = {
4373 TopAppBar(
@@ -76,7 +106,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
76106 SessionRow(
77107 session = session,
78108 onOpen = { dispatch(Event.SelectSession(session.id)) },
79− onDelete = { dispatch(Event.DeleteSession(session.id)) },
109+ onDelete = { pendingDeleteId = session.id },
80110 )
81111 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
82112 }