authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 22:43:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 22:43:41-07:00
logbb5748bfac0fbeffe690875fa485a5cbe7b99132
treefec60fbaa39c705d9b094a11c8d05b15530e9a11
parente1230d6031bc55a21668818d0b585d8887b88579
signature Signed by PGP key B5690EEEBB952194

fix(mobile): show connection status in the floating pill instead of a second one (#10440)

Co-authored-by: Claude Code <noreply@anthropic.com>

5 files changed, 299 insertions(+), 130 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+1-81
......@@ -26,7 +26,7 @@ import {
2626 useState,
2727 type RefObject,
2828} from "react";
29import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
29import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
3030import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
3131import {
3232 composerAttachmentUploadBlockReason,
......@@ -113,7 +113,6 @@ export interface ThreadComposerProps {
113113 readonly contentMaxWidth?: number;
114114 readonly bottomInset?: number;
115115 readonly connectionState: RemoteClientConnectionState;
116 readonly connectionError: string | null;
117116 readonly environmentLabel: string | null;
118117 readonly selectedThread: OrchestrationThreadShell;
119118 readonly hasCompactableConversation: boolean;
......@@ -134,7 +133,6 @@ export interface ThreadComposerProps {
134133 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
135134 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
136135 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
137 readonly onReconnectEnvironment: () => void;
138136 readonly onExpandedChange?: (expanded: boolean) => void;
139137 /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */
140138 readonly onEditorFocusChange?: (focused: boolean) => void;
......@@ -226,72 +224,6 @@ export function ComposerSurface(props: {
226224 );
227225}
228226
229type ComposerStatusPillState = {
230 readonly kind: "unavailable" | "reconnecting";
231 readonly label: string;
232};
233
234function composerConnectionStatus(input: {
235 readonly connectionError: string | null;
236 readonly connectionState: RemoteClientConnectionState;
237 readonly environmentLabel: string | null;
238}): ComposerStatusPillState | null {
239 const environmentLabel = input.environmentLabel ?? "Environment";
240
241 switch (input.connectionState) {
242 case "connecting":
243 case "reconnecting":
244 return {
245 kind: "reconnecting",
246 label:
247 input.connectionError === null
248 ? `Reconnecting to ${environmentLabel}...`
249 : `Failed to connect. Retrying ${environmentLabel}...`,
250 };
251 case "offline":
252 return { kind: "unavailable", label: "You are offline" };
253 case "error":
254 return {
255 kind: "unavailable",
256 label: input.connectionError
257 ? `Failed to connect to ${environmentLabel}: ${input.connectionError}`
258 : `Failed to connect to ${environmentLabel}`,
259 };
260 case "available":
261 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
262 case "connected":
263 return null;
264 }
265}
266
267const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {
268 readonly onPress: () => void;
269 readonly status: ComposerStatusPillState;
270}) {
271 const isReconnecting = props.status.kind === "reconnecting";
272 return (
273 <View className="absolute inset-x-0 bottom-full items-center pb-2" pointerEvents="box-none">
274 <Pressable
275 accessibilityRole="button"
276 onPress={props.onPress}
277 className="max-w-full flex-row items-center gap-2 rounded-full bg-card px-3 py-2 shadow-sm active:opacity-70"
278 >
279 {isReconnecting ? (
280 <ActivityIndicator size="small" colorClassName={"accent-icon-muted"} />
281 ) : (
282 <View className="h-2 w-2 rounded-full bg-red-500" />
283 )}
284 <Text
285 className="max-w-[260px] text-sm font-t3-bold leading-snug text-foreground"
286 numberOfLines={1}
287 >
288 {props.status.label}
289 </Text>
290 </Pressable>
291 </View>
292 );
293});
294
295227export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
296228 const navigation = useNavigation();
297229 const foregroundColor = useUniwindTheme()["--color-foreground"];
......@@ -337,11 +269,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
337269 const modelUnavailable =
338270 props.connectionState === "connected" &&
339271 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);
340 const connectionStatus = composerConnectionStatus({
341 connectionError: props.connectionError,
342 connectionState: props.connectionState,
343 environmentLabel: props.environmentLabel,
344 });
345272 const selectedProviderStatus = useMemo(() => {
346273 if (!props.serverConfig) return null;
347274 return (
......@@ -640,13 +567,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
640567 </View>
641568 ) : null}
642569
643 {connectionStatus ? (
644 <ComposerConnectionStatusPill
645 status={connectionStatus}
646 onPress={props.onReconnectEnvironment}
647 />
648 ) : null}
649
650570 {modelUnavailable ? (
651571 <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>
652572 <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14-10
......@@ -84,8 +84,8 @@ import { PendingUserInputCard } from "./PendingUserInputCard";
8484import {
8585 FLOATING_WORKING_CONTROL_COVERAGE,
8686 FloatingWorkingControl,
87 type FloatingWorkingStatus,
8887} from "./floating-working-control";
88import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";
8989import {
9090 derivePendingUserInputMaxHeight,
9191 ESTIMATED_KEYBOARD_HEIGHT,
......@@ -331,14 +331,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
331331 return null;
332332 }
333333 })();
334 // One floating pill above the composer: it reads the sync state while
335 // messages load, then the working timer once the feed is settled.
334 // One floating pill above the composer: it reads the connection phase while
335 // disconnected, the sync state while messages load, then the working timer
336 // once the feed is settled.
336337 const floatingStatus = ((): FloatingWorkingStatus | null => {
337 if (
338 props.connectionStateLabel !== "connected" ||
339 props.activePendingApproval !== null ||
340 props.activePendingUserInput !== null
341 ) {
338 const connectionStatus = connectionFloatingStatus({
339 connectionError: props.connectionError,
340 connectionState: props.connectionStateLabel,
341 environmentLabel: props.environmentLabel,
342 onReconnect: props.onReconnectEnvironment,
343 });
344 if (connectionStatus !== null) {
345 return connectionStatus;
346 }
347 if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
342348 return null;
343349 }
344350 if (threadSyncLabel !== null) {
......@@ -964,7 +970,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
964970 placeholder="Ask the repo agent, or run a command…"
965971 contentMaxWidth={contentMaxWidth}
966972 connectionState={props.connectionStateLabel}
967 connectionError={props.connectionError}
968973 environmentLabel={props.environmentLabel}
969974 selectedThread={props.selectedThread}
970975 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
......@@ -981,7 +986,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
981986 onStopThread={props.onStopThread}
982987 onSendMessage={handleSendMessage}
983988 onShowUsageLimits={showUsageLimits}
984 onReconnectEnvironment={props.onReconnectEnvironment}
985989 onUpdateModelSelection={props.onUpdateThreadModelSelection}
986990 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
987991 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
apps/mobile/src/features/threads/floating-working-control.tsx+156-39
......@@ -1,7 +1,13 @@
11import { formatDuration } from "@t3tools/shared/orchestrationTiming";
22import { GlassContainer, GlassView } from "expo-glass-effect";
3import { useEffect, useState } from "react";
4import { ActivityIndicator, Text as SystemText, View } from "react-native";
3import { type ReactNode, useEffect, useRef, useState } from "react";
4import {
5 ActivityIndicator,
6 type LayoutChangeEvent,
7 Pressable,
8 Text as SystemText,
9 View,
10} from "react-native";
511import Animated, {
612 Easing,
713 FadeIn,
......@@ -17,6 +23,7 @@ import { AppText as Text } from "../../components/AppText";
1723import { SymbolView } from "../../components/AppSymbol";
1824import { ControlPill } from "../../components/ControlPill";
1925import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
26import type { FloatingWorkingStatus } from "./floating-working-status";
2027
2128const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem
2229// The collapsed composer capsule starts 6 below its overlay's top edge, so
......@@ -33,6 +40,10 @@ const CONTROL_TIMING = {
3340 reduceMotion: ReduceMotion.System,
3441} as const;
3542const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2;
43// Both rows share the same centered anchor, so the outgoing one clears fast and
44// the incoming one waits for it to be mostly gone before it starts to show.
45const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System);
46const LABEL_EXITING = FadeOut.duration(100).reduceMotion(ReduceMotion.System);
3647
3748// Expo reapplies glass after native layout and window reattachment, when UIKit
3849// can otherwise leave the label visible but lose the material behind it.
......@@ -47,15 +58,6 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);
4758const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET;
4859export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP;
4960
50/**
51 * What the floating pill says. Syncing and working share one element so the
52 * label swaps in place instead of one pill fading out for another.
53 */
54export type FloatingWorkingStatus =
55 | { readonly kind: "working"; readonly startedAt: string }
56 | { readonly kind: "syncing"; readonly label: string }
57 | { readonly kind: "compacting" };
58
5961export function FloatingWorkingControl(props: {
6062 readonly colorScheme: "light" | "dark";
6163 readonly status: FloatingWorkingStatus | null;
......@@ -68,9 +70,6 @@ export function FloatingWorkingControl(props: {
6870 separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);
6971 }, [props.showScrollToEnd, separationProgress]);
7072
71 const timerStyle = useAnimatedStyle(() => ({
72 transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],
73 }));
7473 const arrowTransformStyle = useAnimatedStyle(() => ({
7574 transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],
7675 }));
......@@ -78,10 +77,63 @@ export function FloatingWorkingControl(props: {
7877 opacity: separationProgress.value,
7978 }));
8079
80 // The label swaps between connection, syncing, compacting, and working while
81 // the capsule stays mounted. A layout transition on the capsule would move
82 // its left edge, and labels laid out from that edge slide with it, so the pill
83 // reads as shifting sideways. Instead an in-flow sizer animates to the
84 // measured label width and the capsule takes its size from that, while the
85 // labels sit centered on top. The row re-centers as the capsule grows, so its
86 // midpoint never moves and the text underneath stays put.
87 //
88 // The sizer has to carry the width rather than the capsule itself: the native
89 // glass view only picks up a size from a real layout pass, so an animated
90 // width set straight on it leaves the glass stuck at its mounted size.
91 const capsuleWidth = useSharedValue<number | null>(null);
92 const measuredWidthRef = useRef<number | null>(null);
93 const handleLabelLayout = (event: LayoutChangeEvent) => {
94 const width = event.nativeEvent.layout.width;
95 if (width === measuredWidthRef.current) {
96 return;
97 }
98 const first = measuredWidthRef.current === null;
99 measuredWidthRef.current = width;
100 capsuleWidth.value = first ? width : withTiming(width, CONTROL_TIMING);
101 };
102 // Forget the width while no label is shown so the next one appears at its
103 // own size instead of animating from the previous label's.
104 const hasStatus = props.status !== null;
105 useEffect(() => {
106 if (!hasStatus) {
107 measuredWidthRef.current = null;
108 capsuleWidth.value = null;
109 }
110 }, [capsuleWidth, hasStatus]);
111 const capsuleStyle = useAnimatedStyle(() => ({
112 transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],
113 }));
114 // Zero until the first measurement lands, so the capsule never paints around
115 // a label it has not sized to yet.
116 const capsuleSizerStyle = useAnimatedStyle(() => ({ width: capsuleWidth.value ?? 0 }));
117
81118 if (props.status === null && !props.showScrollToEnd) {
82119 return null;
83120 }
84121
122 // Only the connection label is a button (tap to reconnect); the others
123 // pass touches through to the feed like before.
124 const statusInteractive = props.status?.kind === "connection";
125 // Yoga centers an absolute child that has no insets on its parent's align and
126 // justify, so each label row lands centered on the capsule without measuring
127 // itself, and the capsule clips whatever a wider label overhangs while it
128 // catches up.
129 const statusContent =
130 props.status !== null ? (
131 <>
132 <Animated.View className="h-11" style={capsuleSizerStyle} />
133 <FloatingStatusLabel status={props.status} onLayout={handleLabelLayout} />
134 </>
135 ) : null;
136
85137 return (
86138 <Animated.View
87139 pointerEvents="box-none"
......@@ -99,11 +151,12 @@ export function FloatingWorkingControl(props: {
99151 <AnimatedGlassView
100152 colorScheme={props.colorScheme}
101153 glassEffectStyle="regular"
102 pointerEvents="none"
103 className="h-11 justify-center overflow-hidden rounded-full"
104 style={timerStyle}
154 isInteractive={statusInteractive}
155 pointerEvents={statusInteractive ? "box-none" : "none"}
156 className="h-11 items-center justify-center overflow-hidden rounded-full"
157 style={capsuleStyle}
105158 >
106 <FloatingStatusLabel status={props.status} />
159 {statusContent}
107160 </AnimatedGlassView>
108161
109162 <AnimatedGlassView
......@@ -124,11 +177,11 @@ export function FloatingWorkingControl(props: {
124177 ) : props.status !== null ? (
125178 <View pointerEvents="box-none" className="flex-row items-center gap-4">
126179 <Animated.View
127 pointerEvents="none"
128 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"
129 style={timerStyle}
180 pointerEvents={statusInteractive ? "box-none" : "none"}
181 className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-card shadow-md shadow-black/10"
182 style={capsuleStyle}
130183 >
131 <FloatingStatusLabel status={props.status} />
184 {statusContent}
132185 </Animated.View>
133186
134187 <Animated.View
......@@ -169,13 +222,9 @@ export function FloatingWorkingControl(props: {
169222 );
170223}
171224
172function CompactingLabel() {
225function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) {
173226 return (
174 <View
175 accessible
176 accessibilityLabel="Compacting"
177 className="h-11 flex-row items-center gap-1.5 px-4"
178 >
227 <StatusLabelRow accessibilityLabel="Compacting" className="gap-1.5" onLayout={props.onLayout}>
179228 <SymbolView
180229 name="arrow.down.right.and.arrow.up.left"
181230 size={13}
......@@ -183,30 +232,98 @@ function CompactingLabel() {
183232 type="monochrome"
184233 />
185234 <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>
186 </View>
235 </StatusLabelRow>
187236 );
188237}
189238
190function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) {
239function FloatingStatusLabel(props: {
240 readonly status: FloatingWorkingStatus;
241 readonly onLayout: (event: LayoutChangeEvent) => void;
242}) {
243 // Keyed by kind so a swap mounts a fresh row and the two cross-fade while
244 // the capsule animates to the new row's measured width.
191245 if (props.status.kind === "syncing") {
192246 return (
193 <View
194 accessible
247 <StatusLabelRow
248 key="syncing"
195249 accessibilityLabel={props.status.label}
196 className="h-11 flex-row items-center gap-2 px-4"
250 className="gap-2"
251 onLayout={props.onLayout}
197252 >
198253 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
199254 <Text className="font-t3-medium text-xs text-foreground">{props.status.label}</Text>
200 </View>
255 </StatusLabelRow>
201256 );
202257 }
203258 if (props.status.kind === "compacting") {
204 return <CompactingLabel />;
259 return <CompactingLabel key="compacting" onLayout={props.onLayout} />;
260 }
261 if (props.status.kind === "connection") {
262 return (
263 <StatusLabelRow
264 key="connection"
265 accessibilityLabel={props.status.label}
266 accessibilityRole="button"
267 className="gap-2"
268 onLayout={props.onLayout}
269 onPress={props.status.onPress}
270 >
271 {props.status.tone === "reconnecting" ? (
272 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
273 ) : (
274 <View className="h-2 w-2 rounded-full bg-red-500" />
275 )}
276 <Text className="max-w-[260px] font-t3-medium text-xs text-foreground" numberOfLines={1}>
277 {props.status.label}
278 </Text>
279 </StatusLabelRow>
280 );
205281 }
206 return <WorkingDuration startedAt={props.status.startedAt} />;
282 return (
283 <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
284 );
207285}
208286
209function WorkingDuration(props: { readonly startedAt: string }) {
287// Rows are absolute with no insets, so the capsule centers them on itself and an
288// exiting row fading out never shifts the incoming one.
289function StatusLabelRow(props: {
290 readonly accessibilityLabel: string;
291 readonly accessibilityRole?: "button";
292 readonly className?: string;
293 readonly children: ReactNode;
294 readonly onLayout: (event: LayoutChangeEvent) => void;
295 readonly onPress?: () => void;
296}) {
297 const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`;
298 return (
299 <Animated.View
300 className="absolute"
301 entering={LABEL_ENTERING}
302 exiting={LABEL_EXITING}
303 onLayout={props.onLayout}
304 >
305 {props.onPress ? (
306 <Pressable
307 accessibilityLabel={props.accessibilityLabel}
308 accessibilityRole={props.accessibilityRole}
309 className={`${rowClassName} active:opacity-70`}
310 onPress={props.onPress}
311 >
312 {props.children}
313 </Pressable>
314 ) : (
315 <View accessible accessibilityLabel={props.accessibilityLabel} className={rowClassName}>
316 {props.children}
317 </View>
318 )}
319 </Animated.View>
320 );
321}
322
323function WorkingDuration(props: {
324 readonly startedAt: string;
325 readonly onLayout: (event: LayoutChangeEvent) => void;
326}) {
210327 const [nowMs, setNowMs] = useState(() => Date.now());
211328
212329 useEffect(() => {
......@@ -219,7 +336,7 @@ function WorkingDuration(props: { readonly startedAt: string }) {
219336 const label = `Working for ${duration}`;
220337
221338 return (
222 <View accessible accessibilityLabel={label} className="h-11 flex-row items-center px-4">
339 <StatusLabelRow accessibilityLabel={label} onLayout={props.onLayout}>
223340 <Text className="font-t3-medium text-xs text-foreground">Working for </Text>
224341 <SystemText
225342 className="text-xs text-foreground"
......@@ -227,7 +344,7 @@ function WorkingDuration(props: { readonly startedAt: string }) {
227344 >
228345 {duration}
229346 </SystemText>
230 </View>
347 </StatusLabelRow>
231348 );
232349}
233350
apps/mobile/src/features/threads/floating-working-status.test.ts created+66
......@@ -0,0 +1,66 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { connectionFloatingStatus } from "./floating-working-status";
4
5const status = (
6 connectionState: Parameters<typeof connectionFloatingStatus>[0]["connectionState"],
7 overrides: { connectionError?: string | null; environmentLabel?: string | null } = {},
8) =>
9 connectionFloatingStatus({
10 connectionError: overrides.connectionError ?? null,
11 connectionState,
12 environmentLabel:
13 overrides.environmentLabel === undefined ? "Mac mini" : overrides.environmentLabel,
14 onReconnect: () => {},
15 });
16
17describe("connectionFloatingStatus", () => {
18 it("yields the pill to sync and working state once connected", () => {
19 expect(status("connected")).toBeNull();
20 });
21
22 it("names the environment it is retrying, and says so only after a failure", () => {
23 expect(status("connecting")).toMatchObject({
24 tone: "reconnecting",
25 label: "Reconnecting to Mac mini...",
26 });
27 expect(status("reconnecting", { connectionError: "ECONNREFUSED" })).toMatchObject({
28 tone: "reconnecting",
29 label: "Failed to connect. Retrying Mac mini...",
30 });
31 });
32
33 it("reports why the environment is unreachable", () => {
34 expect(status("offline")).toMatchObject({
35 tone: "unavailable",
36 label: "You are offline",
37 });
38 expect(status("available")).toMatchObject({
39 tone: "unavailable",
40 label: "Mac mini is not connected",
41 });
42 expect(status("error", { connectionError: "handshake timed out" })).toMatchObject({
43 label: "Failed to connect to Mac mini: handshake timed out",
44 });
45 expect(status("error")).toMatchObject({ label: "Failed to connect to Mac mini" });
46 });
47
48 it("falls back to a generic name when the environment has no label", () => {
49 expect(status("error", { environmentLabel: null })).toMatchObject({
50 label: "Failed to connect to Environment",
51 });
52 });
53
54 it("carries the reconnect handler so the pill can trigger it", () => {
55 const onReconnect = vi.fn();
56 const pill = connectionFloatingStatus({
57 connectionError: null,
58 connectionState: "offline",
59 environmentLabel: "Mac mini",
60 onReconnect,
61 });
62 if (pill?.kind !== "connection") throw new Error("expected a connection pill");
63 pill.onPress();
64 expect(onReconnect).toHaveBeenCalledOnce();
65 });
66});
apps/mobile/src/features/threads/floating-working-status.ts created+62
......@@ -0,0 +1,62 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2
3/**
4 * What the floating pill says. Connection, syncing, and working share one
5 * element so the label swaps in place instead of one pill fading out for
6 * another. The connection variant is tappable and triggers a reconnect.
7 */
8export type FloatingWorkingStatus =
9 | { readonly kind: "working"; readonly startedAt: string }
10 | { readonly kind: "syncing"; readonly label: string }
11 | { readonly kind: "compacting" }
12 | {
13 readonly kind: "connection";
14 readonly tone: "reconnecting" | "unavailable";
15 readonly label: string;
16 readonly onPress: () => void;
17 };
18
19/**
20 * The pill's connection variant, or null once the environment is connected and
21 * the pill is free to report sync and working state instead.
22 */
23export function connectionFloatingStatus(input: {
24 readonly connectionError: string | null;
25 readonly connectionState: EnvironmentConnectionPhase;
26 readonly environmentLabel: string | null;
27 readonly onReconnect: () => void;
28}): FloatingWorkingStatus | null {
29 const environmentLabel = input.environmentLabel ?? "Environment";
30 const unavailable = (label: string): FloatingWorkingStatus => ({
31 kind: "connection",
32 tone: "unavailable",
33 label,
34 onPress: input.onReconnect,
35 });
36
37 switch (input.connectionState) {
38 case "connecting":
39 case "reconnecting":
40 return {
41 kind: "connection",
42 tone: "reconnecting",
43 label:
44 input.connectionError === null
45 ? `Reconnecting to ${environmentLabel}...`
46 : `Failed to connect. Retrying ${environmentLabel}...`,
47 onPress: input.onReconnect,
48 };
49 case "offline":
50 return unavailable("You are offline");
51 case "error":
52 return unavailable(
53 input.connectionError
54 ? `Failed to connect to ${environmentLabel}: ${input.connectionError}`
55 : `Failed to connect to ${environmentLabel}`,
56 );
57 case "available":
58 return unavailable(`${environmentLabel} is not connected`);
59 case "connected":
60 return null;
61 }
62}