From 9eeb25316370e48fd3e9d9d33b6a85cc85f3113f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:02:03 -0700 Subject: [PATCH 1/6] fix(mobile): show connection status in the floating working pill The composer rendered its own reconnecting/offline pill that swapped with the working pill when the connection dropped. Fold the connection phase into the single floating status pill so the label swaps in place and the capsule animates between widths. Co-Authored-By: Claude Code --- .../src/features/threads/ThreadComposer.tsx | 82 +--------- .../features/threads/ThreadDetailScreen.tsx | 23 +-- .../threads/floating-working-control.tsx | 153 +++++++++++++++--- 3 files changed, 145 insertions(+), 113 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a241866ae601..57d2740b53d3 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -26,7 +26,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -113,7 +113,6 @@ export interface ThreadComposerProps { readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; - readonly connectionError: string | null; readonly environmentLabel: string | null; readonly selectedThread: OrchestrationThreadShell; readonly hasCompactableConversation: boolean; @@ -134,7 +133,6 @@ export interface ThreadComposerProps { readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; - readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void; @@ -226,72 +224,6 @@ export function ComposerSurface(props: { ); } -type ComposerStatusPillState = { - readonly kind: "unavailable" | "reconnecting"; - readonly label: string; -}; - -function composerConnectionStatus(input: { - readonly connectionError: string | null; - readonly connectionState: RemoteClientConnectionState; - readonly environmentLabel: string | null; -}): ComposerStatusPillState | null { - const environmentLabel = input.environmentLabel ?? "Environment"; - - switch (input.connectionState) { - case "connecting": - case "reconnecting": - return { - kind: "reconnecting", - label: - input.connectionError === null - ? `Reconnecting to ${environmentLabel}...` - : `Failed to connect. Retrying ${environmentLabel}...`, - }; - case "offline": - return { kind: "unavailable", label: "You are offline" }; - case "error": - return { - kind: "unavailable", - label: input.connectionError - ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` - : `Failed to connect to ${environmentLabel}`, - }; - case "available": - return { kind: "unavailable", label: `${environmentLabel} is not connected` }; - case "connected": - return null; - } -} - -const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { - readonly onPress: () => void; - readonly status: ComposerStatusPillState; -}) { - const isReconnecting = props.status.kind === "reconnecting"; - return ( - - - {isReconnecting ? ( - - ) : ( - - )} - - {props.status.label} - - - - ); -}); - export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; @@ -337,11 +269,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const modelUnavailable = props.connectionState === "connected" && isModelSelectionUnavailable(props.serverConfig, currentModelSelection); - const connectionStatus = composerConnectionStatus({ - connectionError: props.connectionError, - connectionState: props.connectionState, - environmentLabel: props.environmentLabel, - }); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( @@ -640,13 +567,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - {connectionStatus ? ( - - ) : null} - {modelUnavailable ? ( Model unavailable. Open model settings. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8a6be2a1da98..7becd12373a7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -82,6 +82,7 @@ import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { + connectionFloatingStatus, FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, type FloatingWorkingStatus, @@ -331,14 +332,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } })(); - // One floating pill above the composer: it reads the sync state while - // messages load, then the working timer once the feed is settled. + // One floating pill above the composer: it reads the connection phase while + // disconnected, the sync state while messages load, then the working timer + // once the feed is settled. const floatingStatus = ((): FloatingWorkingStatus | null => { - if ( - props.connectionStateLabel !== "connected" || - props.activePendingApproval !== null || - props.activePendingUserInput !== null - ) { + const connectionStatus = connectionFloatingStatus({ + connectionError: props.connectionError, + connectionState: props.connectionStateLabel, + environmentLabel: props.environmentLabel, + onReconnect: props.onReconnectEnvironment, + }); + if (connectionStatus !== null) { + return connectionStatus; + } + if (props.activePendingApproval !== null || props.activePendingUserInput !== null) { return null; } if (threadSyncLabel !== null) { @@ -964,7 +971,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread placeholder="Ask the repo agent, or run a command…" contentMaxWidth={contentMaxWidth} connectionState={props.connectionStateLabel} - connectionError={props.connectionError} environmentLabel={props.environmentLabel} selectedThread={props.selectedThread} hasCompactableConversation={hasCompactableConversation && !props.isCompacting} @@ -981,7 +987,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onStopThread={props.onStopThread} onSendMessage={handleSendMessage} onShowUsageLimits={showUsageLimits} - onReconnectEnvironment={props.onReconnectEnvironment} onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index a429044fccdd..1516cbdea09c 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,11 +1,13 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; -import { useEffect, useState } from "react"; -import { ActivityIndicator, Text as SystemText, View } from "react-native"; +import { type ReactNode, useEffect, useState } from "react"; +import { ActivityIndicator, Pressable, Text as SystemText, View } from "react-native"; import Animated, { Easing, FadeIn, FadeOut, + LinearTransition, ReduceMotion, useAnimatedStyle, useSharedValue, @@ -33,6 +35,14 @@ const CONTROL_TIMING = { reduceMotion: ReduceMotion.System, } as const; const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; +// The label swaps between syncing, compacting, and working while the capsule +// stays mounted, so the capsule animates to the new label's width and the +// labels cross-fade instead of the pill snapping between sizes. +const CAPSULE_LAYOUT = LinearTransition.duration(CONTROL_TIMING.duration) + .easing(CONTROL_TIMING.easing) + .reduceMotion(ReduceMotion.System); +const LABEL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); +const LABEL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); // Expo reapplies glass after native layout and window reattachment, when UIKit // can otherwise leave the label visible but lose the material behind it. @@ -48,13 +58,61 @@ const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_I export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; /** - * What the floating pill says. Syncing and working share one element so the - * label swaps in place instead of one pill fading out for another. + * What the floating pill says. Connection, syncing, and working share one + * element so the label swaps in place instead of one pill fading out for + * another. The connection variant is tappable and triggers a reconnect. */ export type FloatingWorkingStatus = | { readonly kind: "working"; readonly startedAt: string } | { readonly kind: "syncing"; readonly label: string } - | { readonly kind: "compacting" }; + | { readonly kind: "compacting" } + | { + readonly kind: "connection"; + readonly tone: "reconnecting" | "unavailable"; + readonly label: string; + readonly onPress: () => void; + }; + +export function connectionFloatingStatus(input: { + readonly connectionError: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly environmentLabel: string | null; + readonly onReconnect: () => void; +}): FloatingWorkingStatus | null { + const environmentLabel = input.environmentLabel ?? "Environment"; + const unavailable = (label: string): FloatingWorkingStatus => ({ + kind: "connection", + tone: "unavailable", + label, + onPress: input.onReconnect, + }); + + switch (input.connectionState) { + case "connecting": + case "reconnecting": + return { + kind: "connection", + tone: "reconnecting", + label: + input.connectionError === null + ? `Reconnecting to ${environmentLabel}...` + : `Failed to connect. Retrying ${environmentLabel}...`, + onPress: input.onReconnect, + }; + case "offline": + return unavailable("You are offline"); + case "error": + return unavailable( + input.connectionError + ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` + : `Failed to connect to ${environmentLabel}`, + ); + case "available": + return unavailable(`${environmentLabel} is not connected`); + case "connected": + return null; + } +} export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; @@ -82,6 +140,10 @@ export function FloatingWorkingControl(props: { return null; } + // Only the connection label is a button (tap to reconnect); the others + // pass touches through to the feed like before. + const statusInteractive = props.status?.kind === "connection"; + return ( @@ -124,9 +188,10 @@ export function FloatingWorkingControl(props: { ) : props.status !== null ? ( @@ -171,11 +236,7 @@ export function FloatingWorkingControl(props: { function CompactingLabel() { return ( - + Compacting… - + ); } function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) { + // Keyed by kind so a swap mounts a fresh row and the two cross-fade while + // the capsule's layout transition carries the width change. if (props.status.kind === "syncing") { return ( - + {props.status.label} - + ); } if (props.status.kind === "compacting") { - return ; + return ; } - return ; + if (props.status.kind === "connection") { + return ( + + {props.status.tone === "reconnecting" ? ( + + ) : ( + + )} + + {props.status.label} + + + ); + } + return ; +} + +function StatusLabelRow(props: { + readonly accessibilityLabel: string; + readonly accessibilityRole?: "button"; + readonly className?: string; + readonly children: ReactNode; + readonly onPress?: () => void; +}) { + const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; + return ( + + {props.onPress ? ( + + {props.children} + + ) : ( + + {props.children} + + )} + + ); } function WorkingDuration(props: { readonly startedAt: string }) { @@ -219,7 +326,7 @@ function WorkingDuration(props: { readonly startedAt: string }) { const label = `Working for ${duration}`; return ( - + Working for {duration} - + ); } From 9ab544a53dda19b1ef350f37b1997b47ddf23f3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:34:24 -0700 Subject: [PATCH 2/6] fix(mobile): keep the floating pill centered while its label swaps Co-Authored-By: Claude Code --- .../threads/floating-working-control.tsx | 129 +++++++++++++----- 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 1516cbdea09c..e2779ef3698a 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,13 +1,18 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; -import { type ReactNode, useEffect, useState } from "react"; -import { ActivityIndicator, Pressable, Text as SystemText, View } from "react-native"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + type LayoutChangeEvent, + Pressable, + Text as SystemText, + View, +} from "react-native"; import Animated, { Easing, FadeIn, FadeOut, - LinearTransition, ReduceMotion, useAnimatedStyle, useSharedValue, @@ -35,12 +40,6 @@ const CONTROL_TIMING = { reduceMotion: ReduceMotion.System, } as const; const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; -// The label swaps between syncing, compacting, and working while the capsule -// stays mounted, so the capsule animates to the new label's width and the -// labels cross-fade instead of the pill snapping between sizes. -const CAPSULE_LAYOUT = LinearTransition.duration(CONTROL_TIMING.duration) - .easing(CONTROL_TIMING.easing) - .reduceMotion(ReduceMotion.System); const LABEL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const LABEL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); @@ -126,9 +125,6 @@ export function FloatingWorkingControl(props: { separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); }, [props.showScrollToEnd, separationProgress]); - const timerStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], - })); const arrowTransformStyle = useAnimatedStyle(() => ({ transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], })); @@ -136,6 +132,40 @@ export function FloatingWorkingControl(props: { opacity: separationProgress.value, })); + // The label swaps between connection, syncing, compacting, and working while + // the capsule stays mounted. A layout transition on the capsule would move + // its left edge, and children laid out from that edge slide with it, so the + // pill reads as shifting sideways. Instead the labels hang off a fixed + // midpoint anchor and the capsule animates its width to the measured label, + // shrinking and growing symmetrically under text that stays put. + const capsuleWidth = useSharedValue(null); + const measuredWidthRef = useRef(null); + const handleLabelLayout = (event: LayoutChangeEvent) => { + const width = event.nativeEvent.layout.width; + if (width === measuredWidthRef.current) { + return; + } + const first = measuredWidthRef.current === null; + measuredWidthRef.current = width; + capsuleWidth.value = first ? width : withTiming(width, CONTROL_TIMING); + }; + // Forget the width while no label is shown so the next one appears at its + // own size instead of animating from the previous label's. + const hasStatus = props.status !== null; + useEffect(() => { + if (!hasStatus) { + measuredWidthRef.current = null; + capsuleWidth.value = null; + } + }, [capsuleWidth, hasStatus]); + // Hidden until the first measurement lands so the capsule never paints at + // zero width around a clipped label. + const capsuleStyle = useAnimatedStyle(() => ({ + width: capsuleWidth.value ?? undefined, + opacity: capsuleWidth.value === null ? 0 : 1, + transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], + })); + if (props.status === null && !props.showScrollToEnd) { return null; } @@ -143,6 +173,15 @@ export function FloatingWorkingControl(props: { // Only the connection label is a button (tap to reconnect); the others // pass touches through to the feed like before. const statusInteractive = props.status?.kind === "connection"; + // A zero-width anchor at the capsule's midpoint. Labels are centered on it + // and never clipped, so an incoming wider label sits at its final position + // while the capsule catches up underneath. + const statusLabel = + props.status !== null ? ( + + + + ) : null; return ( - + {statusLabel} - + {statusLabel} void }) { return ( - + void; +}) { // Keyed by kind so a swap mounts a fresh row and the two cross-fade while - // the capsule's layout transition carries the width change. + // the capsule animates to the new row's measured width. if (props.status.kind === "syncing") { return ( - + {props.status.label} ); } if (props.status.kind === "compacting") { - return ; + return ; } if (props.status.kind === "connection") { return ( @@ -269,6 +314,7 @@ function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) accessibilityLabel={props.status.label} accessibilityRole="button" className="gap-2" + onLayout={props.onLayout} onPress={props.status.onPress} > {props.status.tone === "reconnecting" ? ( @@ -282,19 +328,35 @@ function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) ); } - return ; + return ( + + ); } +// Each row is absolutely centered on the capsule's midpoint anchor, so an +// exiting row fading out never shifts the incoming one. function StatusLabelRow(props: { readonly accessibilityLabel: string; readonly accessibilityRole?: "button"; readonly className?: string; readonly children: ReactNode; + readonly onLayout: (event: LayoutChangeEvent) => void; readonly onPress?: () => void; }) { const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; + const [width, setWidth] = useState(null); + const handleLayout = (event: LayoutChangeEvent) => { + setWidth(event.nativeEvent.layout.width); + props.onLayout(event); + }; return ( - + {props.onPress ? ( void; +}) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -326,7 +391,7 @@ function WorkingDuration(props: { readonly startedAt: string }) { const label = `Working for ${duration}`; return ( - + Working for Date: Sun, 6 Sep 2026 14:12:12 -0700 Subject: [PATCH 3/6] fix(mobile): animate the floating pill width from a plain wrapper Co-Authored-By: Claude Code --- .../threads/floating-working-control.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index e2779ef3698a..537f65e6c18d 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -161,7 +161,7 @@ export function FloatingWorkingControl(props: { // Hidden until the first measurement lands so the capsule never paints at // zero width around a clipped label. const capsuleStyle = useAnimatedStyle(() => ({ - width: capsuleWidth.value ?? undefined, + width: capsuleWidth.value ?? CONTROL_HEIGHT, opacity: capsuleWidth.value === null ? 0 : 1, transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], })); @@ -197,16 +197,22 @@ export function FloatingWorkingControl(props: { pointerEvents="box-none" className="flex-row items-center gap-4" > - + {statusLabel} - + Date: Sun, 6 Sep 2026 14:20:03 -0700 Subject: [PATCH 4/6] fix(mobile): center floating pill labels through flex, not self-measurement Co-Authored-By: Claude Code --- .../threads/floating-working-control.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 537f65e6c18d..8fc49a363f9e 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -40,8 +40,10 @@ const CONTROL_TIMING = { reduceMotion: ReduceMotion.System, } as const; const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; -const LABEL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); -const LABEL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); +// Both rows share the same centered anchor, so the outgoing one clears fast and +// the incoming one waits for it to be mostly gone before it starts to show. +const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System); +const LABEL_EXITING = FadeOut.duration(100).reduceMotion(ReduceMotion.System); // Expo reapplies glass after native layout and window reattachment, when UIKit // can otherwise leave the label visible but lose the material behind it. @@ -173,12 +175,16 @@ export function FloatingWorkingControl(props: { // Only the connection label is a button (tap to reconnect); the others // pass touches through to the feed like before. const statusInteractive = props.status?.kind === "connection"; - // A zero-width anchor at the capsule's midpoint. Labels are centered on it - // and never clipped, so an incoming wider label sits at its final position - // while the capsule catches up underneath. + // A zero-width anchor at the capsule's midpoint. Yoga centers an absolute + // child with no insets on the parent's justify-content, so every label row + // lands centered on the anchor without measuring itself, and the capsule + // clips whatever the label overhangs while it catches up. const statusLabel = props.status !== null ? ( - + ) : null; @@ -201,7 +207,7 @@ export function FloatingWorkingControl(props: { view only fills it, since it does not follow animated layout props. */} {statusLabel} @@ -350,18 +356,12 @@ function StatusLabelRow(props: { readonly onPress?: () => void; }) { const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; - const [width, setWidth] = useState(null); - const handleLayout = (event: LayoutChangeEvent) => { - setWidth(event.nativeEvent.layout.width); - props.onLayout(event); - }; return ( {props.onPress ? ( Date: Sun, 6 Sep 2026 17:17:20 -0700 Subject: [PATCH 5/6] fix(mobile): keep the floating pill's glass visible while it resizes The native glass view only takes a size from a real layout pass, so driving its width from an animated style left the material stuck at its mounted size: the pill rendered as a bare label with a stray circle behind it until the first label swap forced a re-layout. An in-flow sizer now carries the animated width and the glass capsule takes its size from that. Co-Authored-By: Claude Code --- .../threads/floating-working-control.tsx | 64 +++++++++---------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 8fc49a363f9e..e0545a0107b0 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -136,10 +136,15 @@ export function FloatingWorkingControl(props: { // The label swaps between connection, syncing, compacting, and working while // the capsule stays mounted. A layout transition on the capsule would move - // its left edge, and children laid out from that edge slide with it, so the - // pill reads as shifting sideways. Instead the labels hang off a fixed - // midpoint anchor and the capsule animates its width to the measured label, - // shrinking and growing symmetrically under text that stays put. + // its left edge, and labels laid out from that edge slide with it, so the pill + // reads as shifting sideways. Instead an in-flow sizer animates to the + // measured label width and the capsule takes its size from that, while the + // labels sit centered on top. The row re-centers as the capsule grows, so its + // midpoint never moves and the text underneath stays put. + // + // The sizer has to carry the width rather than the capsule itself: the native + // glass view only picks up a size from a real layout pass, so an animated + // width set straight on it leaves the glass stuck at its mounted size. const capsuleWidth = useSharedValue(null); const measuredWidthRef = useRef(null); const handleLabelLayout = (event: LayoutChangeEvent) => { @@ -160,13 +165,12 @@ export function FloatingWorkingControl(props: { capsuleWidth.value = null; } }, [capsuleWidth, hasStatus]); - // Hidden until the first measurement lands so the capsule never paints at - // zero width around a clipped label. const capsuleStyle = useAnimatedStyle(() => ({ - width: capsuleWidth.value ?? CONTROL_HEIGHT, - opacity: capsuleWidth.value === null ? 0 : 1, transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], })); + // Zero until the first measurement lands, so the capsule never paints around + // a label it has not sized to yet. + const capsuleSizerStyle = useAnimatedStyle(() => ({ width: capsuleWidth.value ?? 0 })); if (props.status === null && !props.showScrollToEnd) { return null; @@ -175,18 +179,16 @@ export function FloatingWorkingControl(props: { // Only the connection label is a button (tap to reconnect); the others // pass touches through to the feed like before. const statusInteractive = props.status?.kind === "connection"; - // A zero-width anchor at the capsule's midpoint. Yoga centers an absolute - // child with no insets on the parent's justify-content, so every label row - // lands centered on the anchor without measuring itself, and the capsule - // clips whatever the label overhangs while it catches up. - const statusLabel = + // Yoga centers an absolute child that has no insets on its parent's align and + // justify, so each label row lands centered on the capsule without measuring + // itself, and the capsule clips whatever a wider label overhangs while it + // catches up. + const statusContent = props.status !== null ? ( - + <> + - + ) : null; return ( @@ -203,22 +205,16 @@ export function FloatingWorkingControl(props: { pointerEvents="box-none" className="flex-row items-center gap-4" > - {/* A plain animated wrapper owns the animated width; the native glass - view only fills it, since it does not follow animated layout props. */} - - - {statusLabel} - + {statusContent} + - {statusLabel} + {statusContent} Date: Sun, 6 Sep 2026 17:58:48 -0700 Subject: [PATCH 6/6] refactor(mobile): extract the floating pill's connection status mapping Moves the phase-to-pill mapping into a pure module so it can be tested without standing up the native view stack, and covers each connection phase and the reconnect handler. Co-Authored-By: Claude Code --- .../features/threads/ThreadDetailScreen.tsx | 3 +- .../threads/floating-working-control.tsx | 59 +---------------- .../threads/floating-working-status.test.ts | 66 +++++++++++++++++++ .../threads/floating-working-status.ts | 62 +++++++++++++++++ 4 files changed, 130 insertions(+), 60 deletions(-) create mode 100644 apps/mobile/src/features/threads/floating-working-status.test.ts create mode 100644 apps/mobile/src/features/threads/floating-working-status.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 7becd12373a7..57a93e0cff22 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -82,11 +82,10 @@ import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { - connectionFloatingStatus, FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, - type FloatingWorkingStatus, } from "./floating-working-control"; +import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index e0545a0107b0..26f321517baa 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,4 +1,3 @@ -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; import { type ReactNode, useEffect, useRef, useState } from "react"; @@ -24,6 +23,7 @@ import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import type { FloatingWorkingStatus } from "./floating-working-status"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -58,63 +58,6 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; -/** - * What the floating pill says. Connection, syncing, and working share one - * element so the label swaps in place instead of one pill fading out for - * another. The connection variant is tappable and triggers a reconnect. - */ -export type FloatingWorkingStatus = - | { readonly kind: "working"; readonly startedAt: string } - | { readonly kind: "syncing"; readonly label: string } - | { readonly kind: "compacting" } - | { - readonly kind: "connection"; - readonly tone: "reconnecting" | "unavailable"; - readonly label: string; - readonly onPress: () => void; - }; - -export function connectionFloatingStatus(input: { - readonly connectionError: string | null; - readonly connectionState: EnvironmentConnectionPhase; - readonly environmentLabel: string | null; - readonly onReconnect: () => void; -}): FloatingWorkingStatus | null { - const environmentLabel = input.environmentLabel ?? "Environment"; - const unavailable = (label: string): FloatingWorkingStatus => ({ - kind: "connection", - tone: "unavailable", - label, - onPress: input.onReconnect, - }); - - switch (input.connectionState) { - case "connecting": - case "reconnecting": - return { - kind: "connection", - tone: "reconnecting", - label: - input.connectionError === null - ? `Reconnecting to ${environmentLabel}...` - : `Failed to connect. Retrying ${environmentLabel}...`, - onPress: input.onReconnect, - }; - case "offline": - return unavailable("You are offline"); - case "error": - return unavailable( - input.connectionError - ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` - : `Failed to connect to ${environmentLabel}`, - ); - case "available": - return unavailable(`${environmentLabel} is not connected`); - case "connected": - return null; - } -} - export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; readonly status: FloatingWorkingStatus | null; diff --git a/apps/mobile/src/features/threads/floating-working-status.test.ts b/apps/mobile/src/features/threads/floating-working-status.test.ts new file mode 100644 index 000000000000..e7de87947b4d --- /dev/null +++ b/apps/mobile/src/features/threads/floating-working-status.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { connectionFloatingStatus } from "./floating-working-status"; + +const status = ( + connectionState: Parameters[0]["connectionState"], + overrides: { connectionError?: string | null; environmentLabel?: string | null } = {}, +) => + connectionFloatingStatus({ + connectionError: overrides.connectionError ?? null, + connectionState, + environmentLabel: + overrides.environmentLabel === undefined ? "Mac mini" : overrides.environmentLabel, + onReconnect: () => {}, + }); + +describe("connectionFloatingStatus", () => { + it("yields the pill to sync and working state once connected", () => { + expect(status("connected")).toBeNull(); + }); + + it("names the environment it is retrying, and says so only after a failure", () => { + expect(status("connecting")).toMatchObject({ + tone: "reconnecting", + label: "Reconnecting to Mac mini...", + }); + expect(status("reconnecting", { connectionError: "ECONNREFUSED" })).toMatchObject({ + tone: "reconnecting", + label: "Failed to connect. Retrying Mac mini...", + }); + }); + + it("reports why the environment is unreachable", () => { + expect(status("offline")).toMatchObject({ + tone: "unavailable", + label: "You are offline", + }); + expect(status("available")).toMatchObject({ + tone: "unavailable", + label: "Mac mini is not connected", + }); + expect(status("error", { connectionError: "handshake timed out" })).toMatchObject({ + label: "Failed to connect to Mac mini: handshake timed out", + }); + expect(status("error")).toMatchObject({ label: "Failed to connect to Mac mini" }); + }); + + it("falls back to a generic name when the environment has no label", () => { + expect(status("error", { environmentLabel: null })).toMatchObject({ + label: "Failed to connect to Environment", + }); + }); + + it("carries the reconnect handler so the pill can trigger it", () => { + const onReconnect = vi.fn(); + const pill = connectionFloatingStatus({ + connectionError: null, + connectionState: "offline", + environmentLabel: "Mac mini", + onReconnect, + }); + if (pill?.kind !== "connection") throw new Error("expected a connection pill"); + pill.onPress(); + expect(onReconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts new file mode 100644 index 000000000000..71d0f3bd9e17 --- /dev/null +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -0,0 +1,62 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +/** + * What the floating pill says. Connection, syncing, and working share one + * element so the label swaps in place instead of one pill fading out for + * another. The connection variant is tappable and triggers a reconnect. + */ +export type FloatingWorkingStatus = + | { readonly kind: "working"; readonly startedAt: string } + | { readonly kind: "syncing"; readonly label: string } + | { readonly kind: "compacting" } + | { + readonly kind: "connection"; + readonly tone: "reconnecting" | "unavailable"; + readonly label: string; + readonly onPress: () => void; + }; + +/** + * The pill's connection variant, or null once the environment is connected and + * the pill is free to report sync and working state instead. + */ +export function connectionFloatingStatus(input: { + readonly connectionError: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly environmentLabel: string | null; + readonly onReconnect: () => void; +}): FloatingWorkingStatus | null { + const environmentLabel = input.environmentLabel ?? "Environment"; + const unavailable = (label: string): FloatingWorkingStatus => ({ + kind: "connection", + tone: "unavailable", + label, + onPress: input.onReconnect, + }); + + switch (input.connectionState) { + case "connecting": + case "reconnecting": + return { + kind: "connection", + tone: "reconnecting", + label: + input.connectionError === null + ? `Reconnecting to ${environmentLabel}...` + : `Failed to connect. Retrying ${environmentLabel}...`, + onPress: input.onReconnect, + }; + case "offline": + return unavailable("You are offline"); + case "error": + return unavailable( + input.connectionError + ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` + : `Failed to connect to ${environmentLabel}`, + ); + case "available": + return unavailable(`${environmentLabel} is not connected`); + case "connected": + return null; + } +}