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;
+ }
+}