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 useSharedValue,
20 withTiming,
21} from "react-native-reanimated";
22import { withUniwind } from "uniwind";
23
24import { AppActivityIndicator } from "../../components/AppActivityIndicator";
25import { AppText as Text } from "../../components/AppText";
26import { SymbolView } from "../../components/AppSymbol";
27import { ControlPill } from "../../components/ControlPill";
28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
29import { BrowserPreviewButton } from "../browser/browser-preview-button";
30import { DevicePreviewButton } from "../devices/device-preview-button";
31import type { FloatingWorkingStatus } from "./floating-working-status";
32import { ShimmeringWorkContent } from "./thread-work-log";
33import { useVisibleSecondClock } from "./use-visible-second-clock";
34
35const 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.
39const CONTROL_GAP = 8;
40const COMPOSER_CAPSULE_INSET = 6;
41const GLASS_MERGE_SPACING = 12;
42const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
43const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
44const CONTROL_TIMING = {
45 duration: 240,
46 easing: Easing.out(Easing.cubic),
47 reduceMotion: ReduceMotion.System,
48} as const;
49const CONTROL_ROW_GAP = 14;
50const 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.
53const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System);
54const 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.
58const UniwindGlassView = withUniwind(GlassView, {
59 style: { fromClassName: "className" },
60});
61const UniwindGlassContainer = withUniwind(GlassContainer, {
62 style: { fromClassName: "className" },
63});
64const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);
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 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
339function 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
353function 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.
487function 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
521function 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
532export 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
545function 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
564function 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}