| 1 | import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; |
| 2 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 3 | import { GlassContainer, GlassView } from "expo-glass-effect"; |
| 4 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 5 | import { |
| 6 | type LayoutChangeEvent, |
| 7 | Pressable, |
| 8 | Text as SystemText, |
| 9 | useWindowDimensions, |
| 10 | View, |
| 11 | } from "react-native"; |
| 12 | import Animated, { |
| 13 | Easing, |
| 14 | FadeIn, |
| 15 | FadeOut, |
| 16 | ReduceMotion, |
| 17 | type SharedValue, |
| 18 | useAnimatedStyle, |
| 19 | useSharedValue, |
| 20 | withTiming, |
| 21 | } from "react-native-reanimated"; |
| 22 | import { withUniwind } from "uniwind"; |
| 23 | |
| 24 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 25 | import { AppText as Text } from "../../components/AppText"; |
| 26 | import { SymbolView } from "../../components/AppSymbol"; |
| 27 | import { ControlPill } from "../../components/ControlPill"; |
| 28 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 29 | import { BrowserPreviewButton } from "../browser/browser-preview-button"; |
| 30 | import { DevicePreviewButton } from "../devices/device-preview-button"; |
| 31 | import type { FloatingWorkingStatus } from "./floating-working-status"; |
| 32 | import { ShimmeringWorkContent } from "./thread-work-log"; |
| 33 | import { useVisibleSecondClock } from "./use-visible-second-clock"; |
| 34 | |
| 35 | const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem |
| 36 | // The collapsed composer capsule starts 6 below its overlay's top edge, so |
| 37 | // the pill sits at (gap - 6) above the overlay to leave the same gap to the |
| 38 | // capsule as the feed's end inset leaves between it and the last row. |
| 39 | const CONTROL_GAP = 8; |
| 40 | const COMPOSER_CAPSULE_INSET = 6; |
| 41 | const GLASS_MERGE_SPACING = 12; |
| 42 | const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); |
| 43 | const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); |
| 44 | const CONTROL_TIMING = { |
| 45 | duration: 240, |
| 46 | easing: Easing.out(Easing.cubic), |
| 47 | reduceMotion: ReduceMotion.System, |
| 48 | } as const; |
| 49 | const CONTROL_ROW_GAP = 14; |
| 50 | const CONTROL_SEPARATION = (CONTROL_ROW_GAP + CONTROL_HEIGHT) / 2; |
| 51 | // Both rows share the same centered anchor, so the outgoing one clears fast and |
| 52 | // the incoming one waits for it to be mostly gone before it starts to show. |
| 53 | const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System); |
| 54 | const LABEL_EXITING = FadeOut.duration(100).reduceMotion(ReduceMotion.System); |
| 55 | |
| 56 | // Expo reapplies glass after native layout and window reattachment, when UIKit |
| 57 | // can otherwise leave the label visible but lose the material behind it. |
| 58 | const UniwindGlassView = withUniwind(GlassView, { |
| 59 | style: { fromClassName: "className" }, |
| 60 | }); |
| 61 | const UniwindGlassContainer = withUniwind(GlassContainer, { |
| 62 | style: { fromClassName: "className" }, |
| 63 | }); |
| 64 | const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); |
| 65 | |
| 66 | const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; |
| 67 | export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; |
| 68 | |
| 69 | export function FloatingWorkingControl(props: { |
| 70 | readonly colorScheme: "light" | "dark"; |
| 71 | readonly status: FloatingWorkingStatus | null; |
| 72 | readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null; |
| 73 | readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null; |
| 74 | readonly showScrollToEnd: boolean; |
| 75 | readonly onScrollToEnd: () => void; |
| 76 | readonly agents: SubagentPillSegment | null; |
| 77 | readonly onOpenAgents: () => void; |
| 78 | readonly queuedCount: number; |
| 79 | readonly onOpenQueue: () => void; |
| 80 | /** Extra distance to rise above the anchor, e.g. an overlay card's coverage. */ |
| 81 | readonly lift?: SharedValue<number>; |
| 82 | }) { |
| 83 | const { width: windowWidth } = useWindowDimensions(); |
| 84 | const [overlayWidth, setOverlayWidth] = useState(windowWidth); |
| 85 | const [queueWidth, setQueueWidth] = useState(0); |
| 86 | const [agentsWidth, setAgentsWidth] = useState(0); |
| 87 | const hasQueue = props.queuedCount > 0; |
| 88 | const hasPreview = props.devicePreview !== null || props.browserPreview !== null; |
| 89 | const [previewWidth, setPreviewWidth] = useState(0); |
| 90 | const agents = props.agents; |
| 91 | const hasAgents = agents !== null; |
| 92 | // Segments keep their measured width; only the status label absorbs the |
| 93 | // remainder, so a long "Working 12m 04s" truncates before a count does. |
| 94 | const labelWidth = Math.max( |
| 95 | 0, |
| 96 | Math.min(overlayWidth, windowWidth) - |
| 97 | CONTROL_HEIGHT - |
| 98 | 32 - |
| 99 | (hasQueue ? queueWidth : 0) - |
| 100 | (hasAgents ? agentsWidth : 0) - |
| 101 | (hasPreview ? previewWidth : 0), |
| 102 | ); |
| 103 | const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); |
| 104 | |
| 105 | useEffect(() => { |
| 106 | separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); |
| 107 | }, [props.showScrollToEnd, separationProgress]); |
| 108 | |
| 109 | const lift = props.lift; |
| 110 | const liftStyle = useAnimatedStyle(() => ({ |
| 111 | transform: [{ translateY: -(lift?.value ?? 0) }], |
| 112 | })); |
| 113 | const arrowTransformStyle = useAnimatedStyle(() => ({ |
| 114 | transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], |
| 115 | })); |
| 116 | const arrowContentStyle = useAnimatedStyle(() => ({ |
| 117 | opacity: separationProgress.value, |
| 118 | })); |
| 119 | |
| 120 | // Animate an in-flow sizer so native glass receives real layout updates. |
| 121 | // Measure labels in a separate, fixed-width host: measuring against the |
| 122 | // animated capsule constrains the incoming text to each intermediate width |
| 123 | // and repeatedly retargets the animation as it grows. |
| 124 | const capsuleWidth = useSharedValue<number | null>(null); |
| 125 | const measuredWidthRef = useRef<number | null>(null); |
| 126 | const handleLabelLayout = (event: LayoutChangeEvent) => { |
| 127 | const width = event.nativeEvent.layout.width; |
| 128 | if (width === measuredWidthRef.current) { |
| 129 | return; |
| 130 | } |
| 131 | const first = measuredWidthRef.current === null; |
| 132 | measuredWidthRef.current = width; |
| 133 | capsuleWidth.value = first ? width : withTiming(width, CONTROL_TIMING); |
| 134 | }; |
| 135 | // Forget the width while no label is shown so the next one appears at its |
| 136 | // own size instead of animating from the previous label's. |
| 137 | const hasStatus = props.status !== null; |
| 138 | const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview; |
| 139 | useEffect(() => { |
| 140 | if (!hasStatus) { |
| 141 | measuredWidthRef.current = null; |
| 142 | capsuleWidth.value = null; |
| 143 | } |
| 144 | }, [capsuleWidth, hasStatus]); |
| 145 | const capsuleStyle = useAnimatedStyle(() => ({ |
| 146 | transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], |
| 147 | })); |
| 148 | // Zero until the first measurement lands, so the capsule never paints around |
| 149 | // a label it has not sized to yet. |
| 150 | const capsuleSizerStyle = useAnimatedStyle(() => ({ |
| 151 | width: capsuleWidth.value ?? 0, |
| 152 | })); |
| 153 | |
| 154 | if (!hasCapsule && !props.showScrollToEnd) { |
| 155 | return null; |
| 156 | } |
| 157 | |
| 158 | // Both preview buttons fit beside each other only as icons. |
| 159 | const previewCompact = |
| 160 | hasStatus || |
| 161 | hasAgents || |
| 162 | hasQueue || |
| 163 | (props.devicePreview !== null && props.browserPreview !== null); |
| 164 | // The queue, agents, questions, and reconnect labels have separate tap targets. |
| 165 | const statusInteractive = |
| 166 | props.status?.kind === "connection" || props.status?.kind === "child-input"; |
| 167 | const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; |
| 168 | // The host stays centered on the capsule, but its measurement constraint |
| 169 | // comes from the overlay, independent of the capsule's current width. |
| 170 | const statusContent = |
| 171 | props.status !== null ? ( |
| 172 | <View |
| 173 | pointerEvents={statusInteractive ? "box-none" : "none"} |
| 174 | className="h-11 items-center justify-center" |
| 175 | > |
| 176 | <Animated.View className="h-11" style={capsuleSizerStyle} /> |
| 177 | <View |
| 178 | pointerEvents={statusInteractive ? "box-none" : "none"} |
| 179 | className="absolute h-11 items-center justify-center" |
| 180 | style={{ width: labelWidth }} |
| 181 | > |
| 182 | <FloatingStatusLabel |
| 183 | key={ |
| 184 | props.status.kind === "working" || props.status.kind === "compacting" |
| 185 | ? props.status.kind |
| 186 | : `${props.status.kind}:${props.status.label}` |
| 187 | } |
| 188 | status={props.status} |
| 189 | onLayout={handleLabelLayout} |
| 190 | /> |
| 191 | </View> |
| 192 | </View> |
| 193 | ) : null; |
| 194 | |
| 195 | const capsuleContent = ( |
| 196 | <View className="flex-row items-center"> |
| 197 | {statusContent} |
| 198 | {hasPreview ? ( |
| 199 | <View |
| 200 | className="h-11 flex-row items-center" |
| 201 | onLayout={(event) => setPreviewWidth(event.nativeEvent.layout.width)} |
| 202 | > |
| 203 | {hasStatus ? <View className="h-4 w-px bg-border" /> : null} |
| 204 | {props.devicePreview !== null ? ( |
| 205 | <DevicePreviewButton {...props.devicePreview} compact={previewCompact} /> |
| 206 | ) : null} |
| 207 | {props.browserPreview !== null ? ( |
| 208 | <BrowserPreviewButton {...props.browserPreview} compact={previewCompact} /> |
| 209 | ) : null} |
| 210 | </View> |
| 211 | ) : null} |
| 212 | {agents !== null ? ( |
| 213 | <Pressable |
| 214 | accessibilityRole="button" |
| 215 | accessibilityLabel={`Open agents, ${agents.accessibilityLabel}`} |
| 216 | accessibilityHint="Opens this turn's subagents" |
| 217 | onPress={props.onOpenAgents} |
| 218 | onLayout={(event) => setAgentsWidth(event.nativeEvent.layout.width)} |
| 219 | className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70" |
| 220 | > |
| 221 | {hasStatus || hasPreview ? <View className="mr-1 h-4 w-px bg-border" /> : null} |
| 222 | <SymbolView name="person.2" size={13} tintColorClassName="accent-foreground-muted" /> |
| 223 | <Text className="font-t3-medium text-xs tabular-nums" numberOfLines={1}> |
| 224 | {agents.label} |
| 225 | </Text> |
| 226 | </Pressable> |
| 227 | ) : null} |
| 228 | {hasQueue ? ( |
| 229 | <Pressable |
| 230 | accessibilityRole="button" |
| 231 | accessibilityLabel={`Open queue, ${props.queuedCount} messages`} |
| 232 | accessibilityHint="Opens queued messages for reordering, steering, or removal" |
| 233 | onPress={props.onOpenQueue} |
| 234 | onLayout={(event) => setQueueWidth(event.nativeEvent.layout.width)} |
| 235 | style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }} |
| 236 | className="h-11 flex-row items-center gap-2 px-3 active:opacity-70" |
| 237 | > |
| 238 | {hasStatus || hasPreview || hasAgents ? ( |
| 239 | <View className="mr-1 h-4 w-px bg-border" /> |
| 240 | ) : null} |
| 241 | <SymbolView name="list.number" size={13} tintColorClassName="accent-foreground-muted" /> |
| 242 | <Text className="shrink font-t3-medium text-xs tabular-nums" numberOfLines={1}> |
| 243 | {props.queuedCount} queued |
| 244 | </Text> |
| 245 | </Pressable> |
| 246 | ) : null} |
| 247 | </View> |
| 248 | ); |
| 249 | |
| 250 | return ( |
| 251 | <Animated.View |
| 252 | pointerEvents="box-none" |
| 253 | className="absolute left-0 right-0 z-20 items-center" |
| 254 | style={[{ top: -CONTROL_OVERLAY_OFFSET }, liftStyle]} |
| 255 | onLayout={(event) => setOverlayWidth(event.nativeEvent.layout.width)} |
| 256 | entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} |
| 257 | exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} |
| 258 | > |
| 259 | {hasCapsule && NATIVE_LIQUID_GLASS_SUPPORTED ? ( |
| 260 | <UniwindGlassContainer |
| 261 | spacing={GLASS_MERGE_SPACING} |
| 262 | pointerEvents="box-none" |
| 263 | className="flex-row items-center gap-4" |
| 264 | > |
| 265 | <AnimatedGlassView |
| 266 | colorScheme={props.colorScheme} |
| 267 | glassEffectStyle="regular" |
| 268 | isInteractive={capsuleInteractive} |
| 269 | pointerEvents={capsuleInteractive ? "box-none" : "none"} |
| 270 | className="h-11 items-center justify-center overflow-hidden rounded-full" |
| 271 | style={capsuleStyle} |
| 272 | > |
| 273 | {capsuleContent} |
| 274 | </AnimatedGlassView> |
| 275 | |
| 276 | <AnimatedGlassView |
| 277 | colorScheme={props.colorScheme} |
| 278 | glassEffectStyle={{ style: props.showScrollToEnd ? "regular" : "none", animate: true }} |
| 279 | isInteractive |
| 280 | pointerEvents={props.showScrollToEnd ? "auto" : "none"} |
| 281 | accessibilityElementsHidden={!props.showScrollToEnd} |
| 282 | importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"} |
| 283 | className="h-11 w-11 items-center justify-center overflow-hidden rounded-full" |
| 284 | style={arrowTransformStyle} |
| 285 | > |
| 286 | <Animated.View style={arrowContentStyle}> |
| 287 | <ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} /> |
| 288 | </Animated.View> |
| 289 | </AnimatedGlassView> |
| 290 | </UniwindGlassContainer> |
| 291 | ) : hasCapsule ? ( |
| 292 | <View pointerEvents="box-none" className="flex-row items-center gap-4"> |
| 293 | <Animated.View |
| 294 | pointerEvents={capsuleInteractive ? "box-none" : "none"} |
| 295 | className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-glass-fallback shadow-md shadow-black/10" |
| 296 | style={capsuleStyle} |
| 297 | > |
| 298 | {capsuleContent} |
| 299 | </Animated.View> |
| 300 | |
| 301 | <Animated.View |
| 302 | pointerEvents={props.showScrollToEnd ? "auto" : "none"} |
| 303 | accessibilityElementsHidden={!props.showScrollToEnd} |
| 304 | importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"} |
| 305 | style={[arrowTransformStyle, arrowContentStyle]} |
| 306 | > |
| 307 | <ControlPill |
| 308 | accessibilityLabel="Scroll to end" |
| 309 | activateOnPressIn |
| 310 | className="h-11 w-11 border border-border bg-glass-fallback shadow-md shadow-black/10" |
| 311 | disabled={!props.showScrollToEnd} |
| 312 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} |
| 313 | onPress={props.onScrollToEnd} |
| 314 | /> |
| 315 | </Animated.View> |
| 316 | </View> |
| 317 | ) : NATIVE_LIQUID_GLASS_SUPPORTED ? ( |
| 318 | <UniwindGlassView |
| 319 | colorScheme={props.colorScheme} |
| 320 | glassEffectStyle="regular" |
| 321 | isInteractive |
| 322 | className="h-11 w-11 items-center justify-center overflow-hidden rounded-full" |
| 323 | > |
| 324 | <ScrollToEndButton onPress={props.onScrollToEnd} /> |
| 325 | </UniwindGlassView> |
| 326 | ) : ( |
| 327 | <ControlPill |
| 328 | accessibilityLabel="Scroll to end" |
| 329 | activateOnPressIn |
| 330 | className="h-11 w-11 border border-border bg-glass-fallback shadow-md shadow-black/10" |
| 331 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} |
| 332 | onPress={props.onScrollToEnd} |
| 333 | /> |
| 334 | )} |
| 335 | </Animated.View> |
| 336 | ); |
| 337 | } |
| 338 | |
| 339 | function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) { |
| 340 | return ( |
| 341 | <StatusLabelRow accessibilityLabel="Compacting" className="gap-1.5" onLayout={props.onLayout}> |
| 342 | <SymbolView |
| 343 | name="arrow.down.right.and.arrow.up.left" |
| 344 | size={13} |
| 345 | tintColorClassName="foreground" |
| 346 | type="monochrome" |
| 347 | /> |
| 348 | <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text> |
| 349 | </StatusLabelRow> |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | function FloatingStatusLabel(props: { |
| 354 | readonly status: FloatingWorkingStatus; |
| 355 | readonly onLayout: (event: LayoutChangeEvent) => void; |
| 356 | }) { |
| 357 | // Keyed by kind so a swap mounts a fresh row and the two cross-fade while |
| 358 | // the capsule animates to the new row's measured width. |
| 359 | if (props.status.kind === "syncing") { |
| 360 | return ( |
| 361 | <StatusLabelRow |
| 362 | key="syncing" |
| 363 | accessibilityLabel={props.status.label} |
| 364 | className="gap-2" |
| 365 | onLayout={props.onLayout} |
| 366 | > |
| 367 | <AppActivityIndicator size="small" colorClassName="accent-icon-muted" /> |
| 368 | <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}> |
| 369 | {props.status.label} |
| 370 | </Text> |
| 371 | </StatusLabelRow> |
| 372 | ); |
| 373 | } |
| 374 | if (props.status.kind === "compacting") { |
| 375 | return <CompactingLabel key="compacting" onLayout={props.onLayout} />; |
| 376 | } |
| 377 | if (props.status.kind === "child-input") { |
| 378 | return ( |
| 379 | <StatusLabelRow |
| 380 | key="child-input" |
| 381 | accessibilityLabel={props.status.accessibilityLabel} |
| 382 | accessibilityRole="button" |
| 383 | className="gap-2" |
| 384 | onLayout={props.onLayout} |
| 385 | onPress={props.status.onPress} |
| 386 | > |
| 387 | <SymbolView name="text.bubble" size={13} tintColorClassName="foreground" /> |
| 388 | <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}> |
| 389 | {props.status.label} |
| 390 | </Text> |
| 391 | </StatusLabelRow> |
| 392 | ); |
| 393 | } |
| 394 | if (props.status.kind === "connection") { |
| 395 | return ( |
| 396 | <StatusLabelRow |
| 397 | key="connection" |
| 398 | accessibilityLabel={props.status.label} |
| 399 | accessibilityRole="button" |
| 400 | className="gap-2" |
| 401 | onLayout={props.onLayout} |
| 402 | onPress={props.status.onPress} |
| 403 | > |
| 404 | {props.status.tone === "reconnecting" ? ( |
| 405 | <AppActivityIndicator size="small" colorClassName="accent-icon-muted" /> |
| 406 | ) : ( |
| 407 | <View className="h-2 w-2 rounded-full bg-red-500" /> |
| 408 | )} |
| 409 | <Text |
| 410 | className="max-w-[260px] shrink font-t3-medium text-xs text-foreground" |
| 411 | numberOfLines={1} |
| 412 | > |
| 413 | {props.status.label} |
| 414 | </Text> |
| 415 | </StatusLabelRow> |
| 416 | ); |
| 417 | } |
| 418 | if (props.status.kind === "background") { |
| 419 | return ( |
| 420 | <StatusLabelRow |
| 421 | key="background" |
| 422 | accessibilityLabel={props.status.accessibilityLabel} |
| 423 | className="gap-2" |
| 424 | onLayout={props.onLayout} |
| 425 | > |
| 426 | {/* A dev server can run for hours after the agent is done, so only work |
| 427 | that will wake the agent gets the bolt. */} |
| 428 | <SymbolView |
| 429 | name={props.status.waiting ? { ios: "bolt", android: "bolt" } : "terminal"} |
| 430 | size={13} |
| 431 | tintColorClassName="foreground" |
| 432 | type="monochrome" |
| 433 | /> |
| 434 | <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}> |
| 435 | {props.status.label} |
| 436 | </Text> |
| 437 | </StatusLabelRow> |
| 438 | ); |
| 439 | } |
| 440 | if (props.status.kind === "goal") { |
| 441 | return ( |
| 442 | <StatusLabelRow |
| 443 | key="goal" |
| 444 | accessibilityLabel={props.status.accessibilityLabel} |
| 445 | className="gap-2" |
| 446 | onLayout={props.onLayout} |
| 447 | > |
| 448 | <SymbolView name="target" size={13} tintColorClassName="foreground" type="monochrome" /> |
| 449 | <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}> |
| 450 | {props.status.label} |
| 451 | </Text> |
| 452 | </StatusLabelRow> |
| 453 | ); |
| 454 | } |
| 455 | if (props.status.kind === "preparing") { |
| 456 | return ( |
| 457 | <StatusLabelRow |
| 458 | key="preparing" |
| 459 | accessibilityLabel={props.status.label} |
| 460 | className="gap-1.5" |
| 461 | onLayout={props.onLayout} |
| 462 | > |
| 463 | <SymbolView |
| 464 | name="arrow.triangle.branch" |
| 465 | size={13} |
| 466 | tintColorClassName="foreground" |
| 467 | type="monochrome" |
| 468 | /> |
| 469 | <ShimmeringWorkContent |
| 470 | className="flex-none" |
| 471 | textClassName="font-t3-medium" |
| 472 | compact |
| 473 | icon="arrow.triangle.branch" |
| 474 | iconSubtleColor="transparent" |
| 475 | label={props.status.label} |
| 476 | showIcon={false} |
| 477 | /> |
| 478 | </StatusLabelRow> |
| 479 | ); |
| 480 | } |
| 481 | return ( |
| 482 | <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} /> |
| 483 | ); |
| 484 | } |
| 485 | |
| 486 | // Absolute rows cross-fade around the same center without affecting each other. |
| 487 | function StatusLabelRow(props: { |
| 488 | readonly accessibilityLabel?: string; |
| 489 | readonly accessibilityRole?: "button"; |
| 490 | readonly className?: string; |
| 491 | readonly children: ReactNode; |
| 492 | readonly onLayout: (event: LayoutChangeEvent) => void; |
| 493 | readonly onPress?: () => void; |
| 494 | }) { |
| 495 | const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; |
| 496 | return ( |
| 497 | <Animated.View |
| 498 | className="absolute max-w-full" |
| 499 | entering={LABEL_ENTERING} |
| 500 | exiting={LABEL_EXITING} |
| 501 | onLayout={props.onLayout} |
| 502 | > |
| 503 | {props.onPress ? ( |
| 504 | <Pressable |
| 505 | accessibilityLabel={props.accessibilityLabel} |
| 506 | accessibilityRole={props.accessibilityRole} |
| 507 | className={`${rowClassName} active:opacity-70`} |
| 508 | onPress={props.onPress} |
| 509 | > |
| 510 | {props.children} |
| 511 | </Pressable> |
| 512 | ) : ( |
| 513 | <View accessible accessibilityLabel={props.accessibilityLabel} className={rowClassName}> |
| 514 | {props.children} |
| 515 | </View> |
| 516 | )} |
| 517 | </Animated.View> |
| 518 | ); |
| 519 | } |
| 520 | |
| 521 | function WorkingDuration(props: { |
| 522 | readonly startedAt: string; |
| 523 | readonly onLayout: (event: LayoutChangeEvent) => void; |
| 524 | }) { |
| 525 | return ( |
| 526 | <StatusLabelRow onLayout={props.onLayout}> |
| 527 | <WorkingTimer startedAt={props.startedAt} /> |
| 528 | </StatusLabelRow> |
| 529 | ); |
| 530 | } |
| 531 | |
| 532 | export function WorkingTimer(props: { readonly startedAt: string }) { |
| 533 | const nowMs = useVisibleSecondClock(true); |
| 534 | return ( |
| 535 | <SystemText |
| 536 | className="shrink text-xs text-foreground" |
| 537 | numberOfLines={1} |
| 538 | style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }} |
| 539 | > |
| 540 | Working {formatWorkingDuration(props.startedAt, nowMs)} |
| 541 | </SystemText> |
| 542 | ); |
| 543 | } |
| 544 | |
| 545 | function formatWorkingDuration(startedAt: string, nowMs: number): string { |
| 546 | const startedAtMs = Date.parse(startedAt); |
| 547 | if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) { |
| 548 | return "0s"; |
| 549 | } |
| 550 | |
| 551 | const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000); |
| 552 | if (totalSeconds < 60) { |
| 553 | return `${totalSeconds}s`; |
| 554 | } |
| 555 | if (totalSeconds >= 3_600) { |
| 556 | return formatDuration(totalSeconds * 1_000); |
| 557 | } |
| 558 | |
| 559 | const minutes = Math.floor(totalSeconds / 60); |
| 560 | const seconds = String(totalSeconds % 60).padStart(2, "0"); |
| 561 | return `${minutes}m ${seconds}s`; |
| 562 | } |
| 563 | |
| 564 | function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) { |
| 565 | return ( |
| 566 | <ControlPill |
| 567 | accessibilityLabel="Scroll to end" |
| 568 | activateOnPressIn |
| 569 | className="h-11 w-11 bg-transparent" |
| 570 | disabled={props.disabled} |
| 571 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} |
| 572 | onPress={props.onPress} |
| 573 | /> |
| 574 | ); |
| 575 | } |