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 {...@@ -26,7 +26,7 @@ import {
26 useState,26 useState,
27 type RefObject,27 type RefObject,
28} from "react";28} from "react";
29import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native";29import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
30import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";30import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
31import {31import {
32 composerAttachmentUploadBlockReason,32 composerAttachmentUploadBlockReason,
...@@ -113,7 +113,6 @@ export interface ThreadComposerProps {...@@ -113,7 +113,6 @@ export interface ThreadComposerProps {
113 readonly contentMaxWidth?: number;113 readonly contentMaxWidth?: number;
114 readonly bottomInset?: number;114 readonly bottomInset?: number;
115 readonly connectionState: RemoteClientConnectionState;115 readonly connectionState: RemoteClientConnectionState;
116 readonly connectionError: string | null;
117 readonly environmentLabel: string | null;116 readonly environmentLabel: string | null;
118 readonly selectedThread: OrchestrationThreadShell;117 readonly selectedThread: OrchestrationThreadShell;
119 readonly hasCompactableConversation: boolean;118 readonly hasCompactableConversation: boolean;
...@@ -134,7 +133,6 @@ export interface ThreadComposerProps {...@@ -134,7 +133,6 @@ export interface ThreadComposerProps {
134 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;133 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
135 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;134 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
136 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;135 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
137 readonly onReconnectEnvironment: () => void;
138 readonly onExpandedChange?: (expanded: boolean) => void;136 readonly onExpandedChange?: (expanded: boolean) => void;
139 /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */137 /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */
140 readonly onEditorFocusChange?: (focused: boolean) => void;138 readonly onEditorFocusChange?: (focused: boolean) => void;
...@@ -226,72 +224,6 @@ export function ComposerSurface(props: {...@@ -226,72 +224,6 @@ export function ComposerSurface(props: {
226 );224 );
227}225}
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
295export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {227export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
296 const navigation = useNavigation();228 const navigation = useNavigation();
297 const foregroundColor = useUniwindTheme()["--color-foreground"];229 const foregroundColor = useUniwindTheme()["--color-foreground"];
...@@ -337,11 +269,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -337,11 +269,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
337 const modelUnavailable =269 const modelUnavailable =
338 props.connectionState === "connected" &&270 props.connectionState === "connected" &&
339 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);271 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);
340 const connectionStatus = composerConnectionStatus({
341 connectionError: props.connectionError,
342 connectionState: props.connectionState,
343 environmentLabel: props.environmentLabel,
344 });
345 const selectedProviderStatus = useMemo(() => {272 const selectedProviderStatus = useMemo(() => {
346 if (!props.serverConfig) return null;273 if (!props.serverConfig) return null;
347 return (274 return (
...@@ -640,13 +567,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -640,13 +567,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
640 </View>567 </View>
641 ) : null}568 ) : null}
642569
643 {connectionStatus ? (
644 <ComposerConnectionStatusPill
645 status={connectionStatus}
646 onPress={props.onReconnectEnvironment}
647 />
648 ) : null}
649
650 {modelUnavailable ? (570 {modelUnavailable ? (
651 <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>571 <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>
652 <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>572 <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";...@@ -84,8 +84,8 @@ import { PendingUserInputCard } from "./PendingUserInputCard";
84import {84import {
85 FLOATING_WORKING_CONTROL_COVERAGE,85 FLOATING_WORKING_CONTROL_COVERAGE,
86 FloatingWorkingControl,86 FloatingWorkingControl,
87 type FloatingWorkingStatus,
88} from "./floating-working-control";87} from "./floating-working-control";
88import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";
89import {89import {
90 derivePendingUserInputMaxHeight,90 derivePendingUserInputMaxHeight,
91 ESTIMATED_KEYBOARD_HEIGHT,91 ESTIMATED_KEYBOARD_HEIGHT,
...@@ -331,14 +331,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -331,14 +331,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
331 return null;331 return null;
332 }332 }
333 })();333 })();
334 // One floating pill above the composer: it reads the sync state while334 // One floating pill above the composer: it reads the connection phase while
335 // messages load, then the working timer once the feed is settled.335 // disconnected, the sync state while messages load, then the working timer
336 // once the feed is settled.
336 const floatingStatus = ((): FloatingWorkingStatus | null => {337 const floatingStatus = ((): FloatingWorkingStatus | null => {
337 if (338 const connectionStatus = connectionFloatingStatus({
338 props.connectionStateLabel !== "connected" ||339 connectionError: props.connectionError,
339 props.activePendingApproval !== null ||340 connectionState: props.connectionStateLabel,
340 props.activePendingUserInput !== null341 environmentLabel: props.environmentLabel,
341 ) {342 onReconnect: props.onReconnectEnvironment,
343 });
344 if (connectionStatus !== null) {
345 return connectionStatus;
346 }
347 if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
342 return null;348 return null;
343 }349 }
344 if (threadSyncLabel !== null) {350 if (threadSyncLabel !== null) {
...@@ -964,7 +970,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -964,7 +970,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
964 placeholder="Ask the repo agent, or run a command…"970 placeholder="Ask the repo agent, or run a command…"
965 contentMaxWidth={contentMaxWidth}971 contentMaxWidth={contentMaxWidth}
966 connectionState={props.connectionStateLabel}972 connectionState={props.connectionStateLabel}
967 connectionError={props.connectionError}
968 environmentLabel={props.environmentLabel}973 environmentLabel={props.environmentLabel}
969 selectedThread={props.selectedThread}974 selectedThread={props.selectedThread}
970 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}975 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
...@@ -981,7 +986,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -981,7 +986,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
981 onStopThread={props.onStopThread}986 onStopThread={props.onStopThread}
982 onSendMessage={handleSendMessage}987 onSendMessage={handleSendMessage}
983 onShowUsageLimits={showUsageLimits}988 onShowUsageLimits={showUsageLimits}
984 onReconnectEnvironment={props.onReconnectEnvironment}
985 onUpdateModelSelection={props.onUpdateThreadModelSelection}989 onUpdateModelSelection={props.onUpdateThreadModelSelection}
986 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}990 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
987 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}991 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
apps/mobile/src/features/threads/floating-working-control.tsx+156-39
...@@ -1,7 +1,13 @@...@@ -1,7 +1,13 @@
1import { formatDuration } from "@t3tools/shared/orchestrationTiming";1import { formatDuration } from "@t3tools/shared/orchestrationTiming";
2import { GlassContainer, GlassView } from "expo-glass-effect";2import { GlassContainer, GlassView } from "expo-glass-effect";
3import { useEffect, useState } from "react";3import { type ReactNode, useEffect, useRef, useState } from "react";
4import { ActivityIndicator, Text as SystemText, View } from "react-native";4import {
5 ActivityIndicator,
6 type LayoutChangeEvent,
7 Pressable,
8 Text as SystemText,
9 View,
10} from "react-native";
5import Animated, {11import Animated, {
6 Easing,12 Easing,
7 FadeIn,13 FadeIn,
...@@ -17,6 +23,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -17,6 +23,7 @@ import { AppText as Text } from "../../components/AppText";
17import { SymbolView } from "../../components/AppSymbol";23import { SymbolView } from "../../components/AppSymbol";
18import { ControlPill } from "../../components/ControlPill";24import { ControlPill } from "../../components/ControlPill";
19import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";25import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
26import type { FloatingWorkingStatus } from "./floating-working-status";
2027
21const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem28const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem
22// The collapsed composer capsule starts 6 below its overlay's top edge, so29// The collapsed composer capsule starts 6 below its overlay's top edge, so
...@@ -33,6 +40,10 @@ const CONTROL_TIMING = {...@@ -33,6 +40,10 @@ const CONTROL_TIMING = {
33 reduceMotion: ReduceMotion.System,40 reduceMotion: ReduceMotion.System,
34} as const;41} as const;
35const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2;42const 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
37// Expo reapplies glass after native layout and window reattachment, when UIKit48// Expo reapplies glass after native layout and window reattachment, when UIKit
38// can otherwise leave the label visible but lose the material behind it.49// can otherwise leave the label visible but lose the material behind it.
...@@ -47,15 +58,6 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);...@@ -47,15 +58,6 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);
47const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET;58const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET;
48export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP;59export 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
59export function FloatingWorkingControl(props: {61export function FloatingWorkingControl(props: {
60 readonly colorScheme: "light" | "dark";62 readonly colorScheme: "light" | "dark";
61 readonly status: FloatingWorkingStatus | null;63 readonly status: FloatingWorkingStatus | null;
...@@ -68,9 +70,6 @@ export function FloatingWorkingControl(props: {...@@ -68,9 +70,6 @@ export function FloatingWorkingControl(props: {
68 separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);70 separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);
69 }, [props.showScrollToEnd, separationProgress]);71 }, [props.showScrollToEnd, separationProgress]);
7072
71 const timerStyle = useAnimatedStyle(() => ({
72 transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],
73 }));
74 const arrowTransformStyle = useAnimatedStyle(() => ({73 const arrowTransformStyle = useAnimatedStyle(() => ({
75 transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],74 transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],
76 }));75 }));
...@@ -78,10 +77,63 @@ export function FloatingWorkingControl(props: {...@@ -78,10 +77,63 @@ export function FloatingWorkingControl(props: {
78 opacity: separationProgress.value,77 opacity: separationProgress.value,
79 }));78 }));
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
81 if (props.status === null && !props.showScrollToEnd) {118 if (props.status === null && !props.showScrollToEnd) {
82 return null;119 return null;
83 }120 }
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
85 return (137 return (
86 <Animated.View138 <Animated.View
87 pointerEvents="box-none"139 pointerEvents="box-none"
...@@ -99,11 +151,12 @@ export function FloatingWorkingControl(props: {...@@ -99,11 +151,12 @@ export function FloatingWorkingControl(props: {
99 <AnimatedGlassView151 <AnimatedGlassView
100 colorScheme={props.colorScheme}152 colorScheme={props.colorScheme}
101 glassEffectStyle="regular"153 glassEffectStyle="regular"
102 pointerEvents="none"154 isInteractive={statusInteractive}
103 className="h-11 justify-center overflow-hidden rounded-full"155 pointerEvents={statusInteractive ? "box-none" : "none"}
104 style={timerStyle}156 className="h-11 items-center justify-center overflow-hidden rounded-full"
157 style={capsuleStyle}
105 >158 >
106 <FloatingStatusLabel status={props.status} />159 {statusContent}
107 </AnimatedGlassView>160 </AnimatedGlassView>
108161
109 <AnimatedGlassView162 <AnimatedGlassView
...@@ -124,11 +177,11 @@ export function FloatingWorkingControl(props: {...@@ -124,11 +177,11 @@ export function FloatingWorkingControl(props: {
124 ) : props.status !== null ? (177 ) : props.status !== null ? (
125 <View pointerEvents="box-none" className="flex-row items-center gap-4">178 <View pointerEvents="box-none" className="flex-row items-center gap-4">
126 <Animated.View179 <Animated.View
127 pointerEvents="none"180 pointerEvents={statusInteractive ? "box-none" : "none"}
128 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"181 className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-card shadow-md shadow-black/10"
129 style={timerStyle}182 style={capsuleStyle}
130 >183 >
131 <FloatingStatusLabel status={props.status} />184 {statusContent}
132 </Animated.View>185 </Animated.View>
133186
134 <Animated.View187 <Animated.View
...@@ -169,13 +222,9 @@ export function FloatingWorkingControl(props: {...@@ -169,13 +222,9 @@ export function FloatingWorkingControl(props: {
169 );222 );
170}223}
171224
172function CompactingLabel() {225function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) {
173 return (226 return (
174 <View227 <StatusLabelRow accessibilityLabel="Compacting" className="gap-1.5" onLayout={props.onLayout}>
175 accessible
176 accessibilityLabel="Compacting"
177 className="h-11 flex-row items-center gap-1.5 px-4"
178 >
179 <SymbolView228 <SymbolView
180 name="arrow.down.right.and.arrow.up.left"229 name="arrow.down.right.and.arrow.up.left"
181 size={13}230 size={13}
...@@ -183,30 +232,98 @@ function CompactingLabel() {...@@ -183,30 +232,98 @@ function CompactingLabel() {
183 type="monochrome"232 type="monochrome"
184 />233 />
185 <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>234 <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>
186 </View>235 </StatusLabelRow>
187 );236 );
188}237}
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.
191 if (props.status.kind === "syncing") {245 if (props.status.kind === "syncing") {
192 return (246 return (
193 <View247 <StatusLabelRow
194 accessible248 key="syncing"
195 accessibilityLabel={props.status.label}249 accessibilityLabel={props.status.label}
196 className="h-11 flex-row items-center gap-2 px-4"250 className="gap-2"
251 onLayout={props.onLayout}
197 >252 >
198 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />253 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
199 <Text className="font-t3-medium text-xs text-foreground">{props.status.label}</Text>254 <Text className="font-t3-medium text-xs text-foreground">{props.status.label}</Text>
200 </View>255 </StatusLabelRow>
201 );256 );
202 }257 }
203 if (props.status.kind === "compacting") {258 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 );
205 }281 }
206 return <WorkingDuration startedAt={props.status.startedAt} />;282 return (
283 <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
284 );
207}285}
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}) {
210 const [nowMs, setNowMs] = useState(() => Date.now());327 const [nowMs, setNowMs] = useState(() => Date.now());
211328
212 useEffect(() => {329 useEffect(() => {
...@@ -219,7 +336,7 @@ function WorkingDuration(props: { readonly startedAt: string }) {...@@ -219,7 +336,7 @@ function WorkingDuration(props: { readonly startedAt: string }) {
219 const label = `Working for ${duration}`;336 const label = `Working for ${duration}`;
220337
221 return (338 return (
222 <View accessible accessibilityLabel={label} className="h-11 flex-row items-center px-4">339 <StatusLabelRow accessibilityLabel={label} onLayout={props.onLayout}>
223 <Text className="font-t3-medium text-xs text-foreground">Working for </Text>340 <Text className="font-t3-medium text-xs text-foreground">Working for </Text>
224 <SystemText341 <SystemText
225 className="text-xs text-foreground"342 className="text-xs text-foreground"
...@@ -227,7 +344,7 @@ function WorkingDuration(props: { readonly startedAt: string }) {...@@ -227,7 +344,7 @@ function WorkingDuration(props: { readonly startedAt: string }) {
227 >344 >
228 {duration}345 {duration}
229 </SystemText>346 </SystemText>
230 </View>347 </StatusLabelRow>
231 );348 );
232}349}
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}