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 {...@@ -110,12 +110,6 @@ export interface ThreadComposerProps {
110 readonly connectionState: RemoteClientConnectionState;110 readonly connectionState: RemoteClientConnectionState;
111 readonly connectionError: string | null;111 readonly connectionError: string | null;
112 readonly environmentLabel: string | null;112 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;
119 readonly selectedThread: OrchestrationThreadShell;113 readonly selectedThread: OrchestrationThreadShell;
120 readonly serverConfig: T3ServerConfig | null;114 readonly serverConfig: T3ServerConfig | null;
121 readonly queueCount: number;115 readonly queueCount: number;
...@@ -225,7 +219,7 @@ export function ComposerSurface(props: {...@@ -225,7 +219,7 @@ export function ComposerSurface(props: {
225}219}
226220
227type ComposerStatusPillState = {221type ComposerStatusPillState = {
228 readonly kind: "unavailable" | "reconnecting" | "syncing";222 readonly kind: "unavailable" | "reconnecting";
229 readonly label: string;223 readonly label: string;
230};224};
231225
...@@ -233,7 +227,6 @@ function composerConnectionStatus(input: {...@@ -233,7 +227,6 @@ function composerConnectionStatus(input: {
233 readonly connectionError: string | null;227 readonly connectionError: string | null;
234 readonly connectionState: RemoteClientConnectionState;228 readonly connectionState: RemoteClientConnectionState;
235 readonly environmentLabel: string | null;229 readonly environmentLabel: string | null;
236 readonly threadSyncPhase?: "loading" | "syncing" | null;
237}): ComposerStatusPillState | null {230}): ComposerStatusPillState | null {
238 const environmentLabel = input.environmentLabel ?? "Environment";231 const environmentLabel = input.environmentLabel ?? "Environment";
239232
...@@ -259,18 +252,6 @@ function composerConnectionStatus(input: {...@@ -259,18 +252,6 @@ function composerConnectionStatus(input: {
259 case "available":252 case "available":
260 return { kind: "unavailable", label: `${environmentLabel} is not connected` };253 return { kind: "unavailable", label: `${environmentLabel} is not connected` };
261 case "connected":254 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:
274 return null;255 return null;
275 }256 }
276}257}
...@@ -279,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -279,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
279 readonly onPress: () => void;260 readonly onPress: () => void;
280 readonly status: ComposerStatusPillState;261 readonly status: ComposerStatusPillState;
281}) {262}) {
282 const isReconnecting = props.status.kind !== "unavailable";263 const isReconnecting = props.status.kind === "reconnecting";
283 return (264 return (
284 <Animated.View265 <Animated.View
285 className="absolute inset-x-0 bottom-full items-center pb-2"266 className="absolute inset-x-0 bottom-full items-center pb-2"
...@@ -344,7 +325,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -344,7 +325,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
344 connectionError: props.connectionError,325 connectionError: props.connectionError,
345 connectionState: props.connectionState,326 connectionState: props.connectionState,
346 environmentLabel: props.environmentLabel,327 environmentLabel: props.environmentLabel,
347 threadSyncPhase: props.threadSyncPhase,
348 });328 });
349 const selectedProviderStatus = useMemo(() => {329 const selectedProviderStatus = useMemo(() => {
350 if (!props.serverConfig) return null;330 if (!props.serverConfig) return null;
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+24-13
...@@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard";...@@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard";
74import {74import {
75 FLOATING_WORKING_CONTROL_COVERAGE,75 FLOATING_WORKING_CONTROL_COVERAGE,
76 FloatingWorkingControl,76 FloatingWorkingControl,
77 type FloatingWorkingStatus,
77} from "./floating-working-control";78} from "./floating-working-control";
78import {79import {
79 derivePendingUserInputMaxHeight,80 derivePendingUserInputMaxHeight,
...@@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
301 // The raw sync status enters "synchronizing" on every full fetch, cached or302 // The raw sync status enters "synchronizing" on every full fetch, cached or
302 // not. Whether messages are already on screen decides the pill label: no303 // not. Whether messages are already on screen decides the pill label: no
303 // data yet → "Loading messages", cached data reconciling → "Syncing".304 // data yet → "Loading messages", cached data reconciling → "Syncing".
304 const threadSyncPhase = (() => {305 const threadSyncLabel = (() => {
305 switch (props.threadSyncStatus) {306 switch (props.threadSyncStatus) {
306 case "empty":307 case "empty":
307 case "cached":308 case "cached":
308 case "synchronizing":309 case "synchronizing":
309 if (contentPresentationKind === "ready") {310 if (contentPresentationKind === "ready") {
310 return "syncing" as const;311 return "Syncing messages...";
311 }312 }
312 return contentPresentationKind === "loading" ? ("loading" as const) : null;313 return contentPresentationKind === "loading" ? "Loading messages..." : null;
313 default:314 default:
314 return null;315 return null;
315 }316 }
316 })();317 })();
317 const showWorkingControl =318 // One floating pill above the composer: it reads the sync state while
318 props.activeWorkStartedAt !== null &&319 // messages load, then the working timer once the feed is settled.
319 contentPresentationKind === "ready" &&320 const floatingStatus = ((): FloatingWorkingStatus | null => {
320 threadSyncPhase === null &&321 if (
321 props.connectionStateLabel === "connected" &&322 props.connectionStateLabel !== "connected" ||
322 props.activePendingApproval === null &&323 props.activePendingApproval !== null ||
323 props.activePendingUserInput === null;324 props.activePendingUserInput !== null
324 const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : 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;
325 const selectedThreadFeed = props.selectedThreadFeed;337 const selectedThreadFeed = props.selectedThreadFeed;
326 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;338 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
327 const composerOverlapHeight = composerChrome + composerBottomInset;339 const composerOverlapHeight = composerChrome + composerBottomInset;
...@@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
748 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">760 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
749 <FloatingWorkingControl761 <FloatingWorkingControl
750 colorScheme={isDarkMode ? "dark" : "light"}762 colorScheme={isDarkMode ? "dark" : "light"}
751 startedAt={floatingWorkingStartedAt}763 status={floatingStatus}
752 showScrollToEnd={showScrollToEndButton}764 showScrollToEnd={showScrollToEndButton}
753 onScrollToEnd={handleScrollToEnd}765 onScrollToEnd={handleScrollToEnd}
754 />766 />
...@@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
807 connectionState={props.connectionStateLabel}819 connectionState={props.connectionStateLabel}
808 connectionError={props.connectionError}820 connectionError={props.connectionError}
809 environmentLabel={props.environmentLabel}821 environmentLabel={props.environmentLabel}
810 threadSyncPhase={threadSyncPhase}
811 selectedThread={props.selectedThread}822 selectedThread={props.selectedThread}
812 serverConfig={props.serverConfig}823 serverConfig={props.serverConfig}
813 queueCount={props.selectedThreadQueueCount}824 queueCount={props.selectedThreadQueueCount}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+3-2
...@@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {...@@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
157157
158 // Render the full thread chrome (header, feed, composer) as soon as the158 // Render the full thread chrome (header, feed, composer) as soon as the
159 // thread SHELL is known — no blocking on message detail. The feed shows a159 // thread SHELL is known — no blocking on message detail. The feed shows a
160 // loading placeholder while messages fetch, and the composer's connection160 // loading placeholder while messages fetch, the floating pill above the
161 // pill reports connecting/reconnecting/syncing status.161 // composer reports loading/syncing, and the composer's connection pill
162 // reports connecting/reconnecting status.
162 if (selectedThread !== null && selectedThreadKey === routeThreadKey) {163 if (selectedThread !== null && selectedThreadKey === routeThreadKey) {
163 return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;164 return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
164 }165 }
apps/mobile/src/features/threads/floating-working-control.tsx+31-7
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { GlassContainer, GlassView } from "expo-glass-effect";1import { GlassContainer, GlassView } from "expo-glass-effect";
2import { useEffect, useState } from "react";2import { useEffect, useState } from "react";
3import { Text as SystemText, View } from "react-native";3import { ActivityIndicator, Text as SystemText, View } from "react-native";
4import Animated, {4import Animated, {
5 Easing,5 Easing,
6 FadeIn,6 FadeIn,
...@@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);...@@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);
4040
41export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP;41export 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
43export function FloatingWorkingControl(props: {51export function FloatingWorkingControl(props: {
44 readonly colorScheme: "light" | "dark";52 readonly colorScheme: "light" | "dark";
45 readonly startedAt: string | null;53 readonly status: FloatingWorkingStatus | null;
46 readonly showScrollToEnd: boolean;54 readonly showScrollToEnd: boolean;
47 readonly onScrollToEnd: () => void;55 readonly onScrollToEnd: () => void;
48}) {56}) {
...@@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: {...@@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: {
62 opacity: separationProgress.value,70 opacity: separationProgress.value,
63 }));71 }));
6472
65 if (props.startedAt === null && !props.showScrollToEnd) {73 if (props.status === null && !props.showScrollToEnd) {
66 return null;74 return null;
67 }75 }
6876
...@@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: {...@@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: {
74 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}82 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}
75 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}83 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}
76 >84 >
77 {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (85 {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (
78 <UniwindGlassContainer86 <UniwindGlassContainer
79 spacing={GLASS_MERGE_SPACING}87 spacing={GLASS_MERGE_SPACING}
80 pointerEvents="box-none"88 pointerEvents="box-none"
...@@ -87,7 +95,7 @@ export function FloatingWorkingControl(props: {...@@ -87,7 +95,7 @@ export function FloatingWorkingControl(props: {
87 className="h-11 justify-center overflow-hidden rounded-full"95 className="h-11 justify-center overflow-hidden rounded-full"
88 style={timerStyle}96 style={timerStyle}
89 >97 >
90 <WorkingDuration startedAt={props.startedAt} />98 <FloatingStatusLabel status={props.status} />
91 </AnimatedGlassView>99 </AnimatedGlassView>
92100
93 <AnimatedGlassView101 <AnimatedGlassView
...@@ -105,14 +113,14 @@ export function FloatingWorkingControl(props: {...@@ -105,14 +113,14 @@ export function FloatingWorkingControl(props: {
105 </Animated.View>113 </Animated.View>
106 </AnimatedGlassView>114 </AnimatedGlassView>
107 </UniwindGlassContainer>115 </UniwindGlassContainer>
108 ) : props.startedAt !== null ? (116 ) : props.status !== null ? (
109 <View pointerEvents="box-none" className="flex-row items-center gap-4">117 <View pointerEvents="box-none" className="flex-row items-center gap-4">
110 <Animated.View118 <Animated.View
111 pointerEvents="none"119 pointerEvents="none"
112 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"120 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"
113 style={timerStyle}121 style={timerStyle}
114 >122 >
115 <WorkingDuration startedAt={props.startedAt} />123 <FloatingStatusLabel status={props.status} />
116 </Animated.View>124 </Animated.View>
117125
118 <Animated.View126 <Animated.View
...@@ -153,6 +161,22 @@ export function FloatingWorkingControl(props: {...@@ -153,6 +161,22 @@ export function FloatingWorkingControl(props: {
153 );161 );
154}162}
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
156function WorkingDuration(props: { readonly startedAt: string }) {180function WorkingDuration(props: { readonly startedAt: string }) {
157 const [nowMs, setNowMs] = useState(() => Date.now());181 const [nowMs, setNowMs] = useState(() => Date.now());
158182