1package soy.iko.opencode.ui.components
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.BoxScope
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.defaultMinSize
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.filled.CloudOff
13import androidx.compose.material3.Icon
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Surface
16import androidx.compose.material3.Text
17import androidx.compose.material3.TextButton
18import androidx.compose.runtime.Composable
19import androidx.compose.runtime.SideEffect
20import androidx.compose.runtime.getValue
21import androidx.compose.runtime.produceState
22import androidx.compose.runtime.remember
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.ui.Alignment
25import androidx.compose.ui.Modifier
26import androidx.compose.ui.res.stringResource
27import androidx.compose.ui.semantics.contentDescription
28import androidx.compose.ui.semantics.liveRegion
29import androidx.compose.ui.semantics.LiveRegionMode
30import androidx.compose.ui.semantics.semantics
31import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.unit.dp
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
34import kotlinx.coroutines.ExperimentalCoroutinesApi
35import kotlinx.coroutines.flow.flatMapLatest
36import kotlinx.coroutines.flow.flowOf
37import soy.iko.opencode.R
38import soy.iko.opencode.data.network.EventStreamClient
39import soy.iko.opencode.di.AppContainer
42 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
43 * session list so a dropped stream is visible everywhere, not just mid-conversation.
45 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
46 * when [onRetry] is supplied, so the user can re-connect without hunting for the
47 * chat screen's reconnect button or navigating to the server list. Transient
48 * connecting/reconnecting states remain non-interactive (the system is already trying).
52 state: EventStreamClient.ConnectionState,
53 modifier: Modifier = Modifier,
54 isOnline: Boolean = true,
55 onRetry: (() -> Unit)? = null,
57 // When the device itself has no connectivity, surface that distinctly (instead of
58 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
59 // credentials", neither of which reflects the real problem.
60 val text = if (!isOnline) {
61 stringResource(R.string.offline)
64 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
65 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
66 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
67 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
68 EventStreamClient.ConnectionState.Connected -> null
71 val isOffline = !isOnline
72 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
73 // and disappearing instantly — a state change that's especially jarring when the
74 // connection flaps, and was called out as a rough edge in the UX audit.
75 // Retain the last non-null banner text so the exit fade-out has content to render.
76 // The content lambda recomposes with the now-null `text` during the exit animation;
77 // early-returning on null there would leave nothing to fade, so the banner would just
78 // vanish instead of fading out.
79 val lastText = remember { mutableStateOf(text) }
80 // Write the retained text in a SideEffect (not directly in the body): writing state during
81 // composition is a documented anti-pattern that can trigger extra recompositions and isn't
82 // guaranteed a defined ordering. SideEffect runs only after a successful composition.
83 SideEffect { if (text != null) lastText.value = text }
85 visible = text != null,
86 enter = rememberVisibilityTransitions().enter,
87 exit = rememberVisibilityTransitions().exit,
89 val shown = lastText.value ?: return@AnimatedVisibility
90 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
91 // transient connecting/reconnecting states by switching to the error palette,
92 // so the banner conveys urgency without relying on text alone.
93 val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed ||
94 state == EventStreamClient.ConnectionState.AuthFailed
95 // Server-side failure (auth or endpoint) — distinct from offline. Used to gate the
96 // inline Retry button, which only makes sense when the device is online and the server
97 // rejected the connection (not when the device itself is offline).
98 val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
99 state == EventStreamClient.ConnectionState.AuthFailed
100 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
101 else MaterialTheme.colorScheme.tertiaryContainer
102 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
103 else MaterialTheme.colorScheme.onTertiaryContainer
107 // mergeDescendants so the spinner + label read as one TalkBack stop instead of
108 // several; the Retry TextButton is its own merged node, so it stays separately
110 .semantics(mergeDescendants = true) {
111 // Announce connection state changes to TalkBack users so they're
112 // aware the stream dropped/reconnecting without visual cues.
113 liveRegion = LiveRegionMode.Polite
114 contentDescription = shown
119 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
120 horizontalArrangement = Arrangement.Center,
121 verticalAlignment = Alignment.CenterVertically,
124 // An error icon reinforces the failure state visually, matching the
125 // session list's error state which uses ErrorOutline.
127 Icons.Filled.CloudOff,
128 contentDescription = null,
129 modifier = Modifier.size(16.dp),
134 size = LoadingSize.Inline,
140 style = MaterialTheme.typography.labelMedium,
143 overflow = TextOverflow.Ellipsis,
145 .padding(start = 6.dp)
146 .weight(1f, fill = false),
148 // Inline retry on the Failed banner so the user can recover without
149 // leaving the screen. Hidden during transient states (the system is
150 // already reconnecting), when offline (retry can't help), and when no
151 // callback is wired (callers that don't have a reconnect path).
152 if (isServerFailure && !isOffline && onRetry != null) {
155 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
156 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
158 Text(stringResource(R.string.retry_now), color = onContainer)
167 * Convenience wrapper that collects the SSE connection state and device connectivity from
168 * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
169 * [BoxScope]. Screens that don't already expose connection state (Files, Settings,
170 * Diagnostics) can call this instead of duplicating the flatMapLatest boilerplate.
171 * The retry callback triggers an SSE reconnect on the active connection.
173@OptIn(ExperimentalCoroutinesApi::class)
175fun BoxScope.ConnectionBannerFor(container: AppContainer) {
176 // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
177 // gives an initial value. No ViewModel scope needed, so this works on any screen.
178 val connectionState by produceState(
179 initialValue = EventStreamClient.ConnectionState.Disconnected,
182 container.activeConnection
183 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
184 .collect { value = it }
186 val isOnline by container.isOnline.collectAsStateWithLifecycle()
188 state = connectionState,
189 modifier = Modifier.align(Alignment.TopCenter),
191 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list