diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a241866ae601d93cbc3af6e31057672637f36e78..57d2740b53d3b4a6c884a559648eacdca16f1342 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 8a6be2a1da981e97a2e99d6b8d44157b616f1752..57a93e0cff22a505342a49419082c4ceb73fbed7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -84,8 +84,8 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, - type FloatingWorkingStatus, } from "./floating-working-control"; +import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, @@ -331,14 +331,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 +970,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 +986,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 a429044fccdd75f2df8f1894ed392c1ab97056d3..26f321517baa69442d32e0e3878d5b4907300218 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,7 +1,13 @@ 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, useRef, useState } from "react"; +import { + ActivityIndicator, + type LayoutChangeEvent, + Pressable, + Text as SystemText, + View, +} from "react-native"; import Animated, { Easing, FadeIn, @@ -17,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 @@ -33,6 +40,10 @@ const CONTROL_TIMING = { reduceMotion: ReduceMotion.System, } as const; const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; +// 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. @@ -47,15 +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. Syncing and working share one element so the - * label swaps in place instead of one pill fading out for another. - */ -export type FloatingWorkingStatus = - | { readonly kind: "working"; readonly startedAt: string } - | { readonly kind: "syncing"; readonly label: string } - | { readonly kind: "compacting" }; - export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; readonly status: FloatingWorkingStatus | null; @@ -68,9 +70,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) }], })); @@ -78,10 +77,63 @@ 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 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) => { + 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]); + const capsuleStyle = useAnimatedStyle(() => ({ + 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; } + // 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"; + // 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 ( - + {statusContent} - + {statusContent} void }) { return ( - + Compacting… - + ); } -function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) { +function FloatingStatusLabel(props: { + readonly status: FloatingWorkingStatus; + readonly onLayout: (event: LayoutChangeEvent) => void; +}) { + // Keyed by kind so a swap mounts a fresh row and the two cross-fade while + // 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 ; } - return ; + if (props.status.kind === "connection") { + return ( + + {props.status.tone === "reconnecting" ? ( + + ) : ( + + )} + + {props.status.label} + + + ); + } + return ( + + ); +} + +// Rows are absolute with no insets, so the capsule centers them on itself and 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 ?? ""}`; + return ( + + {props.onPress ? ( + + {props.children} + + ) : ( + + {props.children} + + )} + + ); } -function WorkingDuration(props: { readonly startedAt: string }) { +function WorkingDuration(props: { + readonly startedAt: string; + readonly onLayout: (event: LayoutChangeEvent) => void; +}) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -219,7 +336,7 @@ function WorkingDuration(props: { readonly startedAt: string }) { const label = `Working for ${duration}`; return ( - + Working for {duration} - + ); } 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 0000000000000000000000000000000000000000..e7de87947b4ddd1ea2b69b4dd2ec88932c3a595f --- /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 0000000000000000000000000000000000000000..71d0f3bd9e170999a6650ea6cfc23e7f334c47f2 --- /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; + } +}