| author | |
| committer | |
| log | bb5748bfac0fbeffe690875fa485a5cbe7b99132 |
| tree | fec60fbaa39c705d9b094a11c8d05b15530e9a11 |
| parent | e1230d6031bc55a21668818d0b585d8887b88579 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 { |
| 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 { |
| 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: { |
| 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 |
| 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 |
| 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> |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14-10| ... | ... | @@ -84,8 +84,8 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 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 |
| 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 |
| 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 |
| 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} |
apps/mobile/src/features/threads/floating-working-control.tsx+156-39| ... | ... | @@ -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"; |
| 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 = { |
| 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); |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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() { |
| 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 }) { |
| 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 }) { |
| 227 | 344 | > |
| 228 | 345 | {duration} |
| 229 | 346 | </SystemText> |
| 230 | </View> | |
| 347 | </StatusLabelRow> | |
| 231 | 348 | ); |
| 232 | 349 | } |
| 233 | 350 |
apps/mobile/src/features/threads/floating-working-status.test.ts created+66| ... | ... | @@ -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 created+62| ... | ... | @@ -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 | } |