Unified · Split
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | useState, |
| 27 | 27 | type RefObject, |
| 28 | 28 | } 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"; | |
| 30 | 30 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 31 | 31 | import { |
| 32 | 32 | composerAttachmentUploadBlockReason, |
| @@ -113,7 +113,6 @@ export interface ThreadComposerProps { · 80 unmodified lines | ||
| 113 | 113 | readonly contentMaxWidth?: number; |
| 114 | 114 | readonly bottomInset?: number; |
| 115 | 115 | readonly connectionState: RemoteClientConnectionState; |
| 116 | − readonly connectionError: string | null; | |
| 117 | 116 | readonly environmentLabel: string | null; |
| 118 | 117 | readonly selectedThread: OrchestrationThreadShell; |
| 119 | 118 | readonly hasCompactableConversation: boolean; |
| @@ -134,7 +133,6 @@ export interface ThreadComposerProps { · 14 unmodified lines | ||
| 134 | 133 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; |
| 135 | 134 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 136 | 135 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| 137 | − readonly onReconnectEnvironment: () => void; | |
| 138 | 136 | readonly onExpandedChange?: (expanded: boolean) => void; |
| 139 | 137 | /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ |
| 140 | 138 | readonly onEditorFocusChange?: (focused: boolean) => void; |
| @@ -226,72 +224,6 @@ export function ComposerSurface(props: { · 85 unmodified lines | ||
| 226 | 224 | ); |
| 227 | 225 | } |
| 228 | 226 | |
| 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 | − | |
| 295 | 227 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 296 | 228 | const navigation = useNavigation(); |
| 297 | 229 | const foregroundColor = useUniwindTheme()["--color-foreground"]; |
| @@ -337,11 +269,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 39 unmodified lines | ||
| 337 | 269 | const modelUnavailable = |
| 338 | 270 | props.connectionState === "connected" && |
| 339 | 271 | isModelSelectionUnavailable(props.serverConfig, currentModelSelection); |
| 340 | − const connectionStatus = composerConnectionStatus({ | |
| 341 | − connectionError: props.connectionError, | |
| 342 | − connectionState: props.connectionState, | |
| 343 | − environmentLabel: props.environmentLabel, | |
| 344 | − }); | |
| 345 | 272 | const selectedProviderStatus = useMemo(() => { |
| 346 | 273 | if (!props.serverConfig) return null; |
| 347 | 274 | return ( |
| @@ -640,13 +567,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 292 unmodified lines | ||
| 640 | 567 | </View> |
| 641 | 568 | ) : null} |
| 642 | 569 | |
| 643 | − {connectionStatus ? ( | |
| 644 | − <ComposerConnectionStatusPill | |
| 645 | − status={connectionStatus} | |
| 646 | − onPress={props.onReconnectEnvironment} | |
| 647 | − /> | |
| 648 | − ) : null} | |
| 649 | − | |
| 650 | 570 | {modelUnavailable ? ( |
| 651 | 571 | <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}> |
| 652 | 572 | <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text> |
| @@ -84,8 +84,8 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; · 83 unmodified lines | ||
| 84 | 84 | import { |
| 85 | 85 | FLOATING_WORKING_CONTROL_COVERAGE, |
| 86 | 86 | FloatingWorkingControl, |
| 87 | − type FloatingWorkingStatus, | |
| 88 | 87 | } from "./floating-working-control"; |
| 88 | +import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status"; | |
| 89 | 89 | import { |
| 90 | 90 | derivePendingUserInputMaxHeight, |
| 91 | 91 | ESTIMATED_KEYBOARD_HEIGHT, |
| @@ -331,14 +331,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 239 unmodified lines | ||
| 331 | 331 | return null; |
| 332 | 332 | } |
| 333 | 333 | })(); |
| 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. | |
| 336 | 337 | 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) { | |
| 342 | 348 | return null; |
| 343 | 349 | } |
| 344 | 350 | if (threadSyncLabel !== null) { |
| @@ -964,7 +970,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 619 unmodified lines | ||
| 964 | 970 | placeholder="Ask the repo agent, or run a command…" |
| 965 | 971 | contentMaxWidth={contentMaxWidth} |
| 966 | 972 | connectionState={props.connectionStateLabel} |
| 967 | − connectionError={props.connectionError} | |
| 968 | 973 | environmentLabel={props.environmentLabel} |
| 969 | 974 | selectedThread={props.selectedThread} |
| 970 | 975 | hasCompactableConversation={hasCompactableConversation && !props.isCompacting} |
| @@ -981,7 +986,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 10 unmodified lines | ||
| 981 | 986 | onStopThread={props.onStopThread} |
| 982 | 987 | onSendMessage={handleSendMessage} |
| 983 | 988 | onShowUsageLimits={showUsageLimits} |
| 984 | − onReconnectEnvironment={props.onReconnectEnvironment} | |
| 985 | 989 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
| 986 | 990 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} |
| 987 | 991 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 2 | 2 | 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"; | |
| 5 | 11 | import Animated, { |
| 6 | 12 | Easing, |
| 7 | 13 | FadeIn, |
| @@ -17,6 +23,7 @@ import { AppText as Text } from "../../components/AppText"; · 9 unmodified lines | ||
| 17 | 23 | import { SymbolView } from "../../components/AppSymbol"; |
| 18 | 24 | import { ControlPill } from "../../components/ControlPill"; |
| 19 | 25 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 26 | +import type { FloatingWorkingStatus } from "./floating-working-status"; | |
| 20 | 27 | |
| 21 | 28 | const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem |
| 22 | 29 | // The collapsed composer capsule starts 6 below its overlay's top edge, so |
| @@ -33,6 +40,10 @@ const CONTROL_TIMING = { · 10 unmodified lines | ||
| 33 | 40 | reduceMotion: ReduceMotion.System, |
| 34 | 41 | } as const; |
| 35 | 42 | 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); | |
| 36 | 47 | |
| 37 | 48 | // Expo reapplies glass after native layout and window reattachment, when UIKit |
| 38 | 49 | // can otherwise leave the label visible but lose the material behind it. |
| @@ -47,15 +58,6 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); · 8 unmodified lines | ||
| 47 | 58 | const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; |
| 48 | 59 | export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; |
| 49 | 60 | |
| 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 | − | |
| 59 | 61 | export function FloatingWorkingControl(props: { |
| 60 | 62 | readonly colorScheme: "light" | "dark"; |
| 61 | 63 | readonly status: FloatingWorkingStatus | null; |
| @@ -68,9 +70,6 @@ export function FloatingWorkingControl(props: { · 6 unmodified lines | ||
| 68 | 70 | separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); |
| 69 | 71 | }, [props.showScrollToEnd, separationProgress]); |
| 70 | 72 | |
| 71 | − const timerStyle = useAnimatedStyle(() => ({ | |
| 72 | − transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], | |
| 73 | − })); | |
| 74 | 73 | const arrowTransformStyle = useAnimatedStyle(() => ({ |
| 75 | 74 | transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], |
| 76 | 75 | })); |
| @@ -78,10 +77,63 @@ export function FloatingWorkingControl(props: { · 1 unmodified line | ||
| 78 | 77 | opacity: separationProgress.value, |
| 79 | 78 | })); |
| 80 | 79 | |
| 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 | 118 | if (props.status === null && !props.showScrollToEnd) { |
| 82 | 119 | return null; |
| 83 | 120 | } |
| 84 | 121 | |
| 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 | 137 | return ( |
| 86 | 138 | <Animated.View |
| 87 | 139 | pointerEvents="box-none" |
| @@ -99,11 +151,12 @@ export function FloatingWorkingControl(props: { · 11 unmodified lines | ||
| 99 | 151 | <AnimatedGlassView |
| 100 | 152 | colorScheme={props.colorScheme} |
| 101 | 153 | 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} | |
| 105 | 158 | > |
| 106 | − <FloatingStatusLabel status={props.status} /> | |
| 159 | + {statusContent} | |
| 107 | 160 | </AnimatedGlassView> |
| 108 | 161 | |
| 109 | 162 | <AnimatedGlassView |
| @@ -124,11 +177,11 @@ export function FloatingWorkingControl(props: { · 14 unmodified lines | ||
| 124 | 177 | ) : props.status !== null ? ( |
| 125 | 178 | <View pointerEvents="box-none" className="flex-row items-center gap-4"> |
| 126 | 179 | <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} | |
| 130 | 183 | > |
| 131 | − <FloatingStatusLabel status={props.status} /> | |
| 184 | + {statusContent} | |
| 132 | 185 | </Animated.View> |
| 133 | 186 | |
| 134 | 187 | <Animated.View |
| @@ -169,13 +222,9 @@ export function FloatingWorkingControl(props: { · 34 unmodified lines | ||
| 169 | 222 | ); |
| 170 | 223 | } |
| 171 | 224 | |
| 172 | −function CompactingLabel() { | |
| 225 | +function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) { | |
| 173 | 226 | 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}> | |
| 179 | 228 | <SymbolView |
| 180 | 229 | name="arrow.down.right.and.arrow.up.left" |
| 181 | 230 | size={13} |
| @@ -183,30 +232,98 @@ function CompactingLabel() { · 1 unmodified line | ||
| 183 | 232 | type="monochrome" |
| 184 | 233 | /> |
| 185 | 234 | <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text> |
| 186 | − </View> | |
| 235 | + </StatusLabelRow> | |
| 187 | 236 | ); |
| 188 | 237 | } |
| 189 | 238 | |
| 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. | |
| 191 | 245 | if (props.status.kind === "syncing") { |
| 192 | 246 | return ( |
| 193 | − <View | |
| 194 | − accessible | |
| 247 | + <StatusLabelRow | |
| 248 | + key="syncing" | |
| 195 | 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 | 253 | <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> |
| 199 | 254 | <Text className="font-t3-medium text-xs text-foreground">{props.status.label}</Text> |
| 200 | − </View> | |
| 255 | + </StatusLabelRow> | |
| 201 | 256 | ); |
| 202 | 257 | } |
| 203 | 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 | } |
| 208 | 286 | |
| 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 | +}) { | |
| 210 | 327 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| 211 | 328 | |
| 212 | 329 | useEffect(() => { |
| @@ -219,7 +336,7 @@ function WorkingDuration(props: { readonly startedAt: string }) { · 6 unmodified lines | ||
| 219 | 336 | const label = `Working for ${duration}`; |
| 220 | 337 | |
| 221 | 338 | return ( |
| 222 | − <View accessible accessibilityLabel={label} className="h-11 flex-row items-center px-4"> | |
| 339 | + <StatusLabelRow accessibilityLabel={label} onLayout={props.onLayout}> | |
| 223 | 340 | <Text className="font-t3-medium text-xs text-foreground">Working for </Text> |
| 224 | 341 | <SystemText |
| 225 | 342 | className="text-xs text-foreground" |
| @@ -227,7 +344,7 @@ function WorkingDuration(props: { readonly startedAt: string }) { · 1 unmodified line | ||
| 227 | 344 | > |
| 228 | 345 | {duration} |
| 229 | 346 | </SystemText> |
| 230 | − </View> | |
| 347 | + </StatusLabelRow> | |
| 231 | 348 | ); |
| 232 | 349 | } |
| 233 | 350 | |
| @@ -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 | +}); | |
| @@ -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 | +} | |