git.iko.soy/opencode-android
on master help sign in
Improve chat UX: long-press to copy messages with haptic feedback, animated typing-dots generating indicator, pull-to-refresh, overflow menu with refresh action, auto-focus input on empty chat, animated scroll-to-bottom FAB, action-hint empty states, and trimmed URL/username on connect
commit9fac50c62902c478c86a4b32bf74ccb0d8b3fe96 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 11:40 (3 months ago) parentf34974c
5 files changed+192−53
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+80−14 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+102−32 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+5−3 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+1−1 app/src/main/res/values/strings.xml+4−3
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+80−14
@@ -1,5 +1,12 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.animation.core.RepeatMode
4+import androidx.compose.animation.core.animateFloat
5+import androidx.compose.animation.core.infiniteRepeatable
6+import androidx.compose.animation.core.rememberInfiniteTransition
7+import androidx.compose.animation.core.tween
8+import androidx.compose.foundation.ExperimentalFoundationApi
9+import androidx.compose.foundation.combinedClickable
310import androidx.compose.foundation.layout.Arrangement
411import androidx.compose.foundation.layout.Column
512import androidx.compose.foundation.layout.Row
@@ -9,22 +16,25 @@ import androidx.compose.foundation.layout.height
916import androidx.compose.foundation.layout.padding
1017import androidx.compose.foundation.layout.size
1118import androidx.compose.foundation.layout.widthIn
12−import androidx.compose.material3.CircularProgressIndicator
1319import androidx.compose.material3.MaterialTheme
1420import androidx.compose.material3.Surface
1521import androidx.compose.material3.Text
1622import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.getValue
1724import androidx.compose.runtime.remember
1825import androidx.compose.ui.Modifier
26+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
27+import androidx.compose.ui.platform.LocalClipboardManager
1928import androidx.compose.ui.platform.LocalDensity
29+import androidx.compose.ui.platform.LocalHapticFeedback
2030import androidx.compose.ui.platform.LocalWindowInfo
2131import androidx.compose.ui.res.stringResource
2232import androidx.compose.ui.semantics.LiveRegionMode
2333import androidx.compose.ui.semantics.contentDescription
2434import androidx.compose.ui.semantics.liveRegion
2535import androidx.compose.ui.semantics.semantics
36+import androidx.compose.ui.text.AnnotatedString
2637import androidx.compose.ui.text.font.FontStyle
27−import androidx.compose.ui.text.style.TextOverflow
2838import androidx.compose.ui.tooling.preview.Preview
2939import androidx.compose.ui.unit.dp
3040import com.mikepenz.markdown.m3.Markdown
@@ -47,7 +57,11 @@ import java.util.Locale
4757 * TalkBack reads the role label + content as a single utterance. Assistant
4858 * bubbles are marked as a [LiveRegionMode.Polite] live region so streaming
4959 * updates are announced without stealing focus.
60+ *
61+ * Long-press anywhere on the bubble copies the raw message text to the
62+ * clipboard and emits a long-press haptic, mirroring the standard chat UX.
5063 */
64+@OptIn(ExperimentalFoundationApi::class)
5165@Composable
5266fun MessageBubble(
5367 message: MessageView,
@@ -61,6 +75,8 @@ fun MessageBubble(
6175 stringResource(R.string.chat_role_assistant)
6276 }
6377 val timeLabel = remember(message.time) { formatTimestamp(message.time) }
78+ val clipboardManager = LocalClipboardManager.current
79+ val hapticFeedback = LocalHapticFeedback.current
6480 // Build a single TalkBack utterance that includes the role and the
6581 // message body. Setting an explicit contentDescription on a merge node
6682 // replaces descendant text, so we must include the body ourselves rather
@@ -77,6 +93,15 @@ fun MessageBubble(
7793 Row(
7894 modifier = modifier
7995 .fillMaxWidth()
96+ .combinedClickable(
97+ onLongClick = {
98+ if (message.text.isNotBlank()) {
99+ clipboardManager.setText(AnnotatedString(message.text))
100+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
101+ }
102+ },
103+ onClick = {},
104+ )
80105 .semantics(mergeDescendants = true) {
81106 contentDescription = announcement
82107 },
@@ -173,6 +198,38 @@ fun MessageBubble(
173198fun GeneratingIndicator(modifier: Modifier = Modifier) {
174199 val maxWidth = maxBubbleWidthDp()
175200 val generatingLabel = stringResource(R.string.chat_cd_generating)
201+ val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
202+ // Three dots that pulse in sequence: each dot scales/fades with a
203+ // staggered delay so the indicator reads as "typing" rather than a
204+ // single frozen spinner. One shared infinite transition drives them.
205+ val transition = rememberInfiniteTransition(label = "generating-dots")
206+ val phase1 by transition.animateFloat(
207+ initialValue = 0.3f,
208+ targetValue = 1f,
209+ animationSpec = infiniteRepeatable(
210+ animation = tween(durationMillis = 600, delayMillis = 0),
211+ repeatMode = RepeatMode.Reverse,
212+ ),
213+ label = "dot-1",
214+ )
215+ val phase2 by transition.animateFloat(
216+ initialValue = 0.3f,
217+ targetValue = 1f,
218+ animationSpec = infiniteRepeatable(
219+ animation = tween(durationMillis = 600, delayMillis = 200),
220+ repeatMode = RepeatMode.Reverse,
221+ ),
222+ label = "dot-2",
223+ )
224+ val phase3 by transition.animateFloat(
225+ initialValue = 0.3f,
226+ targetValue = 1f,
227+ animationSpec = infiniteRepeatable(
228+ animation = tween(durationMillis = 600, delayMillis = 400),
229+ repeatMode = RepeatMode.Reverse,
230+ ),
231+ label = "dot-3",
232+ )
176233 Row(
177234 horizontalArrangement = Arrangement.Start,
178235 modifier = modifier
@@ -187,6 +244,7 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
187244 ) {
188245 Row(
189246 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
247+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
190248 modifier = Modifier
191249 .padding(
192250 horizontal = Dimens.bubbleHorizontalPadding,
@@ -198,23 +256,31 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
198256 contentDescription = generatingLabel
199257 },
200258 ) {
201− CircularProgressIndicator(
202− modifier = Modifier.size(Dimens.iconInlineSpinner),
203− strokeWidth = Dimens.strokeThin,
204− )
205− Spacer(Modifier.size(Dimens.spaceSmall))
206− Text(
207− text = stringResource(R.string.chat_generating),
208− style = MaterialTheme.typography.bodyMedium,
209− fontStyle = FontStyle.Italic,
210− maxLines = 1,
211− overflow = TextOverflow.Ellipsis,
212− )
259+ GeneratingDot(alpha = phase1, color = dotColor)
260+ GeneratingDot(alpha = phase2, color = dotColor)
261+ GeneratingDot(alpha = phase3, color = dotColor)
213262 }
214263 }
215264 }
216265}
217266
267+@Composable
268+private fun GeneratingDot(alpha: Float, color: androidx.compose.ui.graphics.Color) {
269+ // Scale slightly with alpha so the pulse feels like motion, not just a
270+ // dimmer toggle. alpha drives both the dot's alpha and a small scale.
271+ val scale = 0.75f + alpha * 0.25f
272+ androidx.compose.foundation.Canvas(
273+ modifier = Modifier.size(Dimens.iconInlineSpinner),
274+ ) {
275+ val diameter = size.minDimension * scale
276+ drawCircle(
277+ color = color.copy(alpha = alpha.coerceIn(0.15f, 1f)),
278+ radius = diameter / 2f,
279+ center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f),
280+ )
281+ }
282+}
283+
218284private fun formatTimestamp(epochSeconds: ULong): String {
219285 val date = Date(epochSeconds.toLong() * 1000)
220286 val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+102−32
@@ -1,21 +1,26 @@
11package soy.iko.opencode.ui.screens
22
33import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.scaleIn
5+import androidx.compose.animation.scaleOut
46import androidx.compose.foundation.layout.Arrangement
57import androidx.compose.foundation.layout.Box
68import androidx.compose.foundation.layout.Column
79import androidx.compose.foundation.layout.PaddingValues
810import androidx.compose.foundation.layout.fillMaxSize
911import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
1112import androidx.compose.foundation.lazy.LazyColumn
1213import androidx.compose.foundation.lazy.items
1314import androidx.compose.foundation.lazy.rememberLazyListState
1415import androidx.compose.material.icons.Icons
1516import androidx.compose.material.icons.automirrored.filled.ArrowBack
1617import androidx.compose.material.icons.filled.KeyboardArrowDown
18+import androidx.compose.material.icons.filled.MoreVert
19+import androidx.compose.material.icons.filled.Refresh
20+import androidx.compose.material3.DropdownMenu
21+import androidx.compose.material3.DropdownMenuItem
1722import androidx.compose.material3.ExperimentalMaterial3Api
18−import androidx.compose.material3.FloatingActionButton
23+import androidx.compose.material3.ExtendedFloatingActionButton
1924import androidx.compose.material3.Icon
2025import androidx.compose.material3.IconButton
2126import androidx.compose.material3.MaterialTheme
@@ -33,6 +38,8 @@ import androidx.compose.runtime.remember
3338import androidx.compose.runtime.setValue
3439import androidx.compose.ui.Alignment
3540import androidx.compose.ui.Modifier
41+import androidx.compose.ui.focus.FocusRequester
42+import androidx.compose.ui.focus.focusRequester
3643import androidx.compose.ui.res.stringResource
3744import androidx.compose.ui.text.style.TextOverflow
3845import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +69,8 @@ fun ChatScreen(core: Core) {
6269 var inputText by remember { mutableStateOf("") }
6370 val listState = rememberLazyListState()
6471 var scrollRequest by remember { mutableIntStateOf(0) }
72+ var menuExpanded by remember { mutableStateOf(false) }
73+ val inputFocusRequester = remember { FocusRequester() }
6574
6675 // Track whether the user is parked at the bottom of the list. We only
6776 // auto-scroll when they are, so reading history isn't yanked away as
@@ -90,6 +99,16 @@ fun ChatScreen(core: Core) {
9099 }
91100 }
92101
102+ // Auto-focus the input when the chat is empty so the user can start
103+ // typing immediately without an extra tap. Only request focus once per
104+ // session entry — keyed on the session id so switching sessions retriggers.
105+ val sessionId = view.currentSessionId
106+ LaunchedEffect(sessionId, view.messages.isEmpty()) {
107+ if (sessionId != null && view.messages.isEmpty() && !view.loading && !view.generating) {
108+ inputFocusRequester.requestFocus()
109+ }
110+ }
111+
93112 val errorHostState = ErrorHost(
94113 core = core,
95114 dismissLabel = stringResource(R.string.action_dismiss),
@@ -106,7 +125,6 @@ fun ChatScreen(core: Core) {
106125
107126 // Resolve nullable session fields into locals once per recomposition so we
108127 // never smart-cast a delegate-backed collected property across calls.
109− val sessionId = view.currentSessionId
110128 val sessionTitle = view.currentSessionTitle
111129 val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112130 val idShortTemplate = stringResource(R.string.chat_session_id_short)
@@ -126,6 +144,10 @@ fun ChatScreen(core: Core) {
126144 } else null
127145 }
128146
147+ val refreshLabel = stringResource(R.string.chat_menu_refresh)
148+ val overflowLabel = stringResource(R.string.chat_menu_overflow)
149+ val chatEmptyHint = stringResource(R.string.chat_empty_hint)
150+
129151 Scaffold(
130152 topBar = {
131153 TopAppBar(
@@ -154,6 +176,34 @@ fun ChatScreen(core: Core) {
154176 )
155177 }
156178 },
179+ actions = {
180+ // Overflow menu with a "Refresh messages" action. Gives
181+ // a discoverable manual reload path (e.g. when the SSE
182+ // banner has been dismissed) and a home for future items.
183+ Box {
184+ IconButton(onClick = { menuExpanded = true }) {
185+ Icon(
186+ Icons.Default.MoreVert,
187+ contentDescription = overflowLabel,
188+ )
189+ }
190+ DropdownMenu(
191+ expanded = menuExpanded,
192+ onDismissRequest = { menuExpanded = false },
193+ ) {
194+ DropdownMenuItem(
195+ text = { Text(refreshLabel) },
196+ leadingIcon = {
197+ Icon(Icons.Default.Refresh, contentDescription = null)
198+ },
199+ onClick = {
200+ menuExpanded = false
201+ sessionId?.let { core.update(Event.LoadMessages(it)) }
202+ },
203+ )
204+ }
205+ }
206+ },
157207 )
158208 },
159209 bottomBar = {
@@ -171,6 +221,7 @@ fun ChatScreen(core: Core) {
171221 enabled = !view.loading && !view.generating,
172222 placeholder = stringResource(R.string.chat_input_placeholder),
173223 sendContentDescription = stringResource(R.string.chat_cd_send),
224+ modifier = Modifier.focusRequester(inputFocusRequester),
174225 )
175226 },
176227 snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
@@ -196,25 +247,38 @@ fun ChatScreen(core: Core) {
196247 LoadingPlaceholder()
197248 }
198249 view.messages.isEmpty() -> {
199− EmptyState(message = stringResource(R.string.chat_empty))
250+ EmptyState(message = chatEmptyHint)
200251 }
201252 else -> {
202− LazyColumn(
203− state = listState,
204− modifier = Modifier
205− .fillMaxSize()
206− .padding(horizontal = Dimens.listHorizontalPadding),
207− verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
208− contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
253+ // Pull-to-refresh lets the user manually reload
254+ // messages, e.g. when the SSE stream stalled and the
255+ // banner was dismissed. Mirrors the Sessions screen.
256+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
257+ isRefreshing = view.loading,
258+ onRefresh = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
259+ modifier = Modifier.fillMaxSize(),
209260 ) {
210− items(view.messages, key = { it.id }) { message ->
211− MessageBubble(message)
212− }
213− // "Thinking" indicator shown after the user sends, while
214− // the assistant reply hasn't arrived yet.
215− item(key = "__generating__") {
216− AnimatedVisibility(visible = view.generating) {
217− GeneratingIndicator()
261+ LazyColumn(
262+ state = listState,
263+ modifier = Modifier
264+ .fillMaxSize()
265+ .padding(horizontal = Dimens.listHorizontalPadding),
266+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
268+ ) {
269+ items(view.messages, key = { it.id }) { message ->
270+ MessageBubble(message)
271+ }
272+ // "Thinking" indicator shown after the user sends, while
273+ // the assistant reply hasn't arrived yet. The indicator
274+ // itself animates (pulsing dots), so we don't wrap it in
275+ // AnimatedVisibility — an outer ColumnScope receiver from
276+ // the chat Column would shadow the LazyItemScope here and
277+ // break AnimatedVisibility overload resolution.
278+ if (view.generating) {
279+ item(key = "__generating__") {
280+ GeneratingIndicator()
281+ }
218282 }
219283 }
220284 }
@@ -224,24 +288,30 @@ fun ChatScreen(core: Core) {
224288
225289 // Scroll-to-bottom FAB: only when the user has scrolled up away
226290 // from the latest message and there's content to scroll to.
227− if (view.messages.isNotEmpty() && !isAtBottom) {
228− FloatingActionButton(
291+ // Animated so it scales in/out rather than popping.
292+ AnimatedVisibility(
293+ visible = view.messages.isNotEmpty() && !isAtBottom,
294+ enter = scaleIn(),
295+ exit = scaleOut(),
296+ modifier = Modifier.align(Alignment.BottomEnd),
297+ ) {
298+ ExtendedFloatingActionButton(
229299 onClick = { scrollRequest++ },
300+ icon = {
301+ Icon(
302+ Icons.Default.KeyboardArrowDown,
303+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
304+ )
305+ },
306+ text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
307+ containerColor = MaterialTheme.colorScheme.primaryContainer,
308+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
230309 modifier = Modifier
231− .align(Alignment.BottomEnd)
232310 .padding(
233311 end = Dimens.fabEndMargin,
234312 bottom = Dimens.fabBottomMargin,
235− )
236− .size(Dimens.fabSize),
237− containerColor = MaterialTheme.colorScheme.primaryContainer,
238− contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
239− ) {
240− Icon(
241− Icons.Default.KeyboardArrowDown,
242− contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
243− )
244− }
313+ ),
314+ )
245315 }
246316 }
247317 }
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+5−3
@@ -102,14 +102,16 @@ fun ConnectScreen(core: Core) {
102102 }
103103
104104 fun submit() {
105− if (!isValidUrl(url)) {
105+ val trimmedUrl = url.trim()
106+ if (!isValidUrl(trimmedUrl)) {
106107 urlError = true
107108 return
108109 }
109110 urlError = false
110− core.update(Event.ServerUrlChanged(url))
111+ url = trimmedUrl
112+ core.update(Event.ServerUrlChanged(trimmedUrl))
111113 if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
112− core.update(Event.UsernameChanged(username))
114+ core.update(Event.UsernameChanged(username.trim()))
113115 core.update(Event.PasswordChanged(password))
114116 }
115117 core.update(Event.Connect)
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+1−1
@@ -93,7 +93,7 @@ fun SessionsScreen(core: Core) {
9393 LoadingPlaceholder()
9494 }
9595 view.sessions.isEmpty() -> {
96− EmptyState(message = stringResource(R.string.sessions_empty))
96+ EmptyState(message = stringResource(R.string.sessions_empty_hint))
9797 }
9898 else -> {
9999 PullToRefreshBox(
app/src/main/res/values/strings.xml+4−3
@@ -34,7 +34,7 @@
3434 <string name="crash_reports_clear_confirm_yes">Clear</string>
3535
3636 <!-- Sessions screen -->
37− <string name="sessions_empty">No sessions yet</string>
37+ <string name="sessions_empty_hint">No sessions yet. Tap + to create one.</string>
3838 <string name="sessions_cd_back">Change server</string>
3939 <string name="sessions_cd_new">New session</string>
4040 <string name="sessions_untitled">Untitled</string>
@@ -42,9 +42,8 @@
4242 <string name="session_created">Session created</string>
4343
4444 <!-- Chat screen -->
45− <string name="chat_empty">Send a message to start chatting</string>
45+ <string name="chat_empty_hint">Send a message below to start the conversation.</string>
4646 <string name="chat_no_content">(no content)</string>
47− <string name="chat_generating">Generating…</string>
4847 <string name="chat_input_placeholder">Message…</string>
4948 <string name="chat_cd_back">Back</string>
5049 <string name="chat_cd_send">Send</string>
@@ -53,6 +52,8 @@
5352 <string name="chat_cd_generating">Generating reply</string>
5453 <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
5554 <string name="chat_session_id_short">Session %s</string>
55+ <string name="chat_menu_overflow">More options</string>
56+ <string name="chat_menu_refresh">Refresh messages</string>
5657
5758 <!-- Error / snackbar -->
5859 <string name="action_dismiss">Dismiss</string>