History

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

Julius Marminge committed 2 months ago

Commit bb5748b1 parent e1230d65 files · +299 −130Browse files

Unified · Split

5 files
  1. apps/mobile/src/features/threads/ThreadComposer.tsx +1 −81
  2. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +14 −10
  3. apps/mobile/src/features/threads/floating-working-control.tsx +156 −39
  4. apps/mobile/src/features/threads/floating-working-status.test.ts +66 −0
  5. apps/mobile/src/features/threads/floating-working-status.ts +62 −0
apps/mobile/src/features/threads/ThreadComposer.tsx+1−81
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
2626 useState,
2727 type RefObject,
2828 } from "react";
29−import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
29+import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
3030 import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
3131 import {
3232 composerAttachmentUploadBlockReason,
@@ -113,7 +113,6 @@ export interface ThreadComposerProps { · 80 unmodified lines
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 { · 14 unmodified lines
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: { · 85 unmodified lines
226224 );
227225 }
228226
229−type ComposerStatusPillState = {
230− readonly kind: "unavailable" | "reconnecting";
231− readonly label: string;
232−};
233−
234−function 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−
267−const 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−
295227 export 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 · 39 unmodified lines
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 · 292 unmodified lines
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"; · 83 unmodified lines
8484 import {
8585 FLOATING_WORKING_CONTROL_COVERAGE,
8686 FloatingWorkingControl,
87− type FloatingWorkingStatus,
8887 } from "./floating-working-control";
88+import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";
8989 import {
9090 derivePendingUserInputMaxHeight,
9191 ESTIMATED_KEYBOARD_HEIGHT,
@@ -331,14 +331,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 239 unmodified lines
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 · 619 unmodified lines
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 · 10 unmodified lines
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 @@
11 import { formatDuration } from "@t3tools/shared/orchestrationTiming";
22 import { GlassContainer, GlassView } from "expo-glass-effect";
3−import { useEffect, useState } from "react";
4−import { ActivityIndicator, Text as SystemText, View } from "react-native";
3+import { type ReactNode, useEffect, useRef, useState } from "react";
4+import {
5+ ActivityIndicator,
6+ type LayoutChangeEvent,
7+ Pressable,
8+ Text as SystemText,
9+ View,
10+} from "react-native";
511 import Animated, {
612 Easing,
713 FadeIn,
@@ -17,6 +23,7 @@ import { AppText as Text } from "../../components/AppText"; · 9 unmodified lines
1723 import { SymbolView } from "../../components/AppSymbol";
1824 import { ControlPill } from "../../components/ControlPill";
1925 import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
26+import type { FloatingWorkingStatus } from "./floating-working-status";
2027
2128 const 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 = { · 10 unmodified lines
3340 reduceMotion: ReduceMotion.System,
3441 } as const;
3542 const 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.
45+const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System);
46+const 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); · 8 unmodified lines
4758 const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET;
4859 export 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− */
54−export type FloatingWorkingStatus =
55− | { readonly kind: "working"; readonly startedAt: string }
56− | { readonly kind: "syncing"; readonly label: string }
57− | { readonly kind: "compacting" };
58−
5961 export function FloatingWorkingControl(props: {
6062 readonly colorScheme: "light" | "dark";
6163 readonly status: FloatingWorkingStatus | null;
@@ -68,9 +70,6 @@ export function FloatingWorkingControl(props: { · 6 unmodified lines
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: { · 1 unmodified line
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: { · 11 unmodified lines
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: { · 14 unmodified lines
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: { · 34 unmodified lines
169222 );
170223 }
171224
172−function CompactingLabel() {
225+function 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() { · 1 unmodified line
183232 type="monochrome"
184233 />
185234 <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>
186− </View>
235+ </StatusLabelRow>
187236 );
188237 }
189238
190−function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) {
239+function 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
209−function 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.
289+function 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+
323+function 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 }) { · 6 unmodified lines
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 }) { · 1 unmodified line
227344 >
228345 {duration}
229346 </SystemText>
230− </View>
347+ </StatusLabelRow>
231348 );
232349 }
233350
apps/mobile/src/features/threads/floating-working-status.test.ts+66−0
@@ -0,0 +1,66 @@
1+import { describe, expect, it, vi } from "vite-plus/test";
2+
3+import { connectionFloatingStatus } from "./floating-working-status";
4+
5+const 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+
17+describe("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+62−0
@@ -0,0 +1,62 @@
1+import 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+ */
8+export 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+ */
23+export 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+}