1package soy.iko.opencode.ui.components
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.animateColorAsState
5import androidx.compose.animation.core.tween
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.BoxScope
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.filled.CloudOff
15import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.Surface
18import androidx.compose.material3.Text
19import androidx.compose.material3.TextButton
20import androidx.compose.runtime.Composable
21import androidx.compose.runtime.SideEffect
22import androidx.compose.runtime.getValue
23import androidx.compose.runtime.produceState
24import androidx.compose.runtime.remember
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.ui.Alignment
27import androidx.compose.ui.Modifier
28import androidx.compose.ui.res.stringResource
29import androidx.compose.ui.semantics.contentDescription
30import androidx.compose.ui.semantics.liveRegion
31import androidx.compose.ui.semantics.LiveRegionMode
32import androidx.compose.ui.semantics.semantics
33import androidx.compose.ui.platform.testTag
34import androidx.compose.ui.text.style.TextOverflow
35import androidx.compose.ui.unit.dp
36import androidx.lifecycle.compose.collectAsStateWithLifecycle
37import kotlinx.coroutines.ExperimentalCoroutinesApi
38import kotlinx.coroutines.flow.flatMapLatest
39import kotlinx.coroutines.flow.flowOf
40import soy.iko.opencode.R
41import soy.iko.opencode.data.network.EventStreamClient
42import soy.iko.opencode.data.network.NetworkConfig
43import soy.iko.opencode.di.AppContainer
45/** Attempt count at which the banner switches to "Reconnecting (attempt N)…". See
46 * NetworkConfig.sseReconnectAttemptLabelThreshold. */
47private val RECONNECT_ATTEMPT_LABEL_THRESHOLD get() = NetworkConfig.sseReconnectAttemptLabelThreshold
50 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
51 * session list so a dropped stream is visible everywhere, not just mid-conversation.
53 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
54 * when [onRetry] is supplied, so the user can re-connect without hunting for the
55 * chat screen's reconnect button or navigating to the server list. On an auth failure
56 * ([ConnectionState.AuthFailed]) an "Edit credentials" button is offered instead (via
57 * [onEditCredentials]) — retrying with the same bad credentials is futile, so the
58 * recovery path is to edit the server profile. Transient connecting/reconnecting states
59 * remain non-interactive (the system is already trying).
63 state: EventStreamClient.ConnectionState,
64 modifier: Modifier = Modifier,
65 isOnline: Boolean = true,
66 onRetry: (() -> Unit)? = null,
67 onEditCredentials: (() -> Unit)? = null,
68 reconnectAttempts: Int = 0,
70 // When the device itself has no connectivity, surface that distinctly (instead of
71 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
72 // credentials", neither of which reflects the real problem.
73 val text = if (!isOnline) {
74 stringResource(R.string.offline)
77 EventStreamClient.ConnectionState.Connecting ->
78 if (reconnectAttempts >= RECONNECT_ATTEMPT_LABEL_THRESHOLD) {
79 stringResource(R.string.reconnect_attempt, reconnectAttempts)
81 stringResource(R.string.connecting)
83 EventStreamClient.ConnectionState.Disconnected ->
84 if (reconnectAttempts >= RECONNECT_ATTEMPT_LABEL_THRESHOLD) {
85 stringResource(R.string.reconnect_attempt, reconnectAttempts)
87 stringResource(R.string.reconnecting)
89 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
90 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
91 EventStreamClient.ConnectionState.Connected -> null
94 val isOffline = !isOnline
95 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
96 // and disappearing instantly — a state change that's especially jarring when the
97 // connection flaps, and was called out as a rough edge in the UX audit.
98 // Retain the last non-null banner text so the exit fade-out has content to render.
99 // The content lambda recomposes with the now-null `text` during the exit animation;
100 // early-returning on null there would leave nothing to fade, so the banner would just
101 // vanish instead of fading out.
102 val lastText = remember { mutableStateOf(text) }
103 // Write the retained text in a SideEffect (not directly in the body): writing state during
104 // composition is a documented anti-pattern that can trigger extra recompositions and isn't
105 // guaranteed a defined ordering. SideEffect runs only after a successful composition.
106 SideEffect { if (text != null) lastText.value = text }
108 visible = text != null,
109 enter = rememberVisibilityTransitions().enter,
110 exit = rememberVisibilityTransitions().exit,
112 val shown = lastText.value ?: return@AnimatedVisibility
113 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
114 // transient connecting/reconnecting states by switching to the error palette,
115 // so the banner conveys urgency without relying on text alone.
116 val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed ||
117 state == EventStreamClient.ConnectionState.AuthFailed
118 // Server-side failure (auth or endpoint) — distinct from offline. Used to gate the
119 // inline Retry button, which only makes sense when the device is online and the server
120 // rejected the connection (not when the device itself is offline).
121 val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
122 state == EventStreamClient.ConnectionState.AuthFailed
123 val container by animateColorAsState(
124 targetValue = if (isFailed) MaterialTheme.colorScheme.errorContainer
125 else MaterialTheme.colorScheme.tertiaryContainer,
126 animationSpec = tween(NetworkConfig.motionFadeDurationMs),
127 label = "bannerContainer",
129 val onContainer by animateColorAsState(
130 targetValue = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
131 else MaterialTheme.colorScheme.onTertiaryContainer,
132 animationSpec = tween(NetworkConfig.motionFadeDurationMs),
133 label = "bannerOnContainer",
138 // mergeDescendants so the spinner + label read as one TalkBack stop instead of
139 // several; the Retry TextButton is its own merged node, so it stays separately
141 .semantics(mergeDescendants = true) {
142 // Announce connection state changes to TalkBack users so they're
143 // aware the stream dropped/reconnecting without visual cues.
144 liveRegion = LiveRegionMode.Polite
145 contentDescription = shown
150 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
151 horizontalArrangement = Arrangement.Center,
152 verticalAlignment = Alignment.CenterVertically,
155 // An error icon reinforces the failure state visually, matching the
156 // session list's error state which uses ErrorOutline.
158 Icons.Filled.CloudOff,
159 contentDescription = null,
160 modifier = Modifier.size(16.dp),
165 size = LoadingSize.Inline,
171 style = MaterialTheme.typography.labelMedium,
174 overflow = TextOverflow.Ellipsis,
176 .padding(start = 6.dp)
177 .weight(1f, fill = false),
179 // Inline recovery action on a hard failure so the user can recover without
180 // leaving the screen. Hidden during transient states (the system is already
181 // reconnecting), when offline (retry can't help), and when no callback is wired.
182 if (isServerFailure && !isOffline) {
183 ConnectionRecoveryButton(
186 onEditCredentials = onEditCredentials,
187 onContainer = onContainer,
195/** Renders the inline recovery button for a hard connection failure. For an auth failure
196 * (401/403), retrying with the same bad credentials is futile — offer "Edit credentials"
197 * instead. For an endpoint failure, "Retry now" re-attempts the connection. Extracted from
198 * [ConnectionBanner] to keep that function's cyclomatic complexity under detekt's threshold. */
200private fun ConnectionRecoveryButton(
201 state: EventStreamClient.ConnectionState,
202 onRetry: (() -> Unit)?,
203 onEditCredentials: (() -> Unit)?,
204 onContainer: androidx.compose.ui.graphics.Color,
206 if (state == EventStreamClient.ConnectionState.AuthFailed && onEditCredentials != null) {
208 onClick = onEditCredentials,
209 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
211 .defaultMinSize(minHeight = 48.dp)
212 .testTag("connection_edit_credentials"),
214 Text(stringResource(R.string.edit_credentials), color = onContainer)
216 } else if (onRetry != null) {
219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
221 .defaultMinSize(minHeight = 48.dp)
222 .testTag("connection_retry"),
224 Text(stringResource(R.string.retry_now), color = onContainer)
230 * Convenience wrapper that collects the SSE connection state and device connectivity from
231 * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
232 * [BoxScope]. Screens that don't already expose connection state (Files, Settings,
233 * Diagnostics) can call this instead of duplicating the flatMapLatest boilerplate.
234 * The retry callback triggers an SSE reconnect on the active connection.
236@OptIn(ExperimentalCoroutinesApi::class)
238fun BoxScope.ConnectionBannerFor(
239 container: AppContainer,
240 onEditCredentials: (() -> Unit)? = null,
242 // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
243 // gives an initial value. No ViewModel scope needed, so this works on any screen.
244 val connectionState by produceState(
245 initialValue = EventStreamClient.ConnectionState.Disconnected,
248 container.activeConnection
249 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
250 .collect { value = it }
252 val reconnectAttempts by produceState(initialValue = 0, container) {
253 container.activeConnection
254 .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
255 .collect { value = it }
257 val isOnline by container.isOnline.collectAsStateWithLifecycle()
259 state = connectionState,
260 modifier = Modifier.align(Alignment.TopCenter),
262 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
263 onEditCredentials = onEditCredentials,
264 reconnectAttempts = reconnectAttempts,
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