| 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,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 | } |
| 226 | 220 | ||
| 227 | type ComposerStatusPillState = { | 221 | type ComposerStatusPillState = { |
| 228 | readonly kind: "unavailable" | "reconnecting" | "syncing"; | 222 | readonly kind: "unavailable" | "reconnecting"; |
| 229 | readonly label: string; | 223 | readonly label: string; |
| 230 | }; | 224 | }; |
| 231 | 225 | ||
| ... | @@ -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"; |
| 239 | 232 | ||
| ... | @@ -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.View | 265 | <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"; |
| 74 | import { | 74 | import { |
| 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"; |
| 78 | import { | 79 | import { |
| 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 or | 302 | // The raw sync status enters "synchronizing" on every full fetch, cached or |
| 302 | // not. Whether messages are already on screen decides the pill label: no | 303 | // 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 | <FloatingWorkingControl | 761 | <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) { |
| 157 | 157 | ||
| 158 | // Render the full thread chrome (header, feed, composer) as soon as the | 158 | // 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 a | 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 | 160 | // 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 @@ |
| 1 | import { GlassContainer, GlassView } from "expo-glass-effect"; | 1 | import { GlassContainer, GlassView } from "expo-glass-effect"; |
| 2 | import { useEffect, useState } from "react"; | 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 | import Animated, { | 4 | import 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); |
| 40 | 40 | ||
| 41 | export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; | 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 | export function FloatingWorkingControl(props: { | 51 | export 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 | })); |
| 64 | 72 | ||
| 65 | if (props.startedAt === null && !props.showScrollToEnd) { | 73 | if (props.status === null && !props.showScrollToEnd) { |
| 66 | return null; | 74 | return null; |
| 67 | } | 75 | } |
| 68 | 76 | ||
| ... | @@ -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 | <UniwindGlassContainer | 86 | <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> |
| 92 | 100 | ||
| 93 | <AnimatedGlassView | 101 | <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.View | 118 | <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> |
| 117 | 125 | ||
| 118 | <Animated.View | 126 | <Animated.View |
| ... | @@ -153,6 +161,22 @@ export function FloatingWorkingControl(props: { | ... | @@ -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 | function WorkingDuration(props: { readonly startedAt: string }) { | 180 | function WorkingDuration(props: { readonly startedAt: string }) { |
| 157 | const [nowMs, setNowMs] = useState(() => Date.now()); | 181 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| 158 | 182 |