History

fix(mobile): show loading and syncing in the working pill (#9466)

Julius Marminge committed 2 months ago

Commit de025aa1 parent db8d60f4 files · +60 −44Browse files

Unified · Split

4 files
  1. apps/mobile/src/features/threads/ThreadComposer.tsx +2 −22
  2. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +24 −13
  3. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +3 −2
  4. apps/mobile/src/features/threads/floating-working-control.tsx +31 −7
apps/mobile/src/features/threads/ThreadComposer.tsx+2−22
@@ -110,12 +110,6 @@ export interface ThreadComposerProps { · 109 unmodified lines
110110 readonly connectionState: RemoteClientConnectionState;
111111 readonly connectionError: string | null;
112112 readonly environmentLabel: string | null;
113− /**
114− * Message sync phase for the selected thread (drives the status pill):
115− * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
116− * are on screen while they reconcile with the server.
117− */
118− readonly threadSyncPhase?: "loading" | "syncing" | null;
119113 readonly selectedThread: OrchestrationThreadShell;
120114 readonly serverConfig: T3ServerConfig | null;
121115 readonly queueCount: number;
@@ -225,7 +219,7 @@ export function ComposerSurface(props: { · 103 unmodified lines
225219 }
226220
227221 type ComposerStatusPillState = {
228− readonly kind: "unavailable" | "reconnecting" | "syncing";
222+ readonly kind: "unavailable" | "reconnecting";
229223 readonly label: string;
230224 };
231225
@@ -233,7 +227,6 @@ function composerConnectionStatus(input: { · 1 unmodified line
233227 readonly connectionError: string | null;
234228 readonly connectionState: RemoteClientConnectionState;
235229 readonly environmentLabel: string | null;
236− readonly threadSyncPhase?: "loading" | "syncing" | null;
237230 }): ComposerStatusPillState | null {
238231 const environmentLabel = input.environmentLabel ?? "Environment";
239232
@@ -259,18 +252,6 @@ function composerConnectionStatus(input: { · 19 unmodified lines
259252 case "available":
260253 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
261254 case "connected":
262− break;
263− }
264−
265− // Connected: the pill is the single loading/sync indicator. One stable
266− // label per open — "Loading" when starting from scratch, "Syncing" when
267− // cached messages are already visible.
268− switch (input.threadSyncPhase) {
269− case "loading":
270− return { kind: "syncing", label: "Loading messages..." };
271− case "syncing":
272− return { kind: "syncing", label: "Syncing messages..." };
273− default:
274255 return null;
275256 }
276257 }
@@ -279,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( · 2 unmodified lines
279260 readonly onPress: () => void;
280261 readonly status: ComposerStatusPillState;
281262 }) {
282− const isReconnecting = props.status.kind !== "unavailable";
263+ const isReconnecting = props.status.kind === "reconnecting";
283264 return (
284265 <Animated.View
285266 className="absolute inset-x-0 bottom-full items-center pb-2"
@@ -344,7 +325,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 58 unmodified lines
344325 connectionError: props.connectionError,
345326 connectionState: props.connectionState,
346327 environmentLabel: props.environmentLabel,
347− threadSyncPhase: props.threadSyncPhase,
348328 });
349329 const selectedProviderStatus = useMemo(() => {
350330 if (!props.serverConfig) return null;
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+24−13
@@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; · 73 unmodified lines
7474 import {
7575 FLOATING_WORKING_CONTROL_COVERAGE,
7676 FloatingWorkingControl,
77+ type FloatingWorkingStatus,
7778 } from "./floating-working-control";
7879 import {
7980 derivePendingUserInputMaxHeight,
@@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 221 unmodified lines
301302 // The raw sync status enters "synchronizing" on every full fetch, cached or
302303 // not. Whether messages are already on screen decides the pill label: no
303304 // data yet → "Loading messages", cached data reconciling → "Syncing".
304− const threadSyncPhase = (() => {
305+ const threadSyncLabel = (() => {
305306 switch (props.threadSyncStatus) {
306307 case "empty":
307308 case "cached":
308309 case "synchronizing":
309310 if (contentPresentationKind === "ready") {
310− return "syncing" as const;
311+ return "Syncing messages...";
311312 }
312− return contentPresentationKind === "loading" ? ("loading" as const) : null;
313+ return contentPresentationKind === "loading" ? "Loading messages..." : null;
313314 default:
314315 return null;
315316 }
316317 })();
317− const showWorkingControl =
318− props.activeWorkStartedAt !== null &&
319− contentPresentationKind === "ready" &&
320− threadSyncPhase === null &&
321− props.connectionStateLabel === "connected" &&
322− props.activePendingApproval === null &&
323− props.activePendingUserInput === null;
324− const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null;
318+ // One floating pill above the composer: it reads the sync state while
319+ // messages load, then the working timer once the feed is settled.
320+ const floatingStatus = ((): FloatingWorkingStatus | null => {
321+ if (
322+ props.connectionStateLabel !== "connected" ||
323+ props.activePendingApproval !== null ||
324+ props.activePendingUserInput !== null
325+ ) {
326+ return null;
327+ }
328+ if (threadSyncLabel !== null) {
329+ return { kind: "syncing", label: threadSyncLabel };
330+ }
331+ if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
332+ return { kind: "working", startedAt: props.activeWorkStartedAt };
333+ }
334+ return null;
335+ })();
336+ const showWorkingControl = floatingStatus !== null;
325337 const selectedThreadFeed = props.selectedThreadFeed;
326338 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
327339 const composerOverlapHeight = composerChrome + composerBottomInset;
@@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 420 unmodified lines
748760 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
749761 <FloatingWorkingControl
750762 colorScheme={isDarkMode ? "dark" : "light"}
751− startedAt={floatingWorkingStartedAt}
763+ status={floatingStatus}
752764 showScrollToEnd={showScrollToEndButton}
753765 onScrollToEnd={handleScrollToEnd}
754766 />
@@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 52 unmodified lines
807819 connectionState={props.connectionStateLabel}
808820 connectionError={props.connectionError}
809821 environmentLabel={props.environmentLabel}
810− threadSyncPhase={threadSyncPhase}
811822 selectedThread={props.selectedThread}
812823 serverConfig={props.serverConfig}
813824 queueCount={props.selectedThreadQueueCount}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+3−2
@@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { · 156 unmodified lines
157157
158158 // Render the full thread chrome (header, feed, composer) as soon as the
159159 // thread SHELL is known — no blocking on message detail. The feed shows a
160− // loading placeholder while messages fetch, and the composer's connection
161− // pill reports connecting/reconnecting/syncing status.
160+ // loading placeholder while messages fetch, the floating pill above the
161+ // composer reports loading/syncing, and the composer's connection pill
162+ // reports connecting/reconnecting status.
162163 if (selectedThread !== null && selectedThreadKey === routeThreadKey) {
163164 return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
164165 }
apps/mobile/src/features/threads/floating-working-control.tsx+31−7
@@ -1,6 +1,6 @@
11 import { GlassContainer, GlassView } from "expo-glass-effect";
22 import { useEffect, useState } from "react";
3−import { Text as SystemText, View } from "react-native";
3+import { ActivityIndicator, Text as SystemText, View } from "react-native";
44 import Animated, {
55 Easing,
66 FadeIn,
@@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); · 33 unmodified lines
4040
4141 export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP;
4242
43+/**
44+ * What the floating pill says. Syncing and working share one element so the
45+ * label swaps in place instead of one pill fading out for another.
46+ */
47+export type FloatingWorkingStatus =
48+ | { readonly kind: "working"; readonly startedAt: string }
49+ | { readonly kind: "syncing"; readonly label: string };
50+
4351 export function FloatingWorkingControl(props: {
4452 readonly colorScheme: "light" | "dark";
45− readonly startedAt: string | null;
53+ readonly status: FloatingWorkingStatus | null;
4654 readonly showScrollToEnd: boolean;
4755 readonly onScrollToEnd: () => void;
4856 }) {
@@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: { · 13 unmodified lines
6270 opacity: separationProgress.value,
6371 }));
6472
65− if (props.startedAt === null && !props.showScrollToEnd) {
73+ if (props.status === null && !props.showScrollToEnd) {
6674 return null;
6775 }
6876
@@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: { · 5 unmodified lines
7482 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}
7583 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}
7684 >
77− {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (
85+ {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (
7886 <UniwindGlassContainer
7987 spacing={GLASS_MERGE_SPACING}
8088 pointerEvents="box-none"
@@ -87,7 +95,7 @@ export function FloatingWorkingControl(props: { · 6 unmodified lines
8795 className="h-11 justify-center overflow-hidden rounded-full"
8896 style={timerStyle}
8997 >
90− <WorkingDuration startedAt={props.startedAt} />
98+ <FloatingStatusLabel status={props.status} />
9199 </AnimatedGlassView>
92100
93101 <AnimatedGlassView
@@ -105,14 +113,14 @@ export function FloatingWorkingControl(props: { · 11 unmodified lines
105113 </Animated.View>
106114 </AnimatedGlassView>
107115 </UniwindGlassContainer>
108− ) : props.startedAt !== null ? (
116+ ) : props.status !== null ? (
109117 <View pointerEvents="box-none" className="flex-row items-center gap-4">
110118 <Animated.View
111119 pointerEvents="none"
112120 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"
113121 style={timerStyle}
114122 >
115− <WorkingDuration startedAt={props.startedAt} />
123+ <FloatingStatusLabel status={props.status} />
116124 </Animated.View>
117125
118126 <Animated.View
@@ -153,6 +161,22 @@ export function FloatingWorkingControl(props: { · 34 unmodified lines
153161 );
154162 }
155163
164+function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) {
165+ if (props.status.kind === "syncing") {
166+ return (
167+ <View
168+ accessible
169+ accessibilityLabel={props.status.label}
170+ className="h-11 flex-row items-center gap-2 px-4"
171+ >
172+ <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
173+ <Text className="font-t3-medium text-xs text-foreground">{props.status.label}</Text>
174+ </View>
175+ );
176+ }
177+ return <WorkingDuration startedAt={props.status.startedAt} />;
178+}
179+
156180 function WorkingDuration(props: { readonly startedAt: string }) {
157181 const [nowMs, setNowMs] = useState(() => Date.now());
158182