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
9fac50ciko
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+80−14
@@ -1,5 +1,12 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
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
3import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Row
@@ -9,22 +16,25 @@ import androidx.compose.foundation.layout.height
9import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.widthIn
18import androidx.compose.foundation.layout.widthIn
12−import androidx.compose.material3.CircularProgressIndicator
13import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.MaterialTheme
14import androidx.compose.material3.Surface
20import androidx.compose.material3.Surface
15import androidx.compose.material3.Text
21import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
22import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.getValue
17import androidx.compose.runtime.remember
24import androidx.compose.runtime.remember
18import androidx.compose.ui.Modifier
25import androidx.compose.ui.Modifier
26+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
27+import androidx.compose.ui.platform.LocalClipboardManager
19import androidx.compose.ui.platform.LocalDensity
28import androidx.compose.ui.platform.LocalDensity
29+import androidx.compose.ui.platform.LocalHapticFeedback
20import androidx.compose.ui.platform.LocalWindowInfo
30import androidx.compose.ui.platform.LocalWindowInfo
21import androidx.compose.ui.res.stringResource
31import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.semantics.LiveRegionMode
32import androidx.compose.ui.semantics.LiveRegionMode
23import androidx.compose.ui.semantics.contentDescription
33import androidx.compose.ui.semantics.contentDescription
24import androidx.compose.ui.semantics.liveRegion
34import androidx.compose.ui.semantics.liveRegion
25import androidx.compose.ui.semantics.semantics
35import androidx.compose.ui.semantics.semantics
36+import androidx.compose.ui.text.AnnotatedString
26import androidx.compose.ui.text.font.FontStyle
37import androidx.compose.ui.text.font.FontStyle
27−import androidx.compose.ui.text.style.TextOverflow
28import androidx.compose.ui.tooling.preview.Preview
38import androidx.compose.ui.tooling.preview.Preview
29import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
30import com.mikepenz.markdown.m3.Markdown
40import com.mikepenz.markdown.m3.Markdown
@@ -47,7 +57,11 @@ import java.util.Locale
47 * TalkBack reads the role label + content as a single utterance. Assistant
57 * TalkBack reads the role label + content as a single utterance. Assistant
48 * bubbles are marked as a [LiveRegionMode.Polite] live region so streaming
58 * bubbles are marked as a [LiveRegionMode.Polite] live region so streaming
49 * updates are announced without stealing focus.
59 * 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.
50 */
63 */
64+@OptIn(ExperimentalFoundationApi::class)
51@Composable
65@Composable
52fun MessageBubble(
66fun MessageBubble(
53 message: MessageView,
67 message: MessageView,
@@ -61,6 +75,8 @@ fun MessageBubble(
61 stringResource(R.string.chat_role_assistant)
75 stringResource(R.string.chat_role_assistant)
62 }
76 }
63 val timeLabel = remember(message.time) { formatTimestamp(message.time) }
77 val timeLabel = remember(message.time) { formatTimestamp(message.time) }
78+ val clipboardManager = LocalClipboardManager.current
79+ val hapticFeedback = LocalHapticFeedback.current
64 // Build a single TalkBack utterance that includes the role and the
80 // Build a single TalkBack utterance that includes the role and the
65 // message body. Setting an explicit contentDescription on a merge node
81 // message body. Setting an explicit contentDescription on a merge node
66 // replaces descendant text, so we must include the body ourselves rather
82 // replaces descendant text, so we must include the body ourselves rather
@@ -77,6 +93,15 @@ fun MessageBubble(
77 Row(
93 Row(
78 modifier = modifier
94 modifier = modifier
79 .fillMaxWidth()
95 .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+ )
80 .semantics(mergeDescendants = true) {
105 .semantics(mergeDescendants = true) {
81 contentDescription = announcement
106 contentDescription = announcement
82 },
107 },
@@ -173,6 +198,38 @@ fun MessageBubble(
173fun GeneratingIndicator(modifier: Modifier = Modifier) {
198fun GeneratingIndicator(modifier: Modifier = Modifier) {
174 val maxWidth = maxBubbleWidthDp()
199 val maxWidth = maxBubbleWidthDp()
175 val generatingLabel = stringResource(R.string.chat_cd_generating)
200 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+ )
176 Row(
233 Row(
177 horizontalArrangement = Arrangement.Start,
234 horizontalArrangement = Arrangement.Start,
178 modifier = modifier
235 modifier = modifier
@@ -187,6 +244,7 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
187 ) {
244 ) {
188 Row(
245 Row(
189 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
246 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
247+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
190 modifier = Modifier
248 modifier = Modifier
191 .padding(
249 .padding(
192 horizontal = Dimens.bubbleHorizontalPadding,
250 horizontal = Dimens.bubbleHorizontalPadding,
@@ -198,23 +256,31 @@ fun GeneratingIndicator(modifier: Modifier = Modifier) {
198 contentDescription = generatingLabel
256 contentDescription = generatingLabel
199 },
257 },
200 ) {
258 ) {
201− CircularProgressIndicator(
259+ GeneratingDot(alpha = phase1, color = dotColor)
202− modifier = Modifier.size(Dimens.iconInlineSpinner),
260+ GeneratingDot(alpha = phase2, color = dotColor)
203− strokeWidth = Dimens.strokeThin,
261+ GeneratingDot(alpha = phase3, color = dotColor)
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− )
213 }
262 }
214 }
263 }
215 }
264 }
216}
265}
217
266
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+
218private fun formatTimestamp(epochSeconds: ULong): String {
284private fun formatTimestamp(epochSeconds: ULong): String {
219 val date = Date(epochSeconds.toLong() * 1000)
285 val date = Date(epochSeconds.toLong() * 1000)
220 val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
286 val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+102−32
@@ -1,21 +1,26 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.scaleIn
5+import androidx.compose.animation.scaleOut
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.lazy.rememberLazyListState
14import androidx.compose.foundation.lazy.rememberLazyListState
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.filled.KeyboardArrowDown
17import 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
17import androidx.compose.material3.ExperimentalMaterial3Api
22import androidx.compose.material3.ExperimentalMaterial3Api
18−import androidx.compose.material3.FloatingActionButton
23+import androidx.compose.material3.ExtendedFloatingActionButton
19import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
20import androidx.compose.material3.IconButton
25import androidx.compose.material3.IconButton
21import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
@@ -33,6 +38,8 @@ import androidx.compose.runtime.remember
33import androidx.compose.runtime.setValue
38import androidx.compose.runtime.setValue
34import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
41+import androidx.compose.ui.focus.FocusRequester
42+import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.tooling.preview.Preview
45import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +69,8 @@ fun ChatScreen(core: Core) {
62 var inputText by remember { mutableStateOf("") }
69 var inputText by remember { mutableStateOf("") }
63 val listState = rememberLazyListState()
70 val listState = rememberLazyListState()
64 var scrollRequest by remember { mutableIntStateOf(0) }
71 var scrollRequest by remember { mutableIntStateOf(0) }
72+ var menuExpanded by remember { mutableStateOf(false) }
73+ val inputFocusRequester = remember { FocusRequester() }
65
74
66 // Track whether the user is parked at the bottom of the list. We only
75 // Track whether the user is parked at the bottom of the list. We only
67 // auto-scroll when they are, so reading history isn't yanked away as
76 // auto-scroll when they are, so reading history isn't yanked away as
@@ -90,6 +99,16 @@ fun ChatScreen(core: Core) {
90 }
99 }
91 }
100 }
92
101
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+
93 val errorHostState = ErrorHost(
112 val errorHostState = ErrorHost(
94 core = core,
113 core = core,
95 dismissLabel = stringResource(R.string.action_dismiss),
114 dismissLabel = stringResource(R.string.action_dismiss),
@@ -106,7 +125,6 @@ fun ChatScreen(core: Core) {
106
125
107 // Resolve nullable session fields into locals once per recomposition so we
126 // Resolve nullable session fields into locals once per recomposition so we
108 // never smart-cast a delegate-backed collected property across calls.
127 // never smart-cast a delegate-backed collected property across calls.
109− val sessionId = view.currentSessionId
110 val sessionTitle = view.currentSessionTitle
128 val sessionTitle = view.currentSessionTitle
111 val chatFallback = stringResource(R.string.top_bar_chat_fallback)
129 val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112 val idShortTemplate = stringResource(R.string.chat_session_id_short)
130 val idShortTemplate = stringResource(R.string.chat_session_id_short)
@@ -126,6 +144,10 @@ fun ChatScreen(core: Core) {
126 } else null
144 } else null
127 }
145 }
128
146
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+
129 Scaffold(
151 Scaffold(
130 topBar = {
152 topBar = {
131 TopAppBar(
153 TopAppBar(
@@ -154,6 +176,34 @@ fun ChatScreen(core: Core) {
154 )
176 )
155 }
177 }
156 },
178 },
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+ },
157 )
207 )
158 },
208 },
159 bottomBar = {
209 bottomBar = {
@@ -171,6 +221,7 @@ fun ChatScreen(core: Core) {
171 enabled = !view.loading && !view.generating,
221 enabled = !view.loading && !view.generating,
172 placeholder = stringResource(R.string.chat_input_placeholder),
222 placeholder = stringResource(R.string.chat_input_placeholder),
173 sendContentDescription = stringResource(R.string.chat_cd_send),
223 sendContentDescription = stringResource(R.string.chat_cd_send),
224+ modifier = Modifier.focusRequester(inputFocusRequester),
174 )
225 )
175 },
226 },
176 snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
227 snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
@@ -196,25 +247,38 @@ fun ChatScreen(core: Core) {
196 LoadingPlaceholder()
247 LoadingPlaceholder()
197 }
248 }
198 view.messages.isEmpty() -> {
249 view.messages.isEmpty() -> {
199− EmptyState(message = stringResource(R.string.chat_empty))
250+ EmptyState(message = chatEmptyHint)
200 }
251 }
201 else -> {
252 else -> {
202− LazyColumn(
253+ // Pull-to-refresh lets the user manually reload
203− state = listState,
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)) } },
204− modifier = Modifier
259+ modifier = Modifier.fillMaxSize(),
205− .fillMaxSize()
206− .padding(horizontal = Dimens.listHorizontalPadding),
207− verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
208− contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
209 ) {
260 ) {
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+ ) {
210− items(view.messages, key = { it.id }) { message ->
269+ items(view.messages, key = { it.id }) { message ->
211− MessageBubble(message)
270+ MessageBubble(message)
212− }
271+ }
213− // "Thinking" indicator shown after the user sends, while
272+ // "Thinking" indicator shown after the user sends, while
214− // the assistant reply hasn't arrived yet.
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) {
215− item(key = "__generating__") {
279+ item(key = "__generating__") {
216− AnimatedVisibility(visible = view.generating) {
217− GeneratingIndicator()
280+ GeneratingIndicator()
281+ }
218 }
282 }
219 }
283 }
220 }
284 }
@@ -224,24 +288,30 @@ fun ChatScreen(core: Core) {
224
288
225 // Scroll-to-bottom FAB: only when the user has scrolled up away
289 // Scroll-to-bottom FAB: only when the user has scrolled up away
226 // from the latest message and there's content to scroll to.
290 // from the latest message and there's content to scroll to.
291+ // Animated so it scales in/out rather than popping.
292+ AnimatedVisibility(
227− if (view.messages.isNotEmpty() && !isAtBottom) {
293+ visible = view.messages.isNotEmpty() && !isAtBottom,
228− FloatingActionButton(
294+ enter = scaleIn(),
295+ exit = scaleOut(),
296+ modifier = Modifier.align(Alignment.BottomEnd),
297+ ) {
298+ ExtendedFloatingActionButton(
229 onClick = { scrollRequest++ },
299 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,
230 modifier = Modifier
309 modifier = Modifier
231− .align(Alignment.BottomEnd)
232 .padding(
310 .padding(
233 end = Dimens.fabEndMargin,
311 end = Dimens.fabEndMargin,
234 bottom = Dimens.fabBottomMargin,
312 bottom = Dimens.fabBottomMargin,
235− )
313+ ),
236− .size(Dimens.fabSize),
237− containerColor = MaterialTheme.colorScheme.primaryContainer,
238− contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
239− ) {
314+ )
240− Icon(
241− Icons.Default.KeyboardArrowDown,
242− contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
243− )
244− }
245 }
315 }
246 }
316 }
247 }
317 }
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+5−3
@@ -102,14 +102,16 @@ fun ConnectScreen(core: Core) {
102 }
102 }
103
103
104 fun submit() {
104 fun submit() {
105+ val trimmedUrl = url.trim()
105− if (!isValidUrl(url)) {
106+ if (!isValidUrl(trimmedUrl)) {
106 urlError = true
107 urlError = true
107 return
108 return
108 }
109 }
109 urlError = false
110 urlError = false
111+ url = trimmedUrl
110− core.update(Event.ServerUrlChanged(url))
112+ core.update(Event.ServerUrlChanged(trimmedUrl))
111 if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
113 if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
112− core.update(Event.UsernameChanged(username))
114+ core.update(Event.UsernameChanged(username.trim()))
113 core.update(Event.PasswordChanged(password))
115 core.update(Event.PasswordChanged(password))
114 }
116 }
115 core.update(Event.Connect)
117 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) {
93 LoadingPlaceholder()
93 LoadingPlaceholder()
94 }
94 }
95 view.sessions.isEmpty() -> {
95 view.sessions.isEmpty() -> {
96− EmptyState(message = stringResource(R.string.sessions_empty))
96+ EmptyState(message = stringResource(R.string.sessions_empty_hint))
97 }
97 }
98 else -> {
98 else -> {
99 PullToRefreshBox(
99 PullToRefreshBox(
app/src/main/res/values/strings.xml+4−3
@@ -34,7 +34,7 @@
34 <string name="crash_reports_clear_confirm_yes">Clear</string>
34 <string name="crash_reports_clear_confirm_yes">Clear</string>
35
35
36 <!-- Sessions screen -->
36 <!-- 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>
38 <string name="sessions_cd_back">Change server</string>
38 <string name="sessions_cd_back">Change server</string>
39 <string name="sessions_cd_new">New session</string>
39 <string name="sessions_cd_new">New session</string>
40 <string name="sessions_untitled">Untitled</string>
40 <string name="sessions_untitled">Untitled</string>
@@ -42,9 +42,8 @@
42 <string name="session_created">Session created</string>
42 <string name="session_created">Session created</string>
43
43
44 <!-- Chat screen -->
44 <!-- 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>
46 <string name="chat_no_content">(no content)</string>
46 <string name="chat_no_content">(no content)</string>
47− <string name="chat_generating">Generating…</string>
48 <string name="chat_input_placeholder">Message…</string>
47 <string name="chat_input_placeholder">Message…</string>
49 <string name="chat_cd_back">Back</string>
48 <string name="chat_cd_back">Back</string>
50 <string name="chat_cd_send">Send</string>
49 <string name="chat_cd_send">Send</string>
@@ -53,6 +52,8 @@
53 <string name="chat_cd_generating">Generating reply</string>
52 <string name="chat_cd_generating">Generating reply</string>
54 <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
53 <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
55 <string name="chat_session_id_short">Session %s</string>
54 <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>
56
57
57 <!-- Error / snackbar -->
58 <!-- Error / snackbar -->
58 <string name="action_dismiss">Dismiss</string>
59 <string name="action_dismiss">Dismiss</string>