1package soy.iko.opencode.ui.components
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.material3.CircularProgressIndicator
12import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.Surface
14import androidx.compose.material3.Text
15import androidx.compose.material3.TextButton
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.mutableStateOf
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.semantics.contentDescription
23import androidx.compose.ui.semantics.liveRegion
24import androidx.compose.ui.semantics.LiveRegionMode
25import androidx.compose.ui.semantics.semantics
26import androidx.compose.ui.unit.dp
27import soy.iko.opencode.R
28import soy.iko.opencode.data.network.EventStreamClient
31 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
32 * session list so a dropped stream is visible everywhere, not just mid-conversation.
34 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
35 * when [onRetry] is supplied, so the user can re-connect without hunting for the
36 * chat screen's reconnect button or navigating to the server list. Transient
37 * connecting/reconnecting states remain non-interactive (the system is already trying).
41 state: EventStreamClient.ConnectionState,
42 modifier: Modifier = Modifier,
43 isOnline: Boolean = true,
44 onRetry: (() -> Unit)? = null,
46 // When the device itself has no connectivity, surface that distinctly (instead of
47 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
48 // credentials", neither of which reflects the real problem.
49 val text = if (!isOnline) {
50 stringResource(R.string.offline)
53 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
54 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
55 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
56 EventStreamClient.ConnectionState.Connected -> null
59 val isOffline = !isOnline
60 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
61 // and disappearing instantly — a state change that's especially jarring when the
62 // connection flaps, and was called out as a rough edge in the UX audit.
63 // Retain the last non-null banner text so the exit fade-out has content to render.
64 // The content lambda recomposes with the now-null `text` during the exit animation;
65 // early-returning on null there would leave nothing to fade, so the banner would just
66 // vanish instead of fading out.
67 val lastText = remember { mutableStateOf(text) }
68 if (text != null) lastText.value = text
69 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
70 val shown = lastText.value ?: return@AnimatedVisibility
71 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
72 // transient connecting/reconnecting states by switching to the error palette,
73 // so the banner conveys urgency without relying on text alone.
74 val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
75 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
76 else MaterialTheme.colorScheme.tertiaryContainer
77 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
78 else MaterialTheme.colorScheme.onTertiaryContainer
83 // Announce connection state changes to TalkBack users so they're
84 // aware the stream dropped/reconnecting without visual cues.
85 liveRegion = LiveRegionMode.Polite
86 contentDescription = shown
91 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
92 horizontalArrangement = Arrangement.Center,
93 verticalAlignment = Alignment.CenterVertically,
96 CircularProgressIndicator(
103 style = MaterialTheme.typography.labelMedium,
106 .padding(start = 6.dp)
107 .weight(1f, fill = false),
109 // Inline retry on the Failed banner so the user can recover without
110 // leaving the screen. Hidden during transient states (the system is
111 // already reconnecting), when offline (retry can't help), and when no
112 // callback is wired (callers that don't have a reconnect path).
113 if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
114 TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
115 Text(stringResource(R.string.retry_now), color = onContainer)
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