| 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,7 +26,7 @@ import { |
| 26 | useState, | 26 | useState, |
| 27 | type RefObject, | 27 | type RefObject, |
| 28 | } from "react"; | 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 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 30 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 31 | import { | 31 | import { |
| 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 | } |
| 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 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 227 | export 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} |
| 642 | 569 | ||
| 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"; |
| 84 | import { | 84 | import { |
| 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"; |
| 88 | import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status"; | ||
| 89 | import { | 89 | import { |
| 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 while | 334 | // 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 !== null | 341 | 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 @@ |
| 1 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 1 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 2 | import { GlassContainer, GlassView } from "expo-glass-effect"; | 2 | import { GlassContainer, GlassView } from "expo-glass-effect"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 4 | import { ActivityIndicator, Text as SystemText, View } from "react-native"; | 4 | import { |
| 5 | ActivityIndicator, | ||
| 6 | type LayoutChangeEvent, | ||
| 7 | Pressable, | ||
| 8 | Text as SystemText, | ||
| 9 | View, | ||
| 10 | } from "react-native"; | ||
| 5 | import Animated, { | 11 | import 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"; |
| 17 | import { SymbolView } from "../../components/AppSymbol"; | 23 | import { SymbolView } from "../../components/AppSymbol"; |
| 18 | import { ControlPill } from "../../components/ControlPill"; | 24 | import { ControlPill } from "../../components/ControlPill"; |
| 19 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 25 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 26 | import type { FloatingWorkingStatus } from "./floating-working-status"; | ||
| 20 | 27 | ||
| 21 | const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem | 28 | const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem |
| 22 | // The collapsed composer capsule starts 6 below its overlay's top edge, so | 29 | // 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; |
| 35 | const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; | 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 | // Expo reapplies glass after native layout and window reattachment, when UIKit | 48 | // 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); |
| 47 | const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; | 58 | const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; |
| 48 | export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; | 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 | export function FloatingWorkingControl(props: { | 61 | export 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]); |
| 70 | 72 | ||
| 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 | })); |
| 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 | if (props.status === null && !props.showScrollToEnd) { | 118 | if (props.status === null && !props.showScrollToEnd) { |
| 82 | return null; | 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 | return ( | 137 | return ( |
| 86 | <Animated.View | 138 | <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 | <AnimatedGlassView | 151 | <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> |
| 108 | 161 | ||
| 109 | <AnimatedGlassView | 162 | <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.View | 179 | <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> |
| 133 | 186 | ||
| 134 | <Animated.View | 187 | <Animated.View |
| ... | @@ -169,13 +222,9 @@ export function FloatingWorkingControl(props: { | ... | @@ -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 | return ( | 226 | return ( |
| 174 | <View | 227 | <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 | <SymbolView | 228 | <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 | } |
| 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 | if (props.status.kind === "syncing") { | 245 | if (props.status.kind === "syncing") { |
| 192 | return ( | 246 | return ( |
| 193 | <View | 247 | <StatusLabelRow |
| 194 | accessible | 248 | 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 | } |
| 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 | const [nowMs, setNowMs] = useState(() => Date.now()); | 327 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| 211 | 328 | ||
| 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}`; |
| 220 | 337 | ||
| 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 | <SystemText | 341 | <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 | } |
| 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 | } | ||