git.iko.soy/opencode-android
on master help sign in
UX/UI: edge-to-edge display, adaptive bubble width, pull-to-refresh on sessions, haptic feedback on send/delete

Bug fixes: clear streaming on CancelGeneration, set loading=false on loadMessages early return, remove unused Activity import

commitaa7f54c6edc34d58578f193de267f9ffcf721df9 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:28 (3 months ago) parentce9b0b6
6 files changed+37−14
app/src/main/java/soy/iko/opencode/MainActivity.kt+2 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−3 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+1 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+7−1 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+22−9 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+2
@@ -3,6 +3,7 @@ package soy.iko.opencode
33import android.os.Bundle
44import androidx.activity.ComponentActivity
55import androidx.activity.compose.setContent
6+import androidx.activity.enableEdgeToEdge
67import androidx.compose.runtime.getValue
78import androidx.lifecycle.compose.collectAsStateWithLifecycle
89import androidx.lifecycle.viewmodel.compose.viewModel
@@ -11,6 +12,7 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
1112
1213class MainActivity : ComponentActivity() {
1314 override fun onCreate(savedInstanceState: Bundle?) {
15+ enableEdgeToEdge()
1416 super.onCreate(savedInstanceState)
1517 setContent {
1618 OpencodeTheme {
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−3
@@ -115,7 +115,9 @@ class OpencodeCore(
115115 is Event.SendMessage -> sendMessage(event.text)
116116
117117 Event.CancelGeneration -> {
118− generating = false; emit()
118+ generating = false
119+ messages.forEach { it.streaming = false }
120+ emit()
119121 val id = currentSessionId ?: return
120122 scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
121123 }
@@ -270,7 +272,7 @@ class OpencodeCore(
270272 private suspend fun loadMessages(sessionId: String) {
271273 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
272274 onSuccess = { resp ->
273− if (!isCurrent(sessionId)) return // user navigated away while loading
275+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
274276 loading = false
275277 if (resp.code in 200..299) {
276278 messages.clear()
@@ -282,7 +284,7 @@ class OpencodeCore(
282284 emit()
283285 },
284286 onFailure = { e ->
285− if (!isCurrent(sessionId)) return
287+ if (!isCurrent(sessionId)) { loading = false; return }
286288 loading = false
287289 error = "Request failed: ${e.message}"
288290 emit()
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+1
@@ -69,6 +69,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
6969 ) {
7070 Column(
7171 modifier = Modifier
72+ .fillMaxWidth(0.85f)
7273 .widthIn(max = 320.dp)
7374 .clip(shape)
7475 .background(bubbleColor)
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+7−1
@@ -47,6 +47,8 @@ import androidx.compose.runtime.remember
4747import androidx.compose.runtime.rememberCoroutineScope
4848import androidx.compose.ui.Alignment
4949import androidx.compose.ui.Modifier
50+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
51+import androidx.compose.ui.platform.LocalHapticFeedback
5052import androidx.compose.ui.text.input.ImeAction
5153import androidx.compose.ui.text.style.TextOverflow
5254import androidx.compose.ui.unit.dp
@@ -221,6 +223,7 @@ private fun ChatInputBar(
221223 onSend: () -> Unit,
222224 onStop: () -> Unit,
223225) {
226+ val haptic = LocalHapticFeedback.current
224227 Surface(tonalElevation = 3.dp) {
225228 Row(
226229 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
@@ -244,7 +247,10 @@ private fun ChatInputBar(
244247 }
245248 } else {
246249 FilledIconButton(
247− onClick = onSend,
250+ onClick = {
251+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
252+ onSend()
253+ },
248254 enabled = draft.isNotBlank(),
249255 ) {
250256 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+22−9
@@ -31,6 +31,8 @@ import androidx.compose.material3.Scaffold
3131import androidx.compose.material3.Text
3232import androidx.compose.material3.TextButton
3333import androidx.compose.material3.TopAppBar
34+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
35+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
3436import androidx.compose.runtime.Composable
3537import androidx.compose.runtime.getValue
3638import androidx.compose.runtime.mutableStateOf
@@ -38,6 +40,8 @@ import androidx.compose.runtime.remember
3840import androidx.compose.runtime.setValue
3941import androidx.compose.ui.Alignment
4042import androidx.compose.ui.Modifier
43+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44+import androidx.compose.ui.platform.LocalHapticFeedback
4145import androidx.compose.ui.text.style.TextOverflow
4246import androidx.compose.ui.unit.dp
4347import soy.iko.opencode.core.Event
@@ -47,6 +51,7 @@ import soy.iko.opencode.core.UiState
4751@OptIn(ExperimentalMaterial3Api::class)
4852@Composable
4953fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
54+ val haptic = LocalHapticFeedback.current
5055 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
5156
5257 pendingDeleteId?.let { deleteId ->
@@ -57,6 +62,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
5762 text = { Text(pendingSession?.title ?: deleteId) },
5863 confirmButton = {
5964 TextButton(onClick = {
65+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
6066 dispatch(Event.DeleteSession(deleteId))
6167 pendingDeleteId = null
6268 }) {
@@ -101,17 +107,24 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
101107 }
102108 when {
103109 state.sessions.isEmpty() && !state.loading -> EmptySessions()
104− else -> LazyColumn(
110+ else -> PullToRefreshBox(
111+ isRefreshing = state.loading,
112+ onRefresh = { dispatch(Event.LoadSessions) },
105113 modifier = Modifier.fillMaxSize(),
106− contentPadding = PaddingValues(bottom = 88.dp),
114+ state = rememberPullToRefreshState(),
107115 ) {
108− items(state.sessions, key = { it.id }) { session ->
109− SessionRow(
110− session = session,
111− onOpen = { dispatch(Event.SelectSession(session.id)) },
112− onDelete = { pendingDeleteId = session.id },
113− )
114− HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
116+ LazyColumn(
117+ modifier = Modifier.fillMaxSize(),
118+ contentPadding = PaddingValues(bottom = 88.dp),
119+ ) {
120+ items(state.sessions, key = { it.id }) { session ->
121+ SessionRow(
122+ session = session,
123+ onOpen = { dispatch(Event.SelectSession(session.id)) },
124+ onDelete = { pendingDeleteId = session.id },
125+ )
126+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
127+ }
115128 }
116129 }
117130 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt−1
@@ -1,6 +1,5 @@
11package soy.iko.opencode.ui.theme
22
3−import android.app.Activity
43import android.os.Build
54import androidx.compose.foundation.isSystemInDarkTheme
65import androidx.compose.material3.MaterialTheme