| author | |
| committer | |
| log | de025aa69ffb0ce1a45d30aed25c60454660b62d |
| tree | 4c55576d363111766c667e23a06d5eb160fa1558 |
| parent | db8d60f486c5fc1a80d01b591a359f0c87f0868c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 110 | readonly connectionState: RemoteClientConnectionState; |
| 111 | 111 | readonly connectionError: string | null; |
| 112 | 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 | 113 | readonly selectedThread: OrchestrationThreadShell; |
| 120 | 114 | readonly serverConfig: T3ServerConfig | null; |
| 121 | 115 | readonly queueCount: number; |
| ... | ... | @@ -225,7 +219,7 @@ export function ComposerSurface(props: { |
| 225 | 219 | } |
| 226 | 220 | |
| 227 | 221 | type ComposerStatusPillState = { |
| 228 | readonly kind: "unavailable" | "reconnecting" | "syncing"; | |
| 222 | readonly kind: "unavailable" | "reconnecting"; | |
| 229 | 223 | readonly label: string; |
| 230 | 224 | }; |
| 231 | 225 | |
| ... | ... | @@ -233,7 +227,6 @@ function composerConnectionStatus(input: { |
| 233 | 227 | readonly connectionError: string | null; |
| 234 | 228 | readonly connectionState: RemoteClientConnectionState; |
| 235 | 229 | readonly environmentLabel: string | null; |
| 236 | readonly threadSyncPhase?: "loading" | "syncing" | null; | |
| 237 | 230 | }): ComposerStatusPillState | null { |
| 238 | 231 | const environmentLabel = input.environmentLabel ?? "Environment"; |
| 239 | 232 | |
| ... | ... | @@ -259,18 +252,6 @@ function composerConnectionStatus(input: { |
| 259 | 252 | case "available": |
| 260 | 253 | return { kind: "unavailable", label: `${environmentLabel} is not connected` }; |
| 261 | 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 | 255 | return null; |
| 275 | 256 | } |
| 276 | 257 | } |
| ... | ... | @@ -279,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 279 | 260 | readonly onPress: () => void; |
| 280 | 261 | readonly status: ComposerStatusPillState; |
| 281 | 262 | }) { |
| 282 | const isReconnecting = props.status.kind !== "unavailable"; | |
| 263 | const isReconnecting = props.status.kind === "reconnecting"; | |
| 283 | 264 | return ( |
| 284 | 265 | <Animated.View |
| 285 | 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 | 325 | connectionError: props.connectionError, |
| 345 | 326 | connectionState: props.connectionState, |
| 346 | 327 | environmentLabel: props.environmentLabel, |
| 347 | threadSyncPhase: props.threadSyncPhase, | |
| 348 | 328 | }); |
| 349 | 329 | const selectedProviderStatus = useMemo(() => { |
| 350 | 330 | if (!props.serverConfig) return null; |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+24-13| ... | ... | @@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 74 | 74 | import { |
| 75 | 75 | FLOATING_WORKING_CONTROL_COVERAGE, |
| 76 | 76 | FloatingWorkingControl, |
| 77 | type FloatingWorkingStatus, | |
| 77 | 78 | } from "./floating-working-control"; |
| 78 | 79 | import { |
| 79 | 80 | derivePendingUserInputMaxHeight, |
| ... | ... | @@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 301 | 302 | // The raw sync status enters "synchronizing" on every full fetch, cached or |
| 302 | 303 | // not. Whether messages are already on screen decides the pill label: no |
| 303 | 304 | // data yet → "Loading messages", cached data reconciling → "Syncing". |
| 304 | const threadSyncPhase = (() => { | |
| 305 | const threadSyncLabel = (() => { | |
| 305 | 306 | switch (props.threadSyncStatus) { |
| 306 | 307 | case "empty": |
| 307 | 308 | case "cached": |
| 308 | 309 | case "synchronizing": |
| 309 | 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 | 314 | default: |
| 314 | 315 | return null; |
| 315 | 316 | } |
| 316 | 317 | })(); |
| 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; | |
| 325 | 337 | const selectedThreadFeed = props.selectedThreadFeed; |
| 326 | 338 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 327 | 339 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| ... | ... | @@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 748 | 760 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> |
| 749 | 761 | <FloatingWorkingControl |
| 750 | 762 | colorScheme={isDarkMode ? "dark" : "light"} |
| 751 | startedAt={floatingWorkingStartedAt} | |
| 763 | status={floatingStatus} | |
| 752 | 764 | showScrollToEnd={showScrollToEndButton} |
| 753 | 765 | onScrollToEnd={handleScrollToEnd} |
| 754 | 766 | /> |
| ... | ... | @@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 807 | 819 | connectionState={props.connectionStateLabel} |
| 808 | 820 | connectionError={props.connectionError} |
| 809 | 821 | environmentLabel={props.environmentLabel} |
| 810 | threadSyncPhase={threadSyncPhase} | |
| 811 | 822 | selectedThread={props.selectedThread} |
| 812 | 823 | serverConfig={props.serverConfig} |
| 813 | 824 | queueCount={props.selectedThreadQueueCount} |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+3-2| ... | ... | @@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 157 | 157 | |
| 158 | 158 | // Render the full thread chrome (header, feed, composer) as soon as the |
| 159 | 159 | // 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. | |
| 162 | 163 | if (selectedThread !== null && selectedThreadKey === routeThreadKey) { |
| 163 | 164 | return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; |
| 164 | 165 | } |
apps/mobile/src/features/threads/floating-working-control.tsx+31-7| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { GlassContainer, GlassView } from "expo-glass-effect"; |
| 2 | 2 | import { useEffect, useState } from "react"; |
| 3 | import { Text as SystemText, View } from "react-native"; | |
| 3 | import { ActivityIndicator, Text as SystemText, View } from "react-native"; | |
| 4 | 4 | import Animated, { |
| 5 | 5 | Easing, |
| 6 | 6 | FadeIn, |
| ... | ... | @@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); |
| 40 | 40 | |
| 41 | 41 | export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; |
| 42 | 42 | |
| 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 | ||
| 43 | 51 | export function FloatingWorkingControl(props: { |
| 44 | 52 | readonly colorScheme: "light" | "dark"; |
| 45 | readonly startedAt: string | null; | |
| 53 | readonly status: FloatingWorkingStatus | null; | |
| 46 | 54 | readonly showScrollToEnd: boolean; |
| 47 | 55 | readonly onScrollToEnd: () => void; |
| 48 | 56 | }) { |
| ... | ... | @@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: { |
| 62 | 70 | opacity: separationProgress.value, |
| 63 | 71 | })); |
| 64 | 72 | |
| 65 | if (props.startedAt === null && !props.showScrollToEnd) { | |
| 73 | if (props.status === null && !props.showScrollToEnd) { | |
| 66 | 74 | return null; |
| 67 | 75 | } |
| 68 | 76 | |
| ... | ... | @@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: { |
| 74 | 82 | entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} |
| 75 | 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 | 86 | <UniwindGlassContainer |
| 79 | 87 | spacing={GLASS_MERGE_SPACING} |
| 80 | 88 | pointerEvents="box-none" |
| ... | ... | @@ -87,7 +95,7 @@ export function FloatingWorkingControl(props: { |
| 87 | 95 | className="h-11 justify-center overflow-hidden rounded-full" |
| 88 | 96 | style={timerStyle} |
| 89 | 97 | > |
| 90 | <WorkingDuration startedAt={props.startedAt} /> | |
| 98 | <FloatingStatusLabel status={props.status} /> | |
| 91 | 99 | </AnimatedGlassView> |
| 92 | 100 | |
| 93 | 101 | <AnimatedGlassView |
| ... | ... | @@ -105,14 +113,14 @@ export function FloatingWorkingControl(props: { |
| 105 | 113 | </Animated.View> |
| 106 | 114 | </AnimatedGlassView> |
| 107 | 115 | </UniwindGlassContainer> |
| 108 | ) : props.startedAt !== null ? ( | |
| 116 | ) : props.status !== null ? ( | |
| 109 | 117 | <View pointerEvents="box-none" className="flex-row items-center gap-4"> |
| 110 | 118 | <Animated.View |
| 111 | 119 | pointerEvents="none" |
| 112 | 120 | className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10" |
| 113 | 121 | style={timerStyle} |
| 114 | 122 | > |
| 115 | <WorkingDuration startedAt={props.startedAt} /> | |
| 123 | <FloatingStatusLabel status={props.status} /> | |
| 116 | 124 | </Animated.View> |
| 117 | 125 | |
| 118 | 126 | <Animated.View |
| ... | ... | @@ -153,6 +161,22 @@ export function FloatingWorkingControl(props: { |
| 153 | 161 | ); |
| 154 | 162 | } |
| 155 | 163 | |
| 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 | ||
| 156 | 180 | function WorkingDuration(props: { readonly startedAt: string }) { |
| 157 | 181 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| 158 | 182 |