git.iko.soy/opencode-android
on master help sign in
Fix UI/UX bugs and add polish: separate error/info snackbar hosts, remove redundant sessions logout button, null-safe chat session id, lift crash-log disk reads out of composition, announce message body to TalkBack, add scroll-to-bottom FAB, disable send while generating, friendlier session ids, confirm-before-clear crash reports, conditional title ellipsis, user-bubble timestamps, URL validation, design-token gap, Android 12+ splash screen
f34974ciko
app/src/main/java/soy/iko/opencode/CrashLogger.kt+14
@@ -2,6 +2,9 @@ package soy.iko.opencode
2
2
3import android.content.Context
3import android.content.Context
4import android.util.Log
4import android.util.Log
5+import kotlinx.coroutines.flow.MutableStateFlow
6+import kotlinx.coroutines.flow.StateFlow
7+import kotlinx.coroutines.flow.asStateFlow
5import java.io.File
8import java.io.File
6import java.io.PrintWriter
9import java.io.PrintWriter
7import java.io.StringWriter
10import java.io.StringWriter
@@ -27,8 +30,13 @@ object CrashLogger {
27 private lateinit var appContext: Context
30 private lateinit var appContext: Context
28 private var previousHandler: Thread.UncaughtExceptionHandler? = null
31 private var previousHandler: Thread.UncaughtExceptionHandler? = null
29
32
33+ private val _reportCount = MutableStateFlow(0)
34+ /** Live count of persisted crash reports, safe to observe in composition. */
35+ val reportCount: StateFlow<Int> = _reportCount.asStateFlow()
36+
30 fun init(context: Context) {
37 fun init(context: Context) {
31 appContext = context.applicationContext
38 appContext = context.applicationContext
39+ refreshReportCount()
32 previousHandler = Thread.getDefaultUncaughtExceptionHandler()
40 previousHandler = Thread.getDefaultUncaughtExceptionHandler()
33 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
41 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
34 report(throwable, thread.name)
42 report(throwable, thread.name)
@@ -36,6 +44,10 @@ object CrashLogger {
36 }
44 }
37 }
45 }
38
46
47+ private fun refreshReportCount() {
48+ _reportCount.value = getReports().size
49+ }
50+
39 /**
51 /**
40 * Persist a crash report and log it. Safe to call from any thread,
52 * Persist a crash report and log it. Safe to call from any thread,
41 * including the crashing thread.
53 * including the crashing thread.
@@ -57,6 +69,7 @@ object CrashLogger {
57 */
69 */
58 fun clearReports() {
70 fun clearReports() {
59 crashDir().listFiles()?.forEach { it.delete() }
71 crashDir().listFiles()?.forEach { it.delete() }
72+ refreshReportCount()
60 }
73 }
61
74
62 private fun persistReport(source: String, stackTrace: String) {
75 private fun persistReport(source: String, stackTrace: String) {
@@ -65,6 +78,7 @@ object CrashLogger {
65 val file = File(crashDir(), "crash_${timestamp}.txt")
78 val file = File(crashDir(), "crash_${timestamp}.txt")
66 file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
79 file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
67 pruneOldReports()
80 pruneOldReports()
81+ refreshReportCount()
68 } catch (e: Exception) {
82 } catch (e: Exception) {
69 Log.e(TAG, "Failed to persist crash report", e)
83 Log.e(TAG, "Failed to persist crash report", e)
70 }
84 }
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+87−22
@@ -13,6 +13,10 @@ import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.Text
13import androidx.compose.material3.Text
14import androidx.compose.material3.TextButton
14import androidx.compose.material3.TextButton
15import androidx.compose.runtime.Composable
15import androidx.compose.runtime.Composable
16+import androidx.compose.runtime.getValue
17+import androidx.compose.runtime.mutableStateOf
18+import androidx.compose.runtime.remember
19+import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
17import androidx.compose.ui.tooling.preview.Preview
21import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
22import androidx.compose.ui.unit.dp
@@ -25,6 +29,14 @@ import java.io.File
25 * (header) and the full stack trace (body). Height wraps content but is
29 * (header) and the full stack trace (body). Height wraps content but is
26 * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
30 * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
27 * scroll internally without consuming the whole screen.
31 * scroll internally without consuming the whole screen.
32+ *
33+ * Crash report file contents are read once via [remember] (off the first
34+ * recomposition) rather than on every recomposition — [File.readText] is disk
35+ * I/O and shouldn't run in composition repeatedly.
36+ *
37+ * "Clear all" is a destructive action: it's placed in the dismiss slot (left,
38+ * neutral) and tinted with the error color, and a confirmation step guards
39+ * against accidental taps. "Close" is the affirmative (right) action.
28 */
40 */
29@Composable
41@Composable
30fun CrashLogDialog(
42fun CrashLogDialog(
@@ -35,42 +47,95 @@ fun CrashLogDialog(
35 title: String = "Crash Reports",
47 title: String = "Crash Reports",
36 clearLabel: String = "Clear all",
48 clearLabel: String = "Clear all",
37 closeLabel: String = "Close",
49 closeLabel: String = "Close",
50+ clearConfirmLabel: String = "Clear all crash reports?",
51+ clearConfirmYesLabel: String = "Clear",
38) {
52) {
53+ // Read each report's text once, not on every recomposition.
54+ val contents = remember(reports) {
55+ reports.map { it.name to it.readText() }
56+ }
57+ var confirmingClear by remember { mutableStateOf(false) }
58+
39 AlertDialog(
59 AlertDialog(
40− onDismissRequest = onDismiss,
60+ onDismissRequest = {
61+ if (confirmingClear) confirmingClear = false else onDismiss()
62+ },
41 modifier = modifier,
63 modifier = modifier,
42 title = { Text(title) },
64 title = { Text(title) },
43 text = {
65 text = {
66+ if (confirmingClear) {
67+ Text(
68+ text = clearConfirmLabel,
69+ style = MaterialTheme.typography.bodyMedium,
70+ )
71+ } else {
44− Column(
72+ Column(
45− modifier = Modifier
73+ modifier = Modifier
46− .fillMaxWidth()
74+ .fillMaxWidth()
47− .heightIn(max = 360.dp)
75+ .heightIn(max = 360.dp)
48− .verticalScroll(rememberScrollState()),
76+ .verticalScroll(rememberScrollState()),
49− ) {
77+ ) {
50− reports.forEachIndexed { index, file ->
78+ contents.forEachIndexed { index, (name, text) ->
51− Text(
79+ Text(
52− text = file.name,
80+ text = name,
53− style = MaterialTheme.typography.labelMedium,
81+ style = MaterialTheme.typography.labelMedium,
54− color = MaterialTheme.colorScheme.primary,
82+ color = MaterialTheme.colorScheme.primary,
55− )
83+ )
56− Spacer(Modifier.height(Dimens.spaceTiny))
84+ Spacer(Modifier.height(Dimens.spaceTiny))
57− Text(
85+ Text(
58− text = file.readText(),
86+ text = text,
59− style = MaterialTheme.typography.bodySmall,
87+ style = MaterialTheme.typography.bodySmall,
60− )
88+ )
61− if (index < reports.lastIndex) {
89+ if (index < contents.lastIndex) {
62− Spacer(Modifier.height(Dimens.spaceMedium))
90+ Spacer(Modifier.height(Dimens.spaceMedium))
91+ }
63 }
92 }
64 }
93 }
65 }
94 }
66 },
95 },
67 confirmButton = {
96 confirmButton = {
97+ // Primary (right) button: "Close" is the non-destructive action.
68− TextButton(onClick = onClear) { Text(clearLabel) }
98+ TextButton(onClick = onDismiss) { Text(closeLabel) }
69 },
99 },
70 dismissButton = {
100 dismissButton = {
101+ if (confirmingClear) {
102+ // Confirm step: the affirmative "Clear" sits on the right now,
103+ // so expose a "Cancel" on the left to abort.
71− TextButton(onClick = onDismiss) { Text(closeLabel) }
104+ TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
105+ } else {
106+ // Destructive action in the dismiss slot (left), tinted error.
107+ TextButton(onClick = { confirmingClear = true }) {
108+ Text(
109+ text = clearLabel,
110+ color = MaterialTheme.colorScheme.error,
111+ )
112+ }
113+ }
72 },
114 },
73 )
115 )
116+
117+ // Second-stage confirmation dialog: actually clear when the user confirms.
118+ if (confirmingClear) {
119+ AlertDialog(
120+ onDismissRequest = { confirmingClear = false },
121+ title = { Text(title) },
122+ text = { Text(clearConfirmLabel) },
123+ confirmButton = {
124+ TextButton(onClick = {
125+ onClear()
126+ confirmingClear = false
127+ }) {
128+ Text(
129+ text = clearConfirmYesLabel,
130+ color = MaterialTheme.colorScheme.error,
131+ )
132+ }
133+ },
134+ dismissButton = {
135+ TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
136+ },
137+ )
138+ }
74}
139}
75
140
76@Preview(showBackground = true)
141@Preview(showBackground = true)
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+36−2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import androidx.compose.foundation.layout.Column
3import androidx.compose.material3.MaterialTheme
4import androidx.compose.material3.MaterialTheme
4import androidx.compose.material3.Snackbar
5import androidx.compose.material3.Snackbar
5import androidx.compose.material3.SnackbarDuration
6import androidx.compose.material3.SnackbarDuration
@@ -60,14 +61,19 @@ fun ErrorHost(
60 * Collects transient success/info messages from [Core.info] and surfaces them
61 * Collects transient success/info messages from [Core.info] and surfaces them
61 * as a short snackbar. This gives the user positive feedback (e.g. "Session
62 * as a short snackbar. This gives the user positive feedback (e.g. "Session
62 * created") instead of only ever showing errors.
63 * created") instead of only ever showing errors.
64+ *
65+ * Uses a dedicated [SnackbarHostState] (separate from the error host) so the
66+ * info snackbar can be rendered with default colors rather than the error
67+ * palette. The screens render both hosts in the Scaffold's `snackbarHost`
68+ * slot; typically only one fires at a time.
63 */
69 */
64@Composable
70@Composable
65fun InfoHost(
71fun InfoHost(
66 core: Core,
72 core: Core,
67 successConnectedLabel: String,
73 successConnectedLabel: String,
68 successSessionCreatedLabel: String,
74 successSessionCreatedLabel: String,
69− snackbarHostState: SnackbarHostState,
75+): SnackbarHostState {
70−) {
76+ val snackbarHostState = remember { SnackbarHostState() }
71 LaunchedEffect(snackbarHostState) {
77 LaunchedEffect(snackbarHostState) {
72 core.info.collect { key ->
78 core.info.collect { key ->
73 val message = when (key) {
79 val message = when (key) {
@@ -81,6 +87,7 @@ fun InfoHost(
81 )
87 )
82 }
88 }
83 }
89 }
90+ return snackbarHostState
84}
91}
85
92
86@Composable
93@Composable
@@ -94,3 +101,30 @@ fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
94 )
101 )
95 }
102 }
96}
103}
104+
105+/**
106+ * Snackbar host for success/info messages. Uses the default (surface-tonal)
107+ * colors so positive feedback doesn't read as an error.
108+ */
109+@Composable
110+fun InfoSnackbarHost(snackbarHostState: SnackbarHostState) {
111+ SnackbarHost(snackbarHostState) { data ->
112+ Snackbar(snackbarData = data)
113+ }
114+}
115+
116+/**
117+ * Renders both the error and info snackbar hosts stacked vertically. The
118+ * Material 3 [Scaffold] only accepts a single `snackbarHost` slot, so screens
119+ * pass this helper to show both flavors without one overwriting the other.
120+ */
121+@Composable
122+fun DualSnackbarHost(
123+ errorHostState: SnackbarHostState,
124+ infoHostState: SnackbarHostState,
125+) {
126+ Column {
127+ ErrorSnackbarHost(errorHostState)
128+ InfoSnackbarHost(infoHostState)
129+ }
130+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+33−2
@@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
14import androidx.compose.material3.Surface
14import androidx.compose.material3.Surface
15import androidx.compose.material3.Text
15import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
16import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
18import androidx.compose.ui.platform.LocalDensity
19import androidx.compose.ui.platform.LocalDensity
19import androidx.compose.ui.platform.LocalWindowInfo
20import androidx.compose.ui.platform.LocalWindowInfo
@@ -59,11 +60,25 @@ fun MessageBubble(
59 } else {
60 } else {
60 stringResource(R.string.chat_role_assistant)
61 stringResource(R.string.chat_role_assistant)
61 }
62 }
63+ val timeLabel = remember(message.time) { formatTimestamp(message.time) }
64+ // Build a single TalkBack utterance that includes the role and the
65+ // message body. Setting an explicit contentDescription on a merge node
66+ // replaces descendant text, so we must include the body ourselves rather
67+ // than letting it merge — otherwise TalkBack reads only "User".
68+ val announcement = buildString {
69+ append(roleLabel)
70+ if (message.time > 0uL) {
71+ append(", ")
72+ append(timeLabel)
73+ }
74+ append(". ")
75+ append(if (message.text.isBlank()) stringResource(R.string.chat_no_content) else message.text)
76+ }
62 Row(
77 Row(
63 modifier = modifier
78 modifier = modifier
64 .fillMaxWidth()
79 .fillMaxWidth()
65 .semantics(mergeDescendants = true) {
80 .semantics(mergeDescendants = true) {
66− contentDescription = roleLabel
81+ contentDescription = announcement
67 },
82 },
68 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
83 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
69 ) {
84 ) {
@@ -100,13 +115,29 @@ fun MessageBubble(
100 )
115 )
101 if (message.time > 0uL) {
116 if (message.time > 0uL) {
102 Text(
117 Text(
103− text = formatTimestamp(message.time),
118+ text = timeLabel,
104 style = MaterialTheme.typography.labelSmall,
119 style = MaterialTheme.typography.labelSmall,
105 color = MaterialTheme.colorScheme.onSurfaceVariant,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
106 )
121 )
107 }
122 }
108 }
123 }
109 Spacer(Modifier.height(Dimens.spaceTiny))
124 Spacer(Modifier.height(Dimens.spaceTiny))
125+ } else {
126+ // Show a timestamp on user bubbles too, aligned to the
127+ // end so it reads as the message's send time.
128+ if (message.time > 0uL) {
129+ Row(
130+ modifier = Modifier.fillMaxWidth(),
131+ horizontalArrangement = Arrangement.End,
132+ ) {
133+ Text(
134+ text = timeLabel,
135+ style = MaterialTheme.typography.labelSmall,
136+ color = MaterialTheme.colorScheme.onPrimary,
137+ )
138+ }
139+ Spacer(Modifier.height(Dimens.spaceTiny))
140+ }
110 }
141 }
111 if (message.text.isBlank()) {
142 if (message.text.isBlank()) {
112 Text(
143 Text(
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+80−15
@@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.rememberLazyListState
13import androidx.compose.foundation.lazy.rememberLazyListState
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+import androidx.compose.material.icons.filled.KeyboardArrowDown
15import androidx.compose.material3.ExperimentalMaterial3Api
17import androidx.compose.material3.ExperimentalMaterial3Api
18+import androidx.compose.material3.FloatingActionButton
16import androidx.compose.material3.Icon
19import androidx.compose.material3.Icon
17import androidx.compose.material3.IconButton
20import androidx.compose.material3.IconButton
18import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.MaterialTheme
@@ -24,9 +27,11 @@ import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.collectAsState
27import androidx.compose.runtime.collectAsState
25import androidx.compose.runtime.derivedStateOf
28import androidx.compose.runtime.derivedStateOf
26import androidx.compose.runtime.getValue
29import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableIntStateOf
27import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
32import androidx.compose.runtime.remember
29import androidx.compose.runtime.setValue
33import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
31import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
32import androidx.compose.ui.text.style.TextOverflow
37import androidx.compose.ui.text.style.TextOverflow
@@ -38,9 +43,9 @@ import soy.iko.opencode.core.Event
38import soy.iko.opencode.core.MessageView
43import soy.iko.opencode.core.MessageView
39import soy.iko.opencode.core.SseState
44import soy.iko.opencode.core.SseState
40import soy.iko.opencode.ui.components.ChatInputBar
45import soy.iko.opencode.ui.components.ChatInputBar
46+import soy.iko.opencode.ui.components.DualSnackbarHost
41import soy.iko.opencode.ui.components.EmptyState
47import soy.iko.opencode.ui.components.EmptyState
42import soy.iko.opencode.ui.components.ErrorHost
48import soy.iko.opencode.ui.components.ErrorHost
43−import soy.iko.opencode.ui.components.ErrorSnackbarHost
44import soy.iko.opencode.ui.components.GeneratingIndicator
49import soy.iko.opencode.ui.components.GeneratingIndicator
45import soy.iko.opencode.ui.components.InfoHost
50import soy.iko.opencode.ui.components.InfoHost
46import soy.iko.opencode.ui.components.LoadingPlaceholder
51import soy.iko.opencode.ui.components.LoadingPlaceholder
@@ -56,6 +61,7 @@ fun ChatScreen(core: Core) {
56 val sseState by core.sseState.collectAsState()
61 val sseState by core.sseState.collectAsState()
57 var inputText by remember { mutableStateOf("") }
62 var inputText by remember { mutableStateOf("") }
58 val listState = rememberLazyListState()
63 val listState = rememberLazyListState()
64+ var scrollRequest by remember { mutableIntStateOf(0) }
59
65
60 // Track whether the user is parked at the bottom of the list. We only
66 // Track whether the user is parked at the bottom of the list. We only
61 // auto-scroll when they are, so reading history isn't yanked away as
67 // auto-scroll when they are, so reading history isn't yanked away as
@@ -73,37 +79,66 @@ fun ChatScreen(core: Core) {
73 }
79 }
74 }
80 }
75
81
82+ // Scroll-to-bottom when the user taps the FAB. Keyed on a counter so
83+ // repeated taps keep firing.
84+ LaunchedEffect(scrollRequest) {
85+ if (scrollRequest > 0 && view.messages.isNotEmpty()) {
86+ // Index of the trailing __generating__ item (it's last in the
87+ // list, at position == messages.size). Falling back to the last
88+ // message is safe even if the generating item is invisible.
89+ listState.animateScrollToItem(view.messages.size)
90+ }
91+ }
92+
76− val snackbarHostState = ErrorHost(
93+ val errorHostState = ErrorHost(
77 core = core,
94 core = core,
78 dismissLabel = stringResource(R.string.action_dismiss),
95 dismissLabel = stringResource(R.string.action_dismiss),
79 retryLabel = stringResource(R.string.action_retry),
96 retryLabel = stringResource(R.string.action_retry),
80 )
97 )
81− InfoHost(
98+ val infoHostState = InfoHost(
82 core = core,
99 core = core,
83 successConnectedLabel = stringResource(R.string.connect_success),
100 successConnectedLabel = stringResource(R.string.connect_success),
84 successSessionCreatedLabel = stringResource(R.string.session_created),
101 successSessionCreatedLabel = stringResource(R.string.session_created),
85− snackbarHostState = snackbarHostState,
86 )
102 )
87
103
88 val showSseBanner = sseState is SseState.Error ||
104 val showSseBanner = sseState is SseState.Error ||
89 (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
105 (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
90
106
107+ // Resolve nullable session fields into locals once per recomposition so we
108+ // never smart-cast a delegate-backed collected property across calls.
109+ val sessionId = view.currentSessionId
110+ val sessionTitle = view.currentSessionTitle
111+ val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112+ val idShortTemplate = stringResource(R.string.chat_session_id_short)
113+ val title = remember(sessionId, sessionTitle) {
114+ when {
115+ sessionTitle.isNotEmpty() -> sessionTitle
116+ sessionId != null -> {
117+ val prefix = sessionId.take(8)
118+ if (sessionId.length > prefix.length) "$prefix…" else prefix
119+ }
120+ else -> chatFallback
121+ }
122+ }
123+ val subtitle = remember(sessionId, sessionTitle) {
124+ if (sessionTitle.isNotEmpty() && sessionId != null) {
125+ idShortTemplate.format(sessionId.take(8))
126+ } else null
127+ }
128+
91 Scaffold(
129 Scaffold(
92 topBar = {
130 topBar = {
93 TopAppBar(
131 TopAppBar(
94 title = {
132 title = {
95 Column {
133 Column {
96 Text(
134 Text(
97− text = view.currentSessionTitle.ifEmpty {
135+ text = title,
98− view.currentSessionId?.take(8)?.plus("…")
99− ?: stringResource(R.string.top_bar_chat_fallback)
100− },
101 maxLines = 1,
136 maxLines = 1,
102 overflow = TextOverflow.Ellipsis,
137 overflow = TextOverflow.Ellipsis,
103 )
138 )
104− if (view.currentSessionTitle.isNotEmpty() && view.currentSessionId != null) {
139+ if (subtitle != null) {
105 Text(
140 Text(
106− text = view.currentSessionId!!.take(8),
141+ text = subtitle,
107 style = MaterialTheme.typography.labelSmall,
142 style = MaterialTheme.typography.labelSmall,
108 color = MaterialTheme.colorScheme.onSurfaceVariant,
143 color = MaterialTheme.colorScheme.onSurfaceVariant,
109 maxLines = 1,
144 maxLines = 1,
@@ -131,12 +166,14 @@ fun ChatScreen(core: Core) {
131 inputText = ""
166 inputText = ""
132 }
167 }
133 },
168 },
169+ // Disable while loading OR while the assistant is generating,
170+ // so users can't fire a second message mid-reply.
134− enabled = !view.loading,
171+ enabled = !view.loading && !view.generating,
135 placeholder = stringResource(R.string.chat_input_placeholder),
172 placeholder = stringResource(R.string.chat_input_placeholder),
136 sendContentDescription = stringResource(R.string.chat_cd_send),
173 sendContentDescription = stringResource(R.string.chat_cd_send),
137 )
174 )
138 },
175 },
139− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
176+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
140 ) { padding ->
177 ) { padding ->
141 Box(
178 Box(
142 modifier = Modifier
179 modifier = Modifier
@@ -184,6 +221,28 @@ fun ChatScreen(core: Core) {
184 }
221 }
185 }
222 }
186 }
223 }
224+
225+ // Scroll-to-bottom FAB: only when the user has scrolled up away
226+ // from the latest message and there's content to scroll to.
227+ if (view.messages.isNotEmpty() && !isAtBottom) {
228+ FloatingActionButton(
229+ onClick = { scrollRequest++ },
230+ modifier = Modifier
231+ .align(Alignment.BottomEnd)
232+ .padding(
233+ end = Dimens.fabEndMargin,
234+ 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+ }
245+ }
187 }
246 }
188 }
247 }
189}
248}
@@ -242,14 +301,20 @@ private fun PreviewChatScreen(
242 messages: List<MessageView>,
301 messages: List<MessageView>,
243 generating: Boolean,
302 generating: Boolean,
244) {
303) {
304+ val resolvedTitle = when {
305+ title.isNotEmpty() -> title
306+ sessionId != null -> {
307+ val prefix = sessionId.take(8)
308+ if (sessionId.length > prefix.length) "$prefix…" else prefix
309+ }
310+ else -> "Chat"
311+ }
245 Scaffold(
312 Scaffold(
246 topBar = {
313 topBar = {
247 TopAppBar(
314 TopAppBar(
248 title = {
315 title = {
249 Text(
316 Text(
250− text = title.ifEmpty {
317+ text = resolvedTitle,
251− sessionId?.take(8)?.plus("…") ?: "Chat"
252− },
253 maxLines = 1,
318 maxLines = 1,
254 overflow = TextOverflow.Ellipsis,
319 overflow = TextOverflow.Ellipsis,
255 )
320 )
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+33−7
@@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton
28import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.OutlinedTextField
30import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Scaffold
31+import androidx.compose.material3.SnackbarHostState
31import androidx.compose.material3.Text
32import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
33import androidx.compose.material3.TextButton
33import androidx.compose.material3.TopAppBar
34import androidx.compose.material3.TopAppBar
@@ -55,30 +56,33 @@ import soy.iko.opencode.R
55import soy.iko.opencode.core.Core
56import soy.iko.opencode.core.Core
56import soy.iko.opencode.core.Event
57import soy.iko.opencode.core.Event
57import soy.iko.opencode.ui.components.CrashLogDialog
58import soy.iko.opencode.ui.components.CrashLogDialog
59+import soy.iko.opencode.ui.components.DualSnackbarHost
58import soy.iko.opencode.ui.components.ErrorHost
60import soy.iko.opencode.ui.components.ErrorHost
59import soy.iko.opencode.ui.components.ErrorSnackbarHost
61import soy.iko.opencode.ui.components.ErrorSnackbarHost
60import soy.iko.opencode.ui.components.InfoHost
62import soy.iko.opencode.ui.components.InfoHost
63+import soy.iko.opencode.ui.components.InfoSnackbarHost
61import soy.iko.opencode.ui.theme.Dimens
64import soy.iko.opencode.ui.theme.Dimens
62
65
63@OptIn(ExperimentalMaterial3Api::class)
66@OptIn(ExperimentalMaterial3Api::class)
64@Composable
67@Composable
65fun ConnectScreen(core: Core) {
68fun ConnectScreen(core: Core) {
66 val view by core.view.collectAsState()
69 val view by core.view.collectAsState()
70+ val crashCount by CrashLogger.reportCount.collectAsState()
67 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
71 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
68 var username by remember(view.username) { mutableStateOf(view.username) }
72 var username by remember(view.username) { mutableStateOf(view.username) }
69 var password by remember(view.password) { mutableStateOf(view.password) }
73 var password by remember(view.password) { mutableStateOf(view.password) }
70 var passwordVisible by remember { mutableStateOf(false) }
74 var passwordVisible by remember { mutableStateOf(false) }
71 var showCrashLogs by remember { mutableStateOf(false) }
75 var showCrashLogs by remember { mutableStateOf(false) }
76+ var urlError by remember { mutableStateOf(false) }
72− val snackbarHostState = ErrorHost(
77+ val errorHostState = ErrorHost(
73 core = core,
78 core = core,
74 dismissLabel = stringResource(R.string.action_dismiss),
79 dismissLabel = stringResource(R.string.action_dismiss),
75 retryLabel = stringResource(R.string.action_retry),
80 retryLabel = stringResource(R.string.action_retry),
76 )
81 )
77− InfoHost(
82+ val infoHostState = InfoHost(
78 core = core,
83 core = core,
79 successConnectedLabel = stringResource(R.string.connect_success),
84 successConnectedLabel = stringResource(R.string.connect_success),
80 successSessionCreatedLabel = stringResource(R.string.session_created),
85 successSessionCreatedLabel = stringResource(R.string.session_created),
81− snackbarHostState = snackbarHostState,
82 )
86 )
83
87
84 // Auto-focus the username field when the server reveals it requires
88 // Auto-focus the username field when the server reveals it requires
@@ -90,7 +94,19 @@ fun ConnectScreen(core: Core) {
90 }
94 }
91 }
95 }
92
96
97+ fun isValidUrl(value: String): Boolean {
98+ val trimmed = value.trim()
99+ if (trimmed.isEmpty()) return false
100+ return trimmed.startsWith("http://", ignoreCase = true) ||
101+ trimmed.startsWith("https://", ignoreCase = true)
102+ }
103+
93 fun submit() {
104 fun submit() {
105+ if (!isValidUrl(url)) {
106+ urlError = true
107+ return
108+ }
109+ urlError = false
94 core.update(Event.ServerUrlChanged(url))
110 core.update(Event.ServerUrlChanged(url))
95 if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
111 if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
96 core.update(Event.UsernameChanged(username))
112 core.update(Event.UsernameChanged(username))
@@ -103,7 +119,9 @@ fun ConnectScreen(core: Core) {
103 topBar = {
119 topBar = {
104 TopAppBar(title = { Text(stringResource(R.string.top_bar_connect)) })
120 TopAppBar(title = { Text(stringResource(R.string.top_bar_connect)) })
105 },
121 },
106− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
122+ snackbarHost = {
123+ DualSnackbarHost(errorHostState, infoHostState)
124+ },
107 ) { padding ->
125 ) { padding ->
108 Column(
126 Column(
109 horizontalAlignment = Alignment.CenterHorizontally,
127 horizontalAlignment = Alignment.CenterHorizontally,
@@ -128,9 +146,16 @@ fun ConnectScreen(core: Core) {
128 Spacer(Modifier.height(Dimens.spaceXLarge))
146 Spacer(Modifier.height(Dimens.spaceXLarge))
129 OutlinedTextField(
147 OutlinedTextField(
130 value = url,
148 value = url,
131− onValueChange = { url = it },
149+ onValueChange = {
150+ url = it
151+ if (urlError) urlError = false
152+ },
132 label = { Text(stringResource(R.string.connect_field_server_url)) },
153 label = { Text(stringResource(R.string.connect_field_server_url)) },
133 singleLine = true,
154 singleLine = true,
155+ isError = urlError,
156+ supportingText = if (urlError) {
157+ { Text(stringResource(R.string.connect_error_invalid_url)) }
158+ } else null,
134 keyboardOptions = KeyboardOptions(
159 keyboardOptions = KeyboardOptions(
135 keyboardType = KeyboardType.Uri,
160 keyboardType = KeyboardType.Uri,
136 imeAction = ImeAction.Go,
161 imeAction = ImeAction.Go,
@@ -224,7 +249,6 @@ fun ConnectScreen(core: Core) {
224 }
249 }
225 }
250 }
226
251
227− val crashCount = CrashLogger.getReports().size
228 if (crashCount > 0) {
252 if (crashCount > 0) {
229 Spacer(Modifier.height(Dimens.spaceLarge))
253 Spacer(Modifier.height(Dimens.spaceLarge))
230 Row(verticalAlignment = Alignment.CenterVertically) {
254 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -234,7 +258,7 @@ fun ConnectScreen(core: Core) {
234 tint = MaterialTheme.colorScheme.error,
258 tint = MaterialTheme.colorScheme.error,
235 modifier = Modifier.size(Dimens.iconInputLeading),
259 modifier = Modifier.size(Dimens.iconInputLeading),
236 )
260 )
237− Spacer(Modifier.size(6.dp))
261+ Spacer(Modifier.size(Dimens.gapTiny))
238 TextButton(onClick = { showCrashLogs = true }) {
262 TextButton(onClick = { showCrashLogs = true }) {
239 Text(
263 Text(
240 text = pluralStringResource(R.plurals.crash_reports_view, crashCount, crashCount),
264 text = pluralStringResource(R.plurals.crash_reports_view, crashCount, crashCount),
@@ -257,6 +281,8 @@ fun ConnectScreen(core: Core) {
257 title = stringResource(R.string.crash_reports_title),
281 title = stringResource(R.string.crash_reports_title),
258 clearLabel = stringResource(R.string.crash_reports_clear),
282 clearLabel = stringResource(R.string.crash_reports_clear),
259 closeLabel = stringResource(R.string.crash_reports_close),
283 closeLabel = stringResource(R.string.crash_reports_close),
284+ clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
285+ clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
260 )
286 )
261 }
287 }
262}
288}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+16−21
@@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.automirrored.filled.ArrowBack
10import androidx.compose.material.icons.automirrored.filled.ArrowBack
11import androidx.compose.material.icons.filled.Add
11import androidx.compose.material.icons.filled.Add
12import androidx.compose.material.icons.filled.ChatBubble
12import androidx.compose.material.icons.filled.ChatBubble
13−import androidx.compose.material.icons.automirrored.filled.Logout
14import androidx.compose.material3.ExperimentalMaterial3Api
13import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.Icon
14import androidx.compose.material3.Icon
16import androidx.compose.material3.IconButton
15import androidx.compose.material3.IconButton
@@ -35,9 +34,9 @@ import soy.iko.opencode.R
35import soy.iko.opencode.core.Core
34import soy.iko.opencode.core.Core
36import soy.iko.opencode.core.Event
35import soy.iko.opencode.core.Event
37import soy.iko.opencode.core.SessionView
36import soy.iko.opencode.core.SessionView
37+import soy.iko.opencode.ui.components.DualSnackbarHost
38import soy.iko.opencode.ui.components.EmptyState
38import soy.iko.opencode.ui.components.EmptyState
39import soy.iko.opencode.ui.components.ErrorHost
39import soy.iko.opencode.ui.components.ErrorHost
40−import soy.iko.opencode.ui.components.ErrorSnackbarHost
41import soy.iko.opencode.ui.components.InfoHost
40import soy.iko.opencode.ui.components.InfoHost
42import soy.iko.opencode.ui.components.LoadingPlaceholder
41import soy.iko.opencode.ui.components.LoadingPlaceholder
43import soy.iko.opencode.ui.theme.Dimens
42import soy.iko.opencode.ui.theme.Dimens
@@ -47,17 +46,18 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
47@Composable
46@Composable
48fun SessionsScreen(core: Core) {
47fun SessionsScreen(core: Core) {
49 val view by core.view.collectAsState()
48 val view by core.view.collectAsState()
50− val snackbarHostState = ErrorHost(
49+ val errorHostState = ErrorHost(
51 core = core,
50 core = core,
52 dismissLabel = stringResource(R.string.action_dismiss),
51 dismissLabel = stringResource(R.string.action_dismiss),
53 retryLabel = stringResource(R.string.action_retry),
52 retryLabel = stringResource(R.string.action_retry),
54 )
53 )
55− InfoHost(
54+ val infoHostState = InfoHost(
56 core = core,
55 core = core,
57 successConnectedLabel = stringResource(R.string.connect_success),
56 successConnectedLabel = stringResource(R.string.connect_success),
58 successSessionCreatedLabel = stringResource(R.string.session_created),
57 successSessionCreatedLabel = stringResource(R.string.session_created),
59− snackbarHostState = snackbarHostState,
60 )
58 )
59+ val untitledLabel = stringResource(R.string.sessions_untitled)
60+ val idShortTemplate = stringResource(R.string.sessions_id_short)
61
61
62 Scaffold(
62 Scaffold(
63 topBar = {
63 topBar = {
@@ -78,19 +78,10 @@ fun SessionsScreen(core: Core) {
78 contentDescription = stringResource(R.string.sessions_cd_new),
78 contentDescription = stringResource(R.string.sessions_cd_new),
79 )
79 )
80 }
80 }
81− // Disconnect: returns to the Connect screen to switch
82− // servers / credentials. Labeled and iconed as logout so
83− // users understand it ends the session, not "settings".
84− IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
85− Icon(
86− Icons.AutoMirrored.Filled.Logout,
87− contentDescription = stringResource(R.string.sessions_cd_disconnect),
88− )
89− }
90 },
81 },
91 )
82 )
92 },
83 },
93− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
84+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
94 ) { padding ->
85 ) { padding ->
95 Box(
86 Box(
96 modifier = Modifier
87 modifier = Modifier
@@ -114,7 +105,8 @@ fun SessionsScreen(core: Core) {
114 items(view.sessions, key = { it.id }) { session ->
105 items(view.sessions, key = { it.id }) { session ->
115 SessionRow(
106 SessionRow(
116 session = session,
107 session = session,
117− untitledLabel = stringResource(R.string.sessions_untitled),
108+ untitledLabel = untitledLabel,
109+ idShortLabel = idShortTemplate.format(session.id.take(8)),
118 onClick = { core.update(Event.SelectSession(session.id)) },
110 onClick = { core.update(Event.SelectSession(session.id)) },
119 )
111 )
120 }
112 }
@@ -130,12 +122,13 @@ fun SessionsScreen(core: Core) {
130private fun SessionRow(
122private fun SessionRow(
131 session: SessionView,
123 session: SessionView,
132 untitledLabel: String,
124 untitledLabel: String,
125+ idShortLabel: String,
133 onClick: () -> Unit,
126 onClick: () -> Unit,
134) {
127) {
135 val title = session.title.ifEmpty { untitledLabel }
128 val title = session.title.ifEmpty { untitledLabel }
136 ListItem(
129 ListItem(
137 headlineContent = { Text(title) },
130 headlineContent = { Text(title) },
138− supportingContent = { Text(session.id.take(8)) },
131+ supportingContent = { Text(idShortLabel) },
139 leadingContent = {
132 leadingContent = {
140 Icon(
133 Icon(
141 Icons.Default.ChatBubble,
134 Icons.Default.ChatBubble,
@@ -172,9 +165,6 @@ private fun SessionsScreenPreview() {
172 IconButton(onClick = {}) {
165 IconButton(onClick = {}) {
173 Icon(Icons.Default.Add, contentDescription = "New session")
166 Icon(Icons.Default.Add, contentDescription = "New session")
174 }
167 }
175− IconButton(onClick = {}) {
176− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
177− }
178 },
168 },
179 )
169 )
180 }) { padding ->
170 }) { padding ->
@@ -187,7 +177,12 @@ private fun SessionsScreenPreview() {
187 ),
177 ),
188 key = { it.id },
178 key = { it.id },
189 ) { session ->
179 ) { session ->
190− SessionRow(session, "Untitled", onClick = {})
180+ SessionRow(
181+ session,
182+ untitledLabel = "Untitled",
183+ idShortLabel = "Session ${session.id.take(8)}",
184+ onClick = {},
185+ )
191 }
186 }
192 }
187 }
193 }
188 }
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt+8
@@ -23,6 +23,9 @@ object Dimens {
23 val spaceLarge = 16.dp
23 val spaceLarge = 16.dp
24 val spaceXLarge = 24.dp
24 val spaceXLarge = 24.dp
25
25
26+ // Inline gaps (not large enough for a named "space" token but still shared)
27+ val gapTiny = 6.dp
28+
26 // Bubble internals
29 // Bubble internals
27 val bubbleHorizontalPadding = 14.dp
30 val bubbleHorizontalPadding = 14.dp
28 val bubbleVerticalPadding = 10.dp
31 val bubbleVerticalPadding = 10.dp
@@ -42,4 +45,9 @@ object Dimens {
42
45
43 // Strokes
46 // Strokes
44 val strokeThin = 2.dp
47 val strokeThin = 2.dp
48+
49+ // Floating action button
50+ val fabSize = 40.dp
51+ val fabEndMargin = 16.dp
52+ val fabBottomMargin = 12.dp
45}
53}
app/src/main/res/values-night-v31/themes.xmlnew file+7
@@ -0,0 +1,7 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.Material.NoActionBar">
4+ <item name="android:windowSplashScreenBackground">@android:color/black</item>
5+ <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
6+ </style>
7+</resources>
app/src/main/res/values-v31/themes.xmlnew file+11
@@ -0,0 +1,11 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.Material.Light.NoActionBar">
4+ <!-- Android 12+ system splash screen: use the window background color
5+ so the post-splash crossfade into the Compose UI is seamless. The
6+ Compose UI paints its own background, so we only set the poster
7+ color and a system icon here. -->
8+ <item name="android:windowSplashScreenBackground">@android:color/white</item>
9+ <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
10+ </style>
11+</resources>
app/src/main/res/values/strings.xml+6−1
@@ -20,6 +20,7 @@
20 <string name="connect_cd_dns">Connection icon</string>
20 <string name="connect_cd_dns">Connection icon</string>
21 <string name="connect_cd_lock">Credentials icon</string>
21 <string name="connect_cd_lock">Credentials icon</string>
22 <string name="connect_cd_warning">Warning icon</string>
22 <string name="connect_cd_warning">Warning icon</string>
23+ <string name="connect_error_invalid_url">Enter a valid URL (http:// or https://)</string>
23
24
24 <!-- Crash log -->
25 <!-- Crash log -->
25 <string name="crash_reports_title">Crash Reports</string>
26 <string name="crash_reports_title">Crash Reports</string>
@@ -29,13 +30,15 @@
29 </plurals>
30 </plurals>
30 <string name="crash_reports_clear">Clear all</string>
31 <string name="crash_reports_clear">Clear all</string>
31 <string name="crash_reports_close">Close</string>
32 <string name="crash_reports_close">Close</string>
33+ <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
34+ <string name="crash_reports_clear_confirm_yes">Clear</string>
32
35
33 <!-- Sessions screen -->
36 <!-- Sessions screen -->
34 <string name="sessions_empty">No sessions yet</string>
37 <string name="sessions_empty">No sessions yet</string>
35 <string name="sessions_cd_back">Change server</string>
38 <string name="sessions_cd_back">Change server</string>
36 <string name="sessions_cd_new">New session</string>
39 <string name="sessions_cd_new">New session</string>
37− <string name="sessions_cd_disconnect">Disconnect</string>
38 <string name="sessions_untitled">Untitled</string>
40 <string name="sessions_untitled">Untitled</string>
41+ <string name="sessions_id_short">Session %s</string>
39 <string name="session_created">Session created</string>
42 <string name="session_created">Session created</string>
40
43
41 <!-- Chat screen -->
44 <!-- Chat screen -->
@@ -48,6 +51,8 @@
48 <string name="chat_role_user">User</string>
51 <string name="chat_role_user">User</string>
49 <string name="chat_role_assistant">Assistant</string>
52 <string name="chat_role_assistant">Assistant</string>
50 <string name="chat_cd_generating">Generating reply</string>
53 <string name="chat_cd_generating">Generating reply</string>
54+ <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
55+ <string name="chat_session_id_short">Session %s</string>
51
56
52 <!-- Error / snackbar -->
57 <!-- Error / snackbar -->
53 <string name="action_dismiss">Dismiss</string>
58 <string name="action_dismiss">Dismiss</string>