1import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
2import { formatDuration } from "@t3tools/shared/orchestrationTiming";
3import { GlassContainer, GlassView } from "expo-glass-effect";
4import { type ReactNode, useEffect, useRef, useState } from "react";
5import {
6 type LayoutChangeEvent,
7 Pressable,
8 Text as SystemText,
9 useWindowDimensions,
10 View,
11} from "react-native";
12import Animated, {
13 Easing,
14 FadeIn,
15 FadeOut,
16 ReduceMotion,
17 type SharedValue,
18 useAnimatedStyle,
19 useReducedMotion,
20 useSharedValue,
21 withTiming,
22} from "react-native-reanimated";
23import { withUniwind } from "uniwind";
24
25import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
26import { AppText as Text } from "../../components/ui/AppText";
27import { SymbolView } from "../../components/ui/AppSymbol";
28import { ControlPill } from "../../components/ui/ControlPill";
29import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
30import { BrowserPreviewButton } from "../browser/browser-preview-button";
31import { DevicePreviewButton } from "../devices/device-preview-button";
32import type { FloatingWorkingStatus } from "./floating-working-status";
33import { ShimmeringWorkContent } from "./thread-work-log";
34import { useVisibleSecondClock } from "./use-visible-second-clock";
35
36const CONTROL_HEIGHT = 38.5;
37const CONTROL_GAP = 8;
38const COMPOSER_CAPSULE_INSET = 6;
39const GLASS_MERGE_SPACING = 12;
40const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
41const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
42const CONTROL_TIMING = {
43 duration: 240,
44 easing: Easing.out(Easing.cubic),
45 reduceMotion: ReduceMotion.System,
46} as const;
47const CONTROL_ROW_GAP = 14;
48const CONTROL_SEPARATION = (CONTROL_ROW_GAP + CONTROL_HEIGHT) / 2;
49const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System);
50const LABEL_EXITING = FadeOut.duration(100).reduceMotion(ReduceMotion.System);
51
52// Expo reapplies glass after layout or reattachment, restoring material UIKit can lose behind visible labels.
53const UniwindGlassView = withUniwind(GlassView, {
54 style: { fromClassName: "className" },
55});
56const UniwindGlassContainer = withUniwind(GlassContainer, {
57 style: { fromClassName: "className" },
58});
59const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);
60
61// Segment widths feed back into labelWidth; ignore sub-point changes to avoid rounding-driven ellipsis loops.
62function settleWidth(next: number) {
63 return (current: number) => (Math.abs(current - next) < 1 ? current : next);
64}
65
66const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET;
67export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP;
68
69export function FloatingWorkingControl(props: {
70 readonly colorScheme: "light" | "dark";
71 readonly status: FloatingWorkingStatus | null;
72 readonly extensionStatus?: { readonly label: string; readonly onPress: () => void } | null;
73 readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;
74 readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null;
75 readonly showScrollToEnd: boolean;
76 readonly onScrollToEnd: () => void;
77 readonly agents: SubagentPillSegment | null;
78 readonly onOpenAgents: () => void;
79 readonly queuedCount: number;
80 readonly onOpenQueue: () => void;
81 readonly lift?: SharedValue<number>;
82}) {
83 const { width: windowWidth } = useWindowDimensions();
84 const reducedMotion = useReducedMotion();
85 const [overlayWidth, setOverlayWidth] = useState(windowWidth);
86 const [queueWidth, setQueueWidth] = useState(0);
87 const [actionWidth, setActionWidth] = useState(0);
88 const [extensionWidth, setExtensionWidth] = useState(0);
89 const extensionStatus = props.extensionStatus ?? null;
90 const extensionOnly = props.status?.kind === "extension";
91 const useGlass = NATIVE_LIQUID_GLASS_SUPPORTED && !extensionOnly;
92 const action =
93 props.status?.kind === "background" ||
94 props.status?.kind === "lifecycle" ||
95 props.status?.kind === "stop"
96 ? props.status.action
97 : null;
98 const [agentsWidth, setAgentsWidth] = useState(0);
99 const hasQueue = props.queuedCount > 0;
100 const hasPreview = props.devicePreview !== null || props.browserPreview !== null;
101 const [previewWidth, setPreviewWidth] = useState(0);
102 const agents = props.agents;
103 const hasAgents = agents !== null;
104 // Only reserve the scroll button's slot while it is visible.
105 const reservedWidth = props.showScrollToEnd ? CONTROL_HEIGHT + 32 : 16;
106 const availableLabelWidth = Math.max(
107 0,
108 Math.min(overlayWidth, windowWidth) -
109 reservedWidth -
110 (action !== null ? actionWidth : 0) -
111 (hasQueue ? queueWidth : 0) -
112 (hasAgents ? agentsWidth : 0) -
113 (hasPreview ? previewWidth : 0),
114 );
115 const labelWidth = Math.max(
116 0,
117 availableLabelWidth - (extensionStatus === null ? 0 : extensionWidth),
118 );
119 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
120
121 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated separation value when scroll-to-end visibility changes
122 useEffect(() => {
123 separationProgress.set(withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING));
124 }, [props.showScrollToEnd, separationProgress]);
125
126 const lift = props.lift;
127 const liftStyle = useAnimatedStyle(() => ({
128 transform: [{ translateY: -(lift?.value ?? 0) }],
129 }));
130 const arrowTransformStyle = useAnimatedStyle(() => ({
131 transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],
132 }));
133 const arrowContentStyle = useAnimatedStyle(() => ({
134 opacity: separationProgress.value,
135 }));
136
137 // Animate an in-flow sizer for native glass layout. Measure labels at fixed width to avoid repeated animation retargeting.
138 const capsuleWidth = useSharedValue<number | null>(null);
139 const measuredWidthRef = useRef<number | null>(null);
140 const handleLabelLayout = (event: LayoutChangeEvent) => {
141 const width = event.nativeEvent.layout.width;
142 if (width === measuredWidthRef.current) {
143 return;
144 }
145 const first = measuredWidthRef.current === null;
146 measuredWidthRef.current = width;
147 capsuleWidth.set(first ? width : withTiming(width, CONTROL_TIMING));
148 };
149 const hasStatus = props.status !== null;
150 const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview;
151 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the Reanimated capsule width value and measured width when the status goes away
152 useEffect(() => {
153 if (!hasStatus) {
154 measuredWidthRef.current = null;
155 capsuleWidth.set(null);
156 }
157 }, [capsuleWidth, hasStatus]);
158 const capsuleStyle = useAnimatedStyle(() => ({
159 transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],
160 }));
161 const capsuleSizerStyle = useAnimatedStyle(() => ({
162 width: capsuleWidth.value ?? 0,
163 }));
164
165 if (!hasCapsule && !props.showScrollToEnd) {
166 return null;
167 }
168
169 const previewCompact =
170 hasStatus ||
171 hasAgents ||
172 hasQueue ||
173 (props.devicePreview !== null && props.browserPreview !== null);
174 const statusInteractive =
175 props.status?.kind === "connection" ||
176 props.status?.kind === "extension" ||
177 props.status?.kind === "child-input" ||
178 props.status?.kind === "background";
179 const capsuleInteractive =
180 statusInteractive ||
181 extensionStatus !== null ||
182 action !== null ||
183 hasQueue ||
184 hasAgents ||
185 hasPreview;
186 const statusContent =
187 props.status !== null ? (
188 <View
189 pointerEvents={statusInteractive ? "box-none" : "none"}
190 className="h-11 items-center justify-center overflow-hidden"
191 >
192 <Animated.View className="h-11" style={capsuleSizerStyle} />
193 <View
194 pointerEvents={statusInteractive ? "box-none" : "none"}
195 className="absolute h-11 items-center justify-center"
196 style={{ width: labelWidth }}
197 >
198 <FloatingStatusLabel
199 key={
200 props.status.kind === "working" ||
201 props.status.kind === "compacting" ||
202 props.status.kind === "extension"
203 ? props.status.kind
204 : `${props.status.kind}:${props.status.label}`
205 }
206 status={props.status}
207 onLayout={handleLabelLayout}
208 />
209 </View>
210 </View>
211 ) : null;
212
213 const capsuleContent = (
214 <View className="flex-row items-center">
215 {statusContent}
216 {extensionStatus !== null ? (
217 <Pressable
218 accessibilityRole="button"
219 accessibilityLabel={extensionStatus.label}
220 accessibilityHint="Shows the full Pi status"
221 onPress={extensionStatus.onPress}
222 onLayout={(event) => setExtensionWidth(settleWidth(event.nativeEvent.layout.width))}
223 style={{ maxWidth: availableLabelWidth * 0.5 }}
224 className="h-11 flex-row items-center px-3 active:opacity-70"
225 >
226 {hasStatus ? <View className="absolute left-0 h-4 w-px bg-border" /> : null}
227 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
228 {extensionStatus.label}
229 </Text>
230 </Pressable>
231 ) : null}
232 {action !== null ? (
233 <View
234 onLayout={(event) => setActionWidth(settleWidth(event.nativeEvent.layout.width))}
235 className="h-11 flex-row items-center"
236 >
237 <View className="h-4 w-px bg-border" />
238 <Pressable
239 accessibilityRole="button"
240 accessibilityLabel={action.accessibilityLabel}
241 accessibilityState={{ disabled: action.disabled }}
242 disabled={action.disabled}
243 onPress={action.onPress}
244 className={`h-11 items-center justify-center px-3 active:opacity-70 ${action.disabled ? "opacity-50" : ""}`}
245 >
246 <Text className="font-t3-medium text-xs text-foreground" numberOfLines={1}>
247 {action.label}
248 </Text>
249 </Pressable>
250 </View>
251 ) : null}
252 {hasPreview ? (
253 <View
254 className="h-11 flex-row items-center"
255 onLayout={(event) => setPreviewWidth(settleWidth(event.nativeEvent.layout.width))}
256 >
257 {hasStatus ? <View className="h-4 w-px bg-border" /> : null}
258 {props.devicePreview !== null ? (
259 <DevicePreviewButton {...props.devicePreview} compact={previewCompact} />
260 ) : null}
261 {props.browserPreview !== null ? (
262 <BrowserPreviewButton {...props.browserPreview} compact={previewCompact} />
263 ) : null}
264 </View>
265 ) : null}
266 {agents !== null ? (
267 <Pressable
268 accessibilityRole="button"
269 accessibilityLabel={`Open agents, ${agents.accessibilityLabel}`}
270 accessibilityHint="Opens this turn's subagents"
271 onPress={props.onOpenAgents}
272 onLayout={(event) => setAgentsWidth(settleWidth(event.nativeEvent.layout.width))}
273 className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"
274 >
275 {hasStatus || hasPreview ? <View className="absolute left-0 h-4 w-px bg-border" /> : null}
276 <SymbolView name="person.2" size={13} tintColorClassName="accent-foreground" />
277 <Text className="font-t3-medium text-xs tabular-nums" numberOfLines={1}>
278 {agents.label}
279 </Text>
280 </Pressable>
281 ) : null}
282 {hasQueue ? (
283 <Pressable
284 accessibilityRole="button"
285 accessibilityLabel={`Open queue, ${props.queuedCount} messages`}
286 accessibilityHint="Opens queued messages to send now, edit, or remove"
287 onPress={props.onOpenQueue}
288 onLayout={(event) => setQueueWidth(settleWidth(event.nativeEvent.layout.width))}
289 style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }}
290 className="h-11 flex-row items-center gap-2 px-3 active:opacity-70"
291 >
292 {hasStatus || hasPreview || hasAgents ? (
293 <View className="absolute left-0 h-4 w-px bg-border" />
294 ) : null}
295 <SymbolView name="list.number" size={13} tintColorClassName="accent-foreground" />
296 <Text className="shrink font-t3-medium text-xs tabular-nums" numberOfLines={1}>
297 {props.queuedCount} queued
298 </Text>
299 </Pressable>
300 ) : null}
301 </View>
302 );
303
304 return (
305 <Animated.View
306 pointerEvents="box-none"
307 className="absolute left-0 right-0 z-20 items-center"
308 style={[{ top: -CONTROL_OVERLAY_OFFSET }, liftStyle]}
309 onLayout={(event) => setOverlayWidth(event.nativeEvent.layout.width)}
310 entering={useGlass || extensionOnly || reducedMotion ? undefined : CONTROL_ENTERING}
311 exiting={useGlass || extensionOnly || reducedMotion ? undefined : CONTROL_EXITING}
312 >
313 {hasCapsule && useGlass ? (
314 <UniwindGlassContainer
315 spacing={GLASS_MERGE_SPACING}
316 pointerEvents="box-none"
317 className="flex-row items-center gap-4"
318 >
319 <AnimatedGlassView
320 colorScheme={props.colorScheme}
321 glassEffectStyle="regular"
322 isInteractive={capsuleInteractive}
323 pointerEvents={capsuleInteractive ? "box-none" : "none"}
324 className="h-11 items-center justify-center overflow-hidden rounded-full"
325 style={capsuleStyle}
326 >
327 {capsuleContent}
328 </AnimatedGlassView>
329
330 <AnimatedGlassView
331 colorScheme={props.colorScheme}
332 glassEffectStyle={{ style: props.showScrollToEnd ? "regular" : "none", animate: true }}
333 isInteractive
334 pointerEvents={props.showScrollToEnd ? "auto" : "none"}
335 accessibilityElementsHidden={!props.showScrollToEnd}
336 importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
337 className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
338 style={arrowTransformStyle}
339 >
340 <Animated.View style={arrowContentStyle}>
341 <ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} />
342 </Animated.View>
343 </AnimatedGlassView>
344 </UniwindGlassContainer>
345 ) : hasCapsule ? (
346 <View pointerEvents="box-none" className="flex-row items-center gap-4">
347 <Animated.View
348 pointerEvents={capsuleInteractive ? "box-none" : "none"}
349 className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-glass-fallback shadow-md shadow-black/10"
350 style={capsuleStyle}
351 >
352 {capsuleContent}
353 </Animated.View>
354
355 <Animated.View
356 pointerEvents={props.showScrollToEnd ? "auto" : "none"}
357 accessibilityElementsHidden={!props.showScrollToEnd}
358 importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
359 style={[arrowTransformStyle, arrowContentStyle]}
360 >
361 <ControlPill
362 accessibilityLabel="Scroll to end"
363 activateOnPressIn
364 className="h-11 w-11 border border-border bg-glass-fallback shadow-md shadow-black/10"
365 disabled={!props.showScrollToEnd}
366 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
367 onPress={props.onScrollToEnd}
368 />
369 </Animated.View>
370 </View>
371 ) : NATIVE_LIQUID_GLASS_SUPPORTED ? (
372 <UniwindGlassView
373 colorScheme={props.colorScheme}
374 glassEffectStyle="regular"
375 isInteractive
376 className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
377 >
378 <ScrollToEndButton onPress={props.onScrollToEnd} />
379 </UniwindGlassView>
380 ) : (
381 <ControlPill
382 accessibilityLabel="Scroll to end"
383 activateOnPressIn
384 className="h-11 w-11 border border-border bg-glass-fallback shadow-md shadow-black/10"
385 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
386 onPress={props.onScrollToEnd}
387 />
388 )}
389 </Animated.View>
390 );
391}
392
393function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) {
394 return (
395 <StatusLabelRow accessibilityLabel="Compacting" className="gap-1.5" onLayout={props.onLayout}>
396 <SymbolView
397 name="arrow.down.right.and.arrow.up.left"
398 size={13}
399 tintColorClassName="accent-foreground"
400 type="monochrome"
401 />
402 <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>
403 </StatusLabelRow>
404 );
405}
406
407function FloatingStatusLabel(props: {
408 readonly status: FloatingWorkingStatus;
409 readonly onLayout: (event: LayoutChangeEvent) => void;
410}) {
411 if (props.status.kind === "extension") {
412 return (
413 <StatusLabelRow
414 accessibilityLabel={props.status.label}
415 accessibilityRole="button"
416 onPress={props.status.onPress}
417 onLayout={props.onLayout}
418 className="gap-1.5"
419 animated={false}
420 >
421 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
422 {props.status.label}
423 </Text>
424 <SymbolView name="chevron.down" size={10} tintColorClassName="accent-foreground" />
425 </StatusLabelRow>
426 );
427 }
428 if (props.status.kind === "syncing") {
429 return (
430 <StatusLabelRow
431 key="syncing"
432 accessibilityLabel={props.status.label}
433 className="gap-2"
434 onLayout={props.onLayout}
435 >
436 <AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
437 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
438 {props.status.label}
439 </Text>
440 </StatusLabelRow>
441 );
442 }
443 if (props.status.kind === "compacting") {
444 return <CompactingLabel key="compacting" onLayout={props.onLayout} />;
445 }
446 if (props.status.kind === "child-input") {
447 return (
448 <StatusLabelRow
449 key="child-input"
450 accessibilityLabel={props.status.accessibilityLabel}
451 accessibilityRole="button"
452 className="gap-2"
453 onLayout={props.onLayout}
454 onPress={props.status.onPress}
455 >
456 <SymbolView name="text.bubble" size={13} tintColorClassName="accent-foreground" />
457 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
458 {props.status.label}
459 </Text>
460 </StatusLabelRow>
461 );
462 }
463 if (props.status.kind === "connection") {
464 return (
465 <StatusLabelRow
466 key="connection"
467 accessibilityLabel={props.status.label}
468 accessibilityRole="button"
469 className="gap-2"
470 onLayout={props.onLayout}
471 onPress={props.status.onPress}
472 >
473 {props.status.tone === "reconnecting" ? (
474 <AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
475 ) : (
476 <View className="h-2 w-2 rounded-full bg-red-500" />
477 )}
478 <Text
479 className="max-w-[260px] shrink font-t3-medium text-xs text-foreground"
480 numberOfLines={1}
481 >
482 {props.status.label}
483 </Text>
484 </StatusLabelRow>
485 );
486 }
487 if (props.status.kind === "lifecycle") {
488 return (
489 <StatusLabelRow
490 key="lifecycle"
491 accessibilityLabel={props.status.label}
492 className="gap-2"
493 onLayout={props.onLayout}
494 >
495 <SymbolView
496 name={props.status.state === "settled" ? "checkmark.circle" : "clock"}
497 size={13}
498 tintColorClassName="accent-foreground"
499 type="monochrome"
500 />
501 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
502 {props.status.label}
503 </Text>
504 </StatusLabelRow>
505 );
506 }
507 if (props.status.kind === "background") {
508 return (
509 <StatusLabelRow
510 key="background"
511 accessibilityLabel={props.status.accessibilityLabel}
512 accessibilityRole="button"
513 onPress={props.status.onPress}
514 className="gap-2"
515 onLayout={props.onLayout}
516 >
517 <SymbolView
518 name={props.status.waiting ? "person.2" : "terminal"}
519 size={13}
520 tintColorClassName="accent-foreground"
521 type="monochrome"
522 />
523 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
524 {props.status.label}
525 </Text>
526 </StatusLabelRow>
527 );
528 }
529 if (props.status.kind === "stop") {
530 return (
531 <StatusLabelRow
532 key="stop"
533 accessibilityLabel={props.status.label}
534 className="gap-2"
535 onLayout={props.onLayout}
536 >
537 {props.status.unresponsive ? (
538 <SymbolView
539 name="exclamationmark.triangle"
540 size={13}
541 tintColorClassName="accent-warning-foreground"
542 type="monochrome"
543 />
544 ) : (
545 <AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
546 )}
547 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
548 {props.status.label}
549 </Text>
550 </StatusLabelRow>
551 );
552 }
553 if (props.status.kind === "goal") {
554 return (
555 <StatusLabelRow
556 key="goal"
557 accessibilityLabel={props.status.accessibilityLabel}
558 className="gap-2"
559 onLayout={props.onLayout}
560 >
561 <SymbolView
562 name="target"
563 size={13}
564 tintColorClassName="accent-foreground"
565 type="monochrome"
566 />
567 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
568 {props.status.label}
569 </Text>
570 </StatusLabelRow>
571 );
572 }
573 if (props.status.kind === "preparing") {
574 return (
575 <StatusLabelRow
576 key="preparing"
577 accessibilityLabel={props.status.label}
578 className="gap-1.5"
579 onLayout={props.onLayout}
580 >
581 <SymbolView
582 name="arrow.triangle.branch"
583 size={13}
584 tintColorClassName="accent-foreground"
585 type="monochrome"
586 />
587 <ShimmeringWorkContent
588 className="flex-none"
589 textClassName="font-t3-medium"
590 compact
591 icon="arrow.triangle.branch"
592 iconSubtleColor="transparent"
593 label={props.status.label}
594 showIcon={false}
595 />
596 </StatusLabelRow>
597 );
598 }
599 return (
600 <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
601 );
602}
603
604function StatusLabelRow(props: {
605 readonly animated?: boolean;
606 readonly accessibilityLabel?: string;
607 readonly accessibilityRole?: "button";
608 readonly className?: string;
609 readonly children: ReactNode;
610 readonly onLayout: (event: LayoutChangeEvent) => void;
611 readonly onPress?: () => void;
612}) {
613 const reducedMotion = useReducedMotion();
614 const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`;
615 return (
616 <Animated.View
617 className="absolute max-w-full"
618 entering={props.animated === false || reducedMotion ? undefined : LABEL_ENTERING}
619 exiting={props.animated === false || reducedMotion ? undefined : LABEL_EXITING}
620 onLayout={props.onLayout}
621 >
622 {props.onPress ? (
623 <Pressable
624 accessibilityLabel={props.accessibilityLabel}
625 accessibilityRole={props.accessibilityRole}
626 className={`${rowClassName} active:opacity-70`}
627 onPress={props.onPress}
628 >
629 {props.children}
630 </Pressable>
631 ) : (
632 <View accessible accessibilityLabel={props.accessibilityLabel} className={rowClassName}>
633 {props.children}
634 </View>
635 )}
636 </Animated.View>
637 );
638}
639
640function WorkingDuration(props: {
641 readonly startedAt: string;
642 readonly onLayout: (event: LayoutChangeEvent) => void;
643}) {
644 return (
645 <StatusLabelRow onLayout={props.onLayout}>
646 <WorkingTimer startedAt={props.startedAt} />
647 </StatusLabelRow>
648 );
649}
650
651function WorkingTimer(props: { readonly startedAt: string }) {
652 const nowMs = useVisibleSecondClock(true);
653 return (
654 <SystemText
655 className="shrink text-xs text-foreground"
656 numberOfLines={1}
657 style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
658 >
659 Working {formatWorkingDuration(props.startedAt, nowMs)}
660 </SystemText>
661 );
662}
663
664function formatWorkingDuration(startedAt: string, nowMs: number): string {
665 const startedAtMs = Date.parse(startedAt);
666 if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) {
667 return "0s";
668 }
669
670 const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000);
671 if (totalSeconds < 60) {
672 return `${totalSeconds}s`;
673 }
674 if (totalSeconds >= 3_600) {
675 return formatDuration(totalSeconds * 1_000);
676 }
677
678 const minutes = Math.floor(totalSeconds / 60);
679 const seconds = String(totalSeconds % 60).padStart(2, "0");
680 return `${minutes}m ${seconds}s`;
681}
682
683function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) {
684 return (
685 <ControlPill
686 accessibilityLabel="Scroll to end"
687 activateOnPressIn
688 className="h-11 w-11 bg-transparent"
689 disabled={props.disabled}
690 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
691 onPress={props.onPress}
692 />
693 );
694}