git.iko.soy/opencode-android
on master help sign in
Fix AMOLED theme preview indistinguishable from Dark, measure connection banner height instead of hardcoded 44dp, make crash report text selectable, and add visible Loading label to session list spinner
commit3a20f352ef289fdcc718e6d78c97f78d1387fe98 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 15:56 (3 months ago) parenta9e66cf
5 files changed+61−15
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+23−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+16−5 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+10−5 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−1 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+9
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+23−4
@@ -88,6 +88,7 @@ import androidx.compose.runtime.DisposableEffect
8888import androidx.compose.runtime.LaunchedEffect
8989import androidx.compose.runtime.derivedStateOf
9090import androidx.compose.runtime.getValue
91+import androidx.compose.runtime.mutableIntStateOf
9192import androidx.compose.runtime.mutableStateOf
9293import androidx.compose.runtime.remember
9394import androidx.compose.runtime.rememberCoroutineScope
@@ -108,7 +109,9 @@ import androidx.compose.ui.input.key.isShiftPressed
108109import androidx.compose.ui.input.key.key
109110import androidx.compose.ui.input.key.onPreviewKeyEvent
110111import androidx.compose.ui.input.key.type
112+import androidx.compose.ui.layout.onSizeChanged
111113import androidx.compose.ui.platform.LocalContext
114+import androidx.compose.ui.platform.LocalDensity
112115import androidx.compose.ui.platform.LocalHapticFeedback
113116import androidx.compose.ui.platform.LocalView
114117import androidx.compose.ui.platform.testTag
@@ -894,9 +897,27 @@ fun ChatScreen(
894897 }
895898 }
896899 } else {
900+ val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
901+ // Measure the banner's actual height instead of hardcoding 44dp. At large
902+ // accessibility font scales the banner's labelMedium text + spinner + Retry
903+ // button exceed 44dp, so a fixed inset lets the first message hide behind it
904+ // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
905+ // fires while the banner is composed (visible), so gate the inset on
906+ // bannerVisible and fall back to 44dp until the first measurement arrives.
907+ var bannerHeightPx by remember { mutableIntStateOf(0) }
908+ val density = LocalDensity.current
909+ val topPad = if (bannerVisible) {
910+ if (bannerHeightPx > 0) {
911+ with(density) { bannerHeightPx.toDp() } + 4.dp
912+ } else {
913+ 44.dp
914+ }
915+ } else {
916+ 16.dp
917+ }
897918 ConnectionBanner(
898919 state = connectionState,
899− modifier = Modifier.align(Alignment.TopCenter),
920+ modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
900921 isOnline = isOnline,
901922 // On a hard failure, retry by forcing an SSE reconnect (which
902923 // re-seeds from REST). refreshMessages is the right recovery path
@@ -918,9 +939,7 @@ fun ChatScreen(
918939 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
919940 ) {
920941 // Reserve top space when the banner is visible so it doesn't overlap the
921− // first message / state content.
922− val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
923− val topPad = if (bannerVisible) 44.dp else 16.dp
942+ // first message / state content. (topPad computed above.)
924943 if (loading && messages.isEmpty()) {
925944 val loadingLabel = stringResource(R.string.loading)
926945 // Attach the a11y label directly to the spinner (a detached zero-size Box
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+16−5
@@ -438,11 +438,22 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
438438 when {
439439 state.loading -> {
440440 val loadingLabel = stringResource(R.string.loading)
441− CircularProgressIndicator(
442− Modifier
443− .align(Alignment.Center)
444− .semantics { contentDescription = loadingLabel },
445− )
441+ // Match sibling screens (MCP/Usage/Chat) which show a visible "Loading…"
442+ // label beneath the spinner; a bare spinner is the weakest loading state
443+ // in the app and this is the primary screen.
444+ Column(
445+ modifier = Modifier.align(Alignment.Center),
446+ horizontalAlignment = Alignment.CenterHorizontally,
447+ ) {
448+ CircularProgressIndicator(
449+ Modifier.semantics { contentDescription = loadingLabel },
450+ )
451+ Spacer(Modifier.size(12.dp))
452+ Text(
453+ loadingLabel,
454+ color = MaterialTheme.colorScheme.onSurfaceVariant,
455+ )
456+ }
446457 }
447458 state.sessions.isEmpty() && state.error != null -> Column(
448459 modifier = Modifier.align(Alignment.Center).padding(24.dp),
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+10−5
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
1818import androidx.compose.foundation.lazy.LazyColumn
1919import androidx.compose.foundation.lazy.items
2020import androidx.compose.foundation.rememberScrollState
21+import androidx.compose.foundation.text.selection.SelectionContainer
2122import androidx.compose.foundation.verticalScroll
2223import androidx.compose.material.icons.Icons
2324import androidx.compose.material.icons.filled.BugReport
@@ -372,11 +373,15 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
372373 .heightIn(max = 400.dp)
373374 .verticalScroll(rememberScrollState()),
374375 ) {
375− Text(
376− content,
377− style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
378− modifier = Modifier.fillMaxWidth(),
379− )
376+ // Wrap in SelectionContainer so a user can select/copy just the
377+ // relevant stack snippet instead of the whole-report Copy button.
378+ SelectionContainer {
379+ Text(
380+ content,
381+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
382+ modifier = Modifier.fillMaxWidth(),
383+ )
384+ }
380385 }
381386 loadFailed -> Text(
382387 stringResource(R.string.report_load_failed),
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−1
@@ -87,6 +87,7 @@ import soy.iko.opencode.data.network.NetworkConfig
8787import soy.iko.opencode.ui.canAuthenticateForAppLock
8888import soy.iko.opencode.ui.theme.LightPaletteSwatches
8989import soy.iko.opencode.ui.theme.DarkPaletteSwatches
90+import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
9091import soy.iko.opencode.util.runCatchingCancellable
9192import kotlin.math.roundToInt
9293
@@ -716,7 +717,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
716717 }
717718 ThemeMode.LIGHT -> LightSwatches
718719 ThemeMode.DARK -> DarkSwatches
719− ThemeMode.AMOLED -> DarkSwatches
720+ ThemeMode.AMOLED -> AmoledSwatches
720721 }
721722 Row(
722723 modifier = Modifier
@@ -766,6 +767,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
766767// the character of each scheme at a glance without rendering a full sample surface.
767768private val DarkSwatches: List<Color> = DarkPaletteSwatches
768769private val LightSwatches: List<Color> = LightPaletteSwatches
770+private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
769771
770772// Composable-side read of the system dark setting for the "System" theme row preview.
771773// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+9
@@ -155,6 +155,15 @@ val LightPaletteSwatches: List<Color> = listOf(
155155 LightColors.tertiary,
156156 LightColors.background,
157157)
158+// AMOLED shares the Dark accent colors but uses a pure-black background, so its preview
159+// must differ from Dark's (#1A1B26) dot — otherwise the two theme rows are visually
160+// indistinguishable in the picker and the user can't tell them apart before selecting.
161+val AmoledPaletteSwatches: List<Color> = listOf(
162+ AmoledColors.primary,
163+ AmoledColors.secondary,
164+ AmoledColors.tertiary,
165+ AmoledColors.background,
166+)
158167
159168private tailrec fun Context.findActivity(): Activity? = when (this) {
160169 is Activity -> this