authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 12:52:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 12:52:50-07:00
logde025aa69ffb0ce1a45d30aed25c60454660b62d
tree4c55576d363111766c667e23a06d5eb160fa1558
parentdb8d60f486c5fc1a80d01b591a359f0c87f0868c
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 60 insertions(+), 44 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+2-22
......@@ -110,12 +110,6 @@ export interface ThreadComposerProps {
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: {
225219}
226220
227221type 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: {
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: {
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(
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
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";
7474import {
7575 FLOATING_WORKING_CONTROL_COVERAGE,
7676 FloatingWorkingControl,
77 type FloatingWorkingStatus,
7778} from "./floating-working-control";
7879import {
7980 derivePendingUserInputMaxHeight,
......@@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
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
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
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) {
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 @@
11import { GlassContainer, GlassView } from "expo-glass-effect";
22import { useEffect, useState } from "react";
3import { Text as SystemText, View } from "react-native";
3import { ActivityIndicator, Text as SystemText, View } from "react-native";
44import Animated, {
55 Easing,
66 FadeIn,
......@@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);
4040
4141export 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 */
47export type FloatingWorkingStatus =
48 | { readonly kind: "working"; readonly startedAt: string }
49 | { readonly kind: "syncing"; readonly label: string };
50
4351export 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: {
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: {
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: {
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: {
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: {
153161 );
154162}
155163
164function 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
156180function WorkingDuration(props: { readonly startedAt: string }) {
157181 const [nowMs, setNowMs] = useState(() => Date.now());
158182