git.iko.soy/opencode-android
on master help sign in
UX/UI improvements: copy-on-longpress, timestamps, smart auto-scroll, delete confirmation, collapsible reasoning
8205c19iko
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(
47
47
48 private var sse: SseClient? = null
48 private var sse: SseClient? = null
49 private var sseClosedIntentionally = false
49 private var sseClosedIntentionally = false
50+ private var reconnectAttempts = 0
50
51
51 // ── Event entry point ──────────────────────────────────────────────────────
52 // ── Event entry point ──────────────────────────────────────────────────────
52 fun dispatch(event: Event) {
53 fun dispatch(event: Event) {
@@ -81,14 +82,27 @@ class OpencodeCore(
81 Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
82 Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
82
83
83 is Event.DeleteSession -> {
84 is Event.DeleteSession -> {
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) {
84− sessions.removeAll { it.id == event.id }
92+ sessions.removeAll { it.id == id }
85− if (isCurrent(event.id)) {
93+ if (isCurrent(id)) {
86− currentSessionId = null
94+ currentSessionId = null
87− messages.clear()
95+ messages.clear()
88− screen = Screen.Sessions
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()
89 }
105 }
90− emit()
91− scope.launch { http.delete("$serverUrl/session/${event.id}", authHeader()) }
92 }
106 }
93
107
94 is Event.DraftChanged -> { draft = event.text; emit() }
108 is Event.DraftChanged -> { draft = event.text; emit() }
@@ -137,7 +151,7 @@ class OpencodeCore(
137 val session = currentSessionId ?: return
151 val session = currentSessionId ?: return
138 if (text.isEmpty()) return
152 if (text.isEmpty()) return
139 localSeq += 1
153 localSeq += 1
140− val user = MsgState("local-$localSeq", "user", 0).apply {
154+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
141 status = MessageStatus.Pending
155 status = MessageStatus.Pending
142 textParts["local"] = text
156 textParts["local"] = text
143 }
157 }
@@ -207,7 +221,6 @@ class OpencodeCore(
207 }
221 }
208
222
209 private suspend fun loadSessions() {
223 private suspend fun loadSessions() {
210− loading = false
211 http.get("$serverUrl/session", authHeader()).fold(
224 http.get("$serverUrl/session", authHeader()).fold(
212 onSuccess = { resp ->
225 onSuccess = { resp ->
213 if (resp.code in 200..299) {
226 if (resp.code in 200..299) {
@@ -219,11 +232,11 @@ class OpencodeCore(
219 },
232 },
220 onFailure = { e -> error = "Request failed: ${e.message}" },
233 onFailure = { e -> error = "Request failed: ${e.message}" },
221 )
234 )
235+ loading = false
222 emit()
236 emit()
223 }
237 }
224
238
225 private suspend fun createSession() {
239 private suspend fun createSession() {
226− loading = false
227 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
240 http.postJson("$serverUrl/session", "{}", authHeader()).fold(
228 onSuccess = { resp ->
241 onSuccess = { resp ->
229 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
242 val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
@@ -232,6 +245,7 @@ class OpencodeCore(
232 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
245 sessions.add(0, SessionView(session.id, displayTitle(session.title)))
233 }
246 }
234 emit()
247 emit()
248+ // SelectSession takes over the loading state (loads messages).
235 dispatch(Event.SelectSession(session.id))
249 dispatch(Event.SelectSession(session.id))
236 return
250 return
237 }
251 }
@@ -239,6 +253,7 @@ class OpencodeCore(
239 },
253 },
240 onFailure = { e -> error = "Request failed: ${e.message}" },
254 onFailure = { e -> error = "Request failed: ${e.message}" },
241 )
255 )
256+ loading = false
242 emit()
257 emit()
243 }
258 }
244
259
@@ -273,7 +288,7 @@ class OpencodeCore(
273 url = "$serverUrl/event",
288 url = "$serverUrl/event",
274 auth = authHeader(),
289 auth = authHeader(),
275 onEvent = { json -> scope.launch { handleServerEvent(json) } },
290 onEvent = { json -> scope.launch { handleServerEvent(json) } },
276− onStateChange = { open -> if (!open) scheduleReconnect() },
291+ onStateChange = { open -> if (open) reconnectAttempts = 0 else scheduleReconnect() },
277 )
292 )
278 sse = client
293 sse = client
279 client.connect()
294 client.connect()
@@ -287,8 +302,12 @@ class OpencodeCore(
287
302
288 private fun scheduleReconnect() {
303 private fun scheduleReconnect() {
289 if (sseClosedIntentionally || !connected) return
304 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
290 scope.launch {
309 scope.launch {
291− delay(2000)
310+ delay(delayMs)
292 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
311 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
293 }
312 }
294 }
313 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+73−6
@@ -1,6 +1,13 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
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
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Row
@@ -11,27 +18,41 @@ import androidx.compose.foundation.shape.RoundedCornerShape
11import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.filled.Build
19import androidx.compose.material.icons.filled.Build
13import androidx.compose.material.icons.filled.ErrorOutline
20import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
14import androidx.compose.material.icons.filled.Schedule
23import androidx.compose.material.icons.filled.Schedule
15import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.Text
26import androidx.compose.material3.Text
18import androidx.compose.runtime.Composable
27import 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
19import androidx.compose.ui.Alignment
32import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
33import androidx.compose.ui.Modifier
21import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
22import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.Color
36+import androidx.compose.ui.platform.LocalContext
23import androidx.compose.ui.text.font.FontStyle
37import androidx.compose.ui.text.font.FontStyle
24import androidx.compose.ui.unit.dp
38import androidx.compose.ui.unit.dp
39+import java.text.SimpleDateFormat
40+import java.util.Date
41+import java.util.Locale
25import soy.iko.opencode.core.MessageStatus
42import soy.iko.opencode.core.MessageStatus
26import soy.iko.opencode.core.MessageView
43import soy.iko.opencode.core.MessageView
27import soy.iko.opencode.core.ToolView
44import soy.iko.opencode.core.ToolView
28import soy.iko.opencode.ui.theme.MonoStyle
45import soy.iko.opencode.ui.theme.MonoStyle
29
46
47+private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
48+
49+@OptIn(ExperimentalFoundationApi::class)
30@Composable
50@Composable
31fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
51fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
32 val isUser = message.isUser
52 val isUser = message.isUser
33 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
53 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
34 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
54 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
35 val shape = RoundedCornerShape(
56 val shape = RoundedCornerShape(
36 topStart = 16.dp,
57 topStart = 16.dp,
37 topEnd = 16.dp,
58 topEnd = 16.dp,
@@ -39,6 +60,8 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
39 bottomEnd = if (isUser) 4.dp else 16.dp,
60 bottomEnd = if (isUser) 4.dp else 16.dp,
40 )
61 )
41
62
63+ val context = LocalContext.current
64+
42 Row(
65 Row(
43 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
66 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
44 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
67 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
@@ -48,6 +71,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
48 .widthIn(max = 320.dp)
71 .widthIn(max = 320.dp)
49 .clip(shape)
72 .clip(shape)
50 .background(bubbleColor)
73 .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+ )
51 .padding(horizontal = 14.dp, vertical = 10.dp),
83 .padding(horizontal = 14.dp, vertical = 10.dp),
52 ) {
84 ) {
53 message.reasoning?.let { ReasoningBlock(it) }
85 message.reasoning?.let { ReasoningBlock(it) }
@@ -64,6 +96,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
64 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
96 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
65 }
97 }
66
98
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+
67 if (isUser && message.status != MessageStatus.Sent) {
108 if (isUser && message.status != MessageStatus.Sent) {
68 StatusLine(message.status)
109 StatusLine(message.status)
69 }
110 }
@@ -71,14 +112,40 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
71 }
112 }
72}
113}
73
114
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+
74@Composable
120@Composable
75private fun ReasoningBlock(text: String) {
121private fun ReasoningBlock(text: String) {
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+ ) {
76− Text(
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(
77− text = text,
142+ text = text,
78− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
143+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
79− color = MaterialTheme.colorScheme.onSurfaceVariant,
144+ color = MaterialTheme.colorScheme.onSurfaceVariant,
80− modifier = Modifier.padding(bottom = 6.dp),
145+ modifier = Modifier.padding(top = 4.dp),
81− )
146+ )
147+ }
148+ }
82}
149}
83
150
84@Composable
151@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
43fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
43fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
44 val listState = rememberLazyListState()
44 val listState = rememberLazyListState()
45
45
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.
47 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
48 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
48 LaunchedEffect(state.messages.size, lastLen, state.generating) {
49 LaunchedEffect(state.messages.size, lastLen, state.generating) {
49 if (state.messages.isNotEmpty()) {
50 if (state.messages.isNotEmpty()) {
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) {
50− listState.animateScrollToItem(state.messages.size - 1)
56+ listState.animateScrollToItem(state.messages.size - 1)
57+ }
51 }
58 }
52 }
59 }
53
60
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+31−1
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
16import androidx.compose.material.icons.filled.ChatBubbleOutline
16import androidx.compose.material.icons.filled.ChatBubbleOutline
17import androidx.compose.material.icons.filled.DeleteOutline
17import androidx.compose.material.icons.filled.DeleteOutline
18import androidx.compose.material.icons.filled.Refresh
18import androidx.compose.material.icons.filled.Refresh
19+import androidx.compose.material3.AlertDialog
19import androidx.compose.material3.ExperimentalMaterial3Api
20import androidx.compose.material3.ExperimentalMaterial3Api
20import androidx.compose.material3.ExtendedFloatingActionButton
21import androidx.compose.material3.ExtendedFloatingActionButton
21import androidx.compose.material3.HorizontalDivider
22import androidx.compose.material3.HorizontalDivider
@@ -25,8 +26,13 @@ import androidx.compose.material3.LinearProgressIndicator
25import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.Scaffold
27import androidx.compose.material3.Scaffold
27import androidx.compose.material3.Text
28import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
28import androidx.compose.material3.TopAppBar
30import androidx.compose.material3.TopAppBar
29import androidx.compose.runtime.Composable
31import 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
30import androidx.compose.ui.Alignment
36import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
37import androidx.compose.ui.Modifier
32import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.text.style.TextOverflow
@@ -38,6 +44,30 @@ import soy.iko.opencode.core.UiState
38@OptIn(ExperimentalMaterial3Api::class)
44@OptIn(ExperimentalMaterial3Api::class)
39@Composable
45@Composable
40fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
46fun 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+
41 Scaffold(
71 Scaffold(
42 topBar = {
72 topBar = {
43 TopAppBar(
73 TopAppBar(
@@ -76,7 +106,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
76 SessionRow(
106 SessionRow(
77 session = session,
107 session = session,
78 onOpen = { dispatch(Event.SelectSession(session.id)) },
108 onOpen = { dispatch(Event.SelectSession(session.id)) },
79− onDelete = { dispatch(Event.DeleteSession(session.id)) },
109+ onDelete = { pendingDeleteId = session.id },
80 )
110 )
81 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
111 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
82 }
112 }