authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-30 12:22:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 12:22:45-07:00
logf15680bd3c08a154bacb9b4d163cc8608a310970
tree680e35715f2ea10d0d24cca736b1004e0d85c1c8
parent2daff8c25adf701fddd062ae93b94cc57d420ec2
signature Signed by PGP key B5690EEEBB952194

feat(mobile): update tool summaries and chat transitions (#8793)


14 files changed, 2978 insertions(+), 894 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+178-119
...@@ -9,7 +9,16 @@ import type {...@@ -9,7 +9,16 @@ import type {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";10import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
11import type { ReactNode } from "react";11import type { ReactNode } from "react";
12import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";12import {
13 memo,
14 useCallback,
15 useEffect,
16 useLayoutEffect,
17 useMemo,
18 useRef,
19 useState,
20 type RefObject,
21} from "react";
13import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";22import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";
14import ImageViewing from "react-native-image-viewing";23import ImageViewing from "react-native-image-viewing";
15import Animated, {24import Animated, {
...@@ -18,6 +27,9 @@ import Animated, {...@@ -18,6 +27,9 @@ import Animated, {
18 FadeOut,27 FadeOut,
19 FadeOutDown,28 FadeOutDown,
20 LinearTransition,29 LinearTransition,
30 useAnimatedStyle,
31 useSharedValue,
32 withTiming,
21} from "react-native-reanimated";33} from "react-native-reanimated";
22import { useUniwindTheme } from "../../lib/useUniwindTheme";34import { useUniwindTheme } from "../../lib/useUniwindTheme";
23import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";35import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
...@@ -31,7 +43,6 @@ import {...@@ -31,7 +43,6 @@ import {
31 ComposerInlineControl,43 ComposerInlineControl,
32 ComposerToolbarButton,44 ComposerToolbarButton,
33 ComposerToolbarRow,45 ComposerToolbarRow,
34 ComposerToolbarScroller,
35} from "../../components/ComposerToolbar";46} from "../../components/ComposerToolbar";
36import { ControlPill } from "../../components/ControlPill";47import { ControlPill } from "../../components/ControlPill";
37import { ProviderIcon } from "../../components/ProviderIcon";48import { ProviderIcon } from "../../components/ProviderIcon";
...@@ -104,14 +115,17 @@ export interface ThreadComposerProps {...@@ -104,14 +115,17 @@ export interface ThreadComposerProps {
104 * iOS 26+ devices and keeps the existing opaque fallback elsewhere.115 * iOS 26+ devices and keeps the existing opaque fallback elsewhere.
105 * Exported so NewTaskDraftScreen can render the same composer chrome.116 * Exported so NewTaskDraftScreen can render the same composer chrome.
106 */117 */
107// One timing for every piece of the expanded↔compact morph so the surface,118// The bottom-anchored dock position and clipped surface height use the same
108// toolbar, and siblings move together instead of popping between layouts.119// transition so the card grows upward without exposing its final-size content.
109// Android gets NO layout transition: the composer rides the keyboard via120// Android gets NO layout transition: the composer rides the keyboard via
110// KeyboardStickyView (frame-synced to the IME), and a time-based morph121// KeyboardStickyView (frame-synced to the IME), and a time-based morph
111// running alongside that translate reads as jitter. Snapping the layout and122// running alongside that translate reads as jitter. Snapping the layout and
112// letting the keyboard-synced slide be the only motion looks native there.123// letting the keyboard-synced slide be the only motion looks native there.
113const COMPOSER_LAYOUT_TRANSITION =124export const COMPOSER_TRANSITION_DURATION_MS = 220;
114 Platform.OS === "android" ? undefined : LinearTransition.duration(220);125export const COMPOSER_LAYOUT_TRANSITION =
126 Platform.OS === "android"
127 ? undefined
128 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS);
115129
116export function ComposerSurface(props: {130export function ComposerSurface(props: {
117 readonly children: ReactNode;131 readonly children: ReactNode;
...@@ -119,29 +133,53 @@ export function ComposerSurface(props: {...@@ -119,29 +133,53 @@ export function ComposerSurface(props: {
119 /** Existing thread composers morph between pill and card layouts. */133 /** Existing thread composers morph between pill and card layouts. */
120 readonly animateLayout?: boolean;134 readonly animateLayout?: boolean;
121}) {135}) {
122 // A box shadow follows the rounded surface even when the glass is transparent.136 const targetBorderRadius =
123 // Keep it outside the clipped content so the shadow can extend past the edge.137 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
138 const animatedBorderRadius = useSharedValue(targetBorderRadius);
139 const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";
140 useLayoutEffect(() => {
141 animatedBorderRadius.value = shouldAnimate
142 ? withTiming(targetBorderRadius, {
143 duration: COMPOSER_TRANSITION_DURATION_MS,
144 })
145 : targetBorderRadius;
146 }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]);
147 const animatedShapeStyle = useAnimatedStyle(() => ({
148 borderRadius: animatedBorderRadius.value,
149 }));
150
151 // Clipping here keeps the expanded toolbar inside the glass while the
152 // bottom-anchored host owns the geometry transition.
124 return (153 return (
125 <Animated.View154 <Animated.View
126 className="shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"155 className="shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
127 layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION}156 layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION}
128 style={{157 style={[
129 borderRadius: props.style.borderRadius,158 animatedShapeStyle,
130 // Android versions before 9 do not support outset box shadows.159 {
131 elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined,160 overflow: "hidden",
132 }}161 // Android versions before 9 do not support outset box shadows.
162 elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined,
163 },
164 ]}
133 >165 >
134 <GlassSurface166 <GlassSurface
135 chrome="none"167 chrome="none"
136 fallbackClassName="border border-border bg-card-translucent"168 fallbackClassName="border border-border bg-card-translucent"
137 glassEffectStyle="regular"169 glassEffectStyle="regular"
138 // The composer is a passive material containing interactive controls.170 // The composer is a passive material containing interactive controls.
139 // Expo GlassView defaults to non-interactive and both layouts share it.171 // Keep native glass out of the interactive content's layout path.
172 pointerEvents="none"
140 tintColor="transparent"173 tintColor="transparent"
141 style={props.style}174 style={{
175 position: "absolute",
176 inset: 0,
177 borderRadius: targetBorderRadius,
178 }}
142 >179 >
143 {props.children}180 {null}
144 </GlassSurface>181 </GlassSurface>
182 <View style={props.style}>{props.children}</View>
145 </Animated.View>183 </Animated.View>
146 );184 );
147}185}
...@@ -254,9 +292,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -254,9 +292,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
254 const isExpanded = isFocused || settingsSheetPresentation.isActive;292 const isExpanded = isFocused || settingsSheetPresentation.isActive;
255 const canSend = hasContent;293 const canSend = hasContent;
256294
257 // Notify the parent from the derived value, not focus events: the parent295 // Keep the parent's feed inset synchronized while the settings sheet keeps
258 // sizes the feed inset from this, and blur-during-sheet would otherwise296 // the composer expanded between editor focus events.
259 // report collapsed while the composer still renders expanded.
260 useEffect(() => {297 useEffect(() => {
261 onExpandedChange?.(isExpanded);298 onExpandedChange?.(isExpanded);
262 }, [isExpanded, onExpandedChange]);299 }, [isExpanded, onExpandedChange]);
...@@ -279,13 +316,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -279,13 +316,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
279 const onEditorFocusChange = props.onEditorFocusChange;316 const onEditorFocusChange = props.onEditorFocusChange;
280 const handleFocus = useCallback(() => {317 const handleFocus = useCallback(() => {
281 setIsFocused(true);318 setIsFocused(true);
319 onExpandedChange?.(true);
282 onEditorFocusChange?.(true);320 onEditorFocusChange?.(true);
283 }, [onEditorFocusChange]);321 }, [onEditorFocusChange, onExpandedChange]);
284322
285 const handleBlur = useCallback(() => {323 const handleBlur = useCallback(() => {
286 setIsFocused(false);324 setIsFocused(false);
325 if (!settingsSheetPresentation.isActive) {
326 onExpandedChange?.(false);
327 }
287 onEditorFocusChange?.(false);328 onEditorFocusChange?.(false);
288 }, [onEditorFocusChange]);329 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);
289 const showStopAction =330 const showStopAction =
290 props.selectedThread.session?.status === "running" ||331 props.selectedThread.session?.status === "running" ||
291 props.selectedThread.session?.status === "starting";332 props.selectedThread.session?.status === "starting";
...@@ -445,7 +486,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -445,7 +486,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
445 return (486 return (
446 <Animated.View487 <Animated.View
447 className="px-4"488 className="px-4"
448 layout={COMPOSER_LAYOUT_TRANSITION}
449 style={{489 style={{
450 paddingTop: isExpanded ? 8 : 6,490 paddingTop: isExpanded ? 8 : 6,
451 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),491 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
...@@ -460,7 +500,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -460,7 +500,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
460 />500 />
461 <Animated.View501 <Animated.View
462 className="relative w-full self-center"502 className="relative w-full self-center"
463 layout={COMPOSER_LAYOUT_TRANSITION}
464 style={{ maxWidth: props.contentMaxWidth }}503 style={{ maxWidth: props.contentMaxWidth }}
465 >504 >
466 {composerMenu.trigger && composerMenu.items.length > 0 ? (505 {composerMenu.trigger && composerMenu.items.length > 0 ? (
...@@ -493,12 +532,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -493,12 +532,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
493 paddingTop: 14,532 paddingTop: 14,
494 }533 }
495 : {534 : {
496 borderRadius: 999,535 // Keep the numeric radius close to the expanded card so the
536 // shape morph stays bounded while rendering as a capsule.
537 borderRadius: 27,
497 overflow: "hidden" as const,538 overflow: "hidden" as const,
498 flexDirection: "row" as const,539 paddingHorizontal: 14,
499 alignItems: "center" as const,
500 paddingLeft: 18,
501 paddingRight: 5,
502 paddingVertical: 5,540 paddingVertical: 5,
503 }541 }
504 }542 }
...@@ -518,95 +556,124 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -518,95 +556,124 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
518 </Animated.View>556 </Animated.View>
519 ) : null}557 ) : null}
520558
521 <View className={isExpanded ? undefined : "min-w-0 flex-1"}>559 <View className={isExpanded ? undefined : "flex-row items-center"}>
522 <ComposerEditor560 <View className={isExpanded ? undefined : "min-w-0 flex-1"}>
523 ref={inputRef}561 <ComposerEditor
524 multiline562 ref={inputRef}
525 value={props.draftMessage}563 multiline
526 skills={selectedProviderStatus?.skills ?? []}564 value={props.draftMessage}
527 selection={composerMenu.selection}565 skills={selectedProviderStatus?.skills ?? []}
528 onChangeText={props.onChangeDraftMessage}566 selection={composerMenu.selection}
529 onSelectionChange={composerMenu.onSelectionChange}567 onChangeText={props.onChangeDraftMessage}
530 onPasteImages={(uris) => void props.onNativePasteImages(uris)}568 onSelectionChange={composerMenu.onSelectionChange}
531 placeholder={props.placeholder}569 onPasteImages={(uris) => void props.onNativePasteImages(uris)}
532 onFocus={handleFocus}570 placeholder={props.placeholder}
533 onBlur={handleBlur}571 onFocus={handleFocus}
534 onSubmit={handleSend}572 onBlur={handleBlur}
535 scrollEnabled={isExpanded}573 onSubmit={handleSend}
536 // Android: collapsed single line centers natively (gravity) in574 scrollEnabled={isExpanded}
537 // a pill-height box matching the send button; iOS keeps insets.575 // Android: collapsed single line centers natively (gravity) in
538 singleLineCentered={!isExpanded}576 // a pill-height box matching the send button; iOS keeps insets.
539 contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6}577 singleLineCentered={!isExpanded}
540 style={578 contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6}
541 isExpanded579 style={
542 ? {580 isExpanded
543 minHeight: 72,581 ? {
544 maxHeight: 160,582 minHeight: 72,
545 paddingHorizontal: 4,583 maxHeight: 160,
546 paddingVertical: 4,584 paddingHorizontal: 4,
547 }585 paddingVertical: 4,
548 : {586 }
549 height: 36,587 : {
550 }588 height: 36,
551 }589 paddingHorizontal: 4,
552 textStyle={{590 }
553 ...bodyText,591 }
554 color: foregroundColor,592 textStyle={{
555 }}593 ...bodyText,
556 />594 color: foregroundColor,
557 </View>595 }}
558 {!isExpanded && props.draftAttachments.length > 0 ? (596 />
559 <View className="flex-row gap-1 pl-1">
560 {props.draftAttachments.slice(0, 3).map((image) => (
561 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>
562 <Image
563 source={{ uri: image.previewUri }}
564 className="size-[30px] rounded-lg bg-subtle"
565 resizeMode="cover"
566 />
567 </Pressable>
568 ))}
569 {props.draftAttachments.length > 3 ? (
570 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
571 <Text className="text-foreground-muted text-2xs font-t3-bold">
572 +{props.draftAttachments.length - 3}
573 </Text>
574 </View>
575 ) : null}
576 </View>597 </View>
577 ) : null}598 {!isExpanded && props.draftAttachments.length > 0 ? (
578 {!isExpanded ? (599 <View className="flex-row gap-1 pl-1">
579 <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>600 {props.draftAttachments.slice(0, 3).map((image) => (
580 {showStopAction ? (601 <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}>
581 <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />602 <Image
582 ) : (603 source={{ uri: image.previewUri }}
583 <ControlPill604 className="size-[30px] rounded-lg bg-subtle"
584 icon="arrow.up"605 resizeMode="cover"
585 variant="primary"606 />
586 disabled={!canSend}607 </Pressable>
587 onPress={handleSend}608 ))}
588 />609 {props.draftAttachments.length > 3 ? (
589 )}610 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
590 </Animated.View>611 <Text className="text-foreground-muted text-2xs font-t3-bold">
591 ) : null}612 +{props.draftAttachments.length - 3}
592 {isExpanded ? (613 </Text>
614 </View>
615 ) : null}
616 </View>
617 ) : null}
618 {!isExpanded ? (
619 <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>
620 {showStopAction ? (
621 <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
622 ) : (
623 <ControlPill
624 icon="arrow.up"
625 variant="primary"
626 disabled={!canSend}
627 onPress={handleSend}
628 />
629 )}
630 </Animated.View>
631 ) : null}
632 </View>
633 <View
634 accessibilityElementsHidden={!isExpanded}
635 collapsable={false}
636 importantForAccessibility={isExpanded ? "auto" : "no-hide-descendants"}
637 pointerEvents={isExpanded ? "auto" : "none"}
638 style={
639 isExpanded
640 ? undefined
641 : {
642 height: 0,
643 opacity: 0,
644 overflow: "hidden",
645 }
646 }
647 >
593 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>648 <ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>
594 <ComposerToolbarScroller contentPaddingRight={8}>649 <View className="min-w-0 flex-1 flex-row items-center gap-2">
595 <ComposerToolbarButton650 <ComposerToolbarButton
596 accessibilityLabel="Add attachment"651 accessibilityLabel="Add attachment"
597 icon="plus"652 icon="plus"
598 onPress={() => void props.onPickDraftImages()}653 onPress={() => void props.onPickDraftImages()}
599 showChevron={false}654 showChevron={false}
600 />655 />
601 <ComposerInlineControl656 <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}>
602 accessibilityLabel="Model and reasoning settings"657 <ComposerInlineControl
603 emphasized658 accessibilityLabel="Model and reasoning settings"
604 iconNode={659 emphasized
605 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />660 iconNode={
606 }661 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
607 label={currentModelOption?.label ?? currentModelSelection.model}662 }
608 maxWidth={152}663 label={currentModelOption?.label ?? currentModelSelection.model}
609 onPress={openSettings}664 maxWidth={152}
665 onPress={openSettings}
666 />
667 </View>
668 </View>
669 <View className="shrink-0 flex-row items-center gap-2">
670 <ComposerToolbarButton
671 accessibilityLabel={sendLabel}
672 icon="arrow.up"
673 variant="primary"
674 disabled={!canSend}
675 onPress={handleSend}
676 showChevron={false}
610 />677 />
611 {showStopAction ? (678 {showStopAction ? (
612 <ComposerToolbarButton679 <ComposerToolbarButton
...@@ -617,17 +684,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -617,17 +684,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
617 showChevron={false}684 showChevron={false}
618 />685 />
619 ) : null}686 ) : null}
620 </ComposerToolbarScroller>687 </View>
621 <ComposerToolbarButton
622 accessibilityLabel={sendLabel}
623 icon="arrow.up"
624 variant="primary"
625 disabled={!canSend}
626 onPress={handleSend}
627 showChevron={false}
628 />
629 </ComposerToolbarRow>688 </ComposerToolbarRow>
630 ) : null}689 </View>
631 </ComposerSurface>690 </ComposerSurface>
632691
633 {/* Queue count */}692 {/* Queue count */}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+160-140
...@@ -27,7 +27,6 @@ import {...@@ -27,7 +27,6 @@ import {
27 useRef,27 useRef,
28 useState,28 useState,
29} from "react";29} from "react";
30import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
31import {30import {
32 AppState,31 AppState,
33 Keyboard,32 Keyboard,
...@@ -45,13 +44,13 @@ import Animated, {...@@ -45,13 +44,13 @@ import Animated, {
45 Easing,44 Easing,
46 FadeInDown,45 FadeInDown,
47 FadeOut,46 FadeOut,
47 ReduceMotion,
48 useAnimatedReaction,48 useAnimatedReaction,
49 useSharedValue,49 useSharedValue,
50 withTiming,50 withTiming,
51} from "react-native-reanimated";51} from "react-native-reanimated";
52import { useSafeAreaInsets } from "react-native-safe-area-context";52import { useSafeAreaInsets } from "react-native-safe-area-context";
5353
54import { ControlPill } from "../../components/ControlPill";
55import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";54import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
56import type { ComposerEditorHandle } from "../../components/ComposerEditor";55import type { ComposerEditorHandle } from "../../components/ComposerEditor";
57import type { StatusTone } from "../../components/StatusPill";56import type { StatusTone } from "../../components/StatusPill";
...@@ -67,6 +66,10 @@ import type {...@@ -67,6 +66,10 @@ import type {
67} from "../../lib/threadActivity";66} from "../../lib/threadActivity";
68import { PendingApprovalCard } from "./PendingApprovalCard";67import { PendingApprovalCard } from "./PendingApprovalCard";
69import { PendingUserInputCard } from "./PendingUserInputCard";68import { PendingUserInputCard } from "./PendingUserInputCard";
69import {
70 FLOATING_WORKING_CONTROL_COVERAGE,
71 FloatingWorkingControl,
72} from "./floating-working-control";
70import {73import {
71 derivePendingUserInputMaxHeight,74 derivePendingUserInputMaxHeight,
72 ESTIMATED_KEYBOARD_HEIGHT,75 ESTIMATED_KEYBOARD_HEIGHT,
...@@ -75,6 +78,8 @@ import {...@@ -75,6 +78,8 @@ import {
75import {78import {
76 COMPOSER_COLLAPSED_CHROME,79 COMPOSER_COLLAPSED_CHROME,
77 COMPOSER_EXPANDED_CHROME,80 COMPOSER_EXPANDED_CHROME,
81 COMPOSER_LAYOUT_TRANSITION,
82 COMPOSER_TRANSITION_DURATION_MS,
78 ThreadComposer,83 ThreadComposer,
79} from "./ThreadComposer";84} from "./ThreadComposer";
80import { ThreadFeed } from "./ThreadFeed";85import { ThreadFeed } from "./ThreadFeed";
...@@ -290,6 +295,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -290,6 +295,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
290 return null;295 return null;
291 }296 }
292 })();297 })();
298 const showWorkingControl =
299 props.activeWorkStartedAt !== null &&
300 contentPresentationKind === "ready" &&
301 threadSyncPhase === null &&
302 props.connectionStateLabel === "connected" &&
303 props.activePendingApproval === null &&
304 props.activePendingUserInput === null;
305 const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null;
293 const selectedThreadFeed = props.selectedThreadFeed;306 const selectedThreadFeed = props.selectedThreadFeed;
294 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;307 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
295 const composerOverlapHeight = composerChrome + composerBottomInset;308 const composerOverlapHeight = composerChrome + composerBottomInset;
...@@ -343,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -343,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
343 composerOverlayRef,356 composerOverlayRef,
344 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),357 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
345 -nativeInsetOvercount,358 -nativeInsetOvercount,
359 Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0,
346 );360 );
347 // The expanded questionnaire is an absolute overlay on iOS, so it never361 // The expanded questionnaire is an absolute overlay on iOS, so it never
348 // changes the measured overlay height (that constancy is what keeps the362 // changes the measured overlay height (that constancy is what keeps the
...@@ -356,6 +370,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -356,6 +370,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
356 const userInputCardProgress = useSharedValue(1);370 const userInputCardProgress = useSharedValue(1);
357 const userInputInsetProgress = useSharedValue(1);371 const userInputInsetProgress = useSharedValue(1);
358 const userInputCardCoverage = useSharedValue(0);372 const userInputCardCoverage = useSharedValue(0);
373 const floatingControlCoverage = useSharedValue(
374 showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
375 );
376 useEffect(() => {
377 floatingControlCoverage.value = withTiming(
378 showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
379 { duration: 180, reduceMotion: ReduceMotion.System },
380 );
381 }, [floatingControlCoverage, showWorkingControl]);
359 // Android renders the expanded card in-flow (it cannot hit-test the iOS382 // Android renders the expanded card in-flow (it cannot hit-test the iOS
360 // overlay outside the bar's bounds), so its measured overlay height already383 // overlay outside the bar's bounds), so its measured overlay height already
361 // includes the card — the coverage extra is iOS-only.384 // includes the card — the coverage extra is iOS-only.
...@@ -366,6 +389,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -366,6 +389,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
366 useAnimatedReaction(389 useAnimatedReaction(
367 () =>390 () =>
368 contentInsetEndAdjustment.value +391 contentInsetEndAdjustment.value +
392 floatingControlCoverage.value +
369 (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0),393 (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0),
370 (value) => {394 (value) => {
371 combinedContentInsetEndAdjustment.value = value;395 combinedContentInsetEndAdjustment.value = value;
...@@ -375,20 +399,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -375,20 +399,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
375 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });399 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
376 const endFollowEnabledRef = useRef(true);400 const endFollowEnabledRef = useRef(true);
377 endFollowEnabledRef.current = endFollowEnabled;401 endFollowEnabledRef.current = endFollowEnabled;
378 const userInputRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);402 const overlayRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
403 const previousWorkingControlStateRef = useRef({
404 threadKey: selectedThreadKey,
405 visible: false,
406 });
379 // The list's own corrections for these inset changes drift on short407 // The list's own corrections for these inset changes drift on short
380 // content (and the error compounds across toggles), so deterministically408 // content (and the error compounds across toggles), so deterministically
381 // re-pin the end once a toggle settles: a no-op when the resting position409 // re-pin the end once a toggle settles: a no-op when the resting position
382 // is already right, corrective when it is not. Follow state is re-checked410 // is already right, corrective when it is not. Follow state is re-checked
383 // inside the callback — the user may grab the list during the settle411 // inside the callback — the user may grab the list during the settle
384 // window, and yanking them back would override a live gesture.412 // window, and yanking them back would override a live gesture.
385 const scheduleUserInputRepin = useCallback(413 const scheduleOverlayRepin = useCallback(
386 (delayMs: number) => {414 (delayMs: number) => {
387 if (userInputRepinTimerRef.current !== null) {415 if (overlayRepinTimerRef.current !== null) {
388 clearTimeout(userInputRepinTimerRef.current);416 clearTimeout(overlayRepinTimerRef.current);
389 }417 }
390 userInputRepinTimerRef.current = setTimeout(() => {418 overlayRepinTimerRef.current = setTimeout(() => {
391 userInputRepinTimerRef.current = null;419 overlayRepinTimerRef.current = null;
392 if (!endFollowEnabledRef.current) {420 if (!endFollowEnabledRef.current) {
393 return;421 return;
394 }422 }
...@@ -401,12 +429,29 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -401,12 +429,29 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
401 );429 );
402 useEffect(430 useEffect(
403 () => () => {431 () => () => {
404 if (userInputRepinTimerRef.current !== null) {432 if (overlayRepinTimerRef.current !== null) {
405 clearTimeout(userInputRepinTimerRef.current);433 clearTimeout(overlayRepinTimerRef.current);
406 }434 }
407 },435 },
408 [],436 [],
409 );437 );
438 useEffect(() => {
439 const previous = previousWorkingControlStateRef.current;
440 const threadChanged = previous.threadKey !== selectedThreadKey;
441 const visibilityChanged = previous.visible !== showWorkingControl;
442 previousWorkingControlStateRef.current = {
443 threadKey: selectedThreadKey,
444 visible: showWorkingControl,
445 };
446 if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) {
447 return;
448 }
449 // LegendList applies the larger inset but does not re-anchor short
450 // followed conversations when this floating coverage changes after the
451 // initial load. Re-pin after the finite inset transition; the callback
452 // checks follow state again so a user who scrolled up stays put.
453 scheduleOverlayRepin(230);
454 }, [scheduleOverlayRepin, selectedThreadKey, showWorkingControl]);
410 const handleToggleUserInputCollapsed = useCallback(() => {455 const handleToggleUserInputCollapsed = useCallback(() => {
411 if (activeUserInputRequestId === null) {456 if (activeUserInputRequestId === null) {
412 return;457 return;
...@@ -416,7 +461,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -416,7 +461,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
416 userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);461 userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
417 userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);462 userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
418 setCollapsedUserInputRequestId(null);463 setCollapsedUserInputRequestId(null);
419 scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);464 scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);
420 } else {465 } else {
421 // Collapsing hides the custom-answer inputs; release the keyboard with466 // Collapsing hides the custom-answer inputs; release the keyboard with
422 // them instead of leaving it up over a dead responder.467 // them instead of leaving it up over a dead responder.
...@@ -427,11 +472,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -427,11 +472,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
427 // anchor.472 // anchor.
428 userInputInsetProgress.value = 0;473 userInputInsetProgress.value = 0;
429 setCollapsedUserInputRequestId(activeUserInputRequestId);474 setCollapsedUserInputRequestId(activeUserInputRequestId);
430 scheduleUserInputRepin(60);475 scheduleOverlayRepin(60);
431 }476 }
432 }, [477 }, [
433 activeUserInputRequestId,478 activeUserInputRequestId,
434 scheduleUserInputRepin,479 scheduleOverlayRepin,
435 userInputCardProgress,480 userInputCardProgress,
436 userInputCollapsed,481 userInputCollapsed,
437 userInputInsetProgress,482 userInputInsetProgress,
...@@ -622,7 +667,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -622,7 +667,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
622 submittedMessageId={submittedMessageId}667 submittedMessageId={submittedMessageId}
623 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}668 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
624 contentTopInset={0}669 contentTopInset={0}
625 contentBottomInset={estimatedOverlayHeight}670 contentBottomInset={
671 estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
672 }
626 contentMaxWidth={contentMaxWidth}673 contentMaxWidth={contentMaxWidth}
627 layoutVariant={layoutVariant}674 layoutVariant={layoutVariant}
628 usesAutomaticContentInsets={props.usesAutomaticContentInsets}675 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
...@@ -639,137 +686,110 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -639,137 +686,110 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
639 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}686 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
640 {showContent ? (687 {showContent ? (
641 <KeyboardStickyView688 <KeyboardStickyView
642 // The animated keyboard height can remain stale after a dismissed689 // iOS emits a native animated height target on both will-show and
643 // IME on both platforms. Visibility is the authoritative closed690 // will-hide, so stay subscribed for the full transition. Android
644 // state, so disable the translation rather than stranding the pill.691 // retains its background/resume stale-state quarantine.
645 enabled={isKeyboardVisible && !keyboardStateSuspect}692 enabled={Platform.OS === "ios" || (isKeyboardVisible && !keyboardStateSuspect)}
646 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}693 pointerEvents="box-none"
694 style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }}
647 offset={{ closed: 0, opened: 0 }}695 offset={{ closed: 0, opened: 0 }}
648 >696 >
649 {/* No paddingTop here: the overlay's measured height becomes the697 {/* The fixed sticky host gives this bottom-anchored child a stable
650 list's bottom inset, so any padding above the pill/composer698 coordinate space. Its top and height can then animate together
651 pushes the resting content floor up by the same amount. */}699 instead of the auto-sized host jumping to Yoga's destination. */}
652 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">700 <Animated.View
653 {showScrollToEndButton ? (701 layout={COMPOSER_LAYOUT_TRANSITION}
654 <Animated.View702 pointerEvents="box-none"
655 pointerEvents="box-none"703 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
656 className="absolute -top-11 left-0 right-0 z-20 items-center"704 >
657 entering={FadeInDown.duration(160)}705 {/* No paddingTop here: the overlay's measured height becomes the
658 exiting={FadeOut.duration(100)}706 list's bottom inset, so any padding above the pill/composer
659 >707 pushes the resting content floor up by the same amount. */}
660 {isLiquidGlassSupported ? (708 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
661 <LiquidGlassView709 <FloatingWorkingControl
662 colorScheme={isDarkMode ? "dark" : "light"}710 colorScheme={isDarkMode ? "dark" : "light"}
663 effect="regular"711 startedAt={floatingWorkingStartedAt}
664 interactive712 showScrollToEnd={showScrollToEndButton}
665 // Interactive glass can render larger than the requested713 onScrollToEnd={handleScrollToEnd}
666 // box (minimum touch size), so center the pill instead of714 />
667 // relying on it filling the glass exactly.715 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
668 style={{716 {props.activePendingApproval || props.activePendingUserInput ? (
669 alignItems: "center",717 <Animated.View
670 borderRadius: 18,718 className="shrink-0 gap-3 px-4 pb-3"
671 height: 36,719 // The questionnaire replaces the composer, so it must pad
672 justifyContent: "center",720 // the home indicator the composer normally covers.
673 overflow: "hidden",721 style={
674 width: 36,722 activeUserInputRequestId !== null
675 }}723 ? { paddingBottom: composerBottomInset }
724 : undefined
725 }
726 entering={FadeInDown.duration(220)}
727 exiting={FadeOut.duration(140)}
676 >728 >
677 <ControlPill729 {props.activePendingApproval ? (
678 accessibilityLabel="Scroll to end"730 <PendingApprovalCard
679 activateOnPressIn731 approval={props.activePendingApproval}
680 className="h-9 w-9 bg-transparent"732 respondingApprovalId={props.respondingApprovalId}
681 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}733 onRespond={props.onRespondToApproval}
682 onPress={handleScrollToEnd}734 />
683 />735 ) : null}
684 </LiquidGlassView>736 {props.activePendingUserInput ? (
685 ) : (737 <PendingUserInputCard
686 <ControlPill738 pendingUserInput={props.activePendingUserInput}
687 accessibilityLabel="Scroll to end"739 maxHeight={pendingUserInputMaxHeight}
688 activateOnPressIn740 collapsed={userInputCollapsed}
689 className="h-9 w-9 border border-border bg-card shadow-md shadow-black/10"741 onToggleCollapsed={handleToggleUserInputCollapsed}
690 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}742 onStopThread={props.onStopThread}
691 onPress={handleScrollToEnd}743 cardProgress={userInputCardProgress}
692 />744 cardCoverage={userInputCardCoverage}
693 )}745 onInputFocusChange={handleOwnedInputFocusChange}
694 </Animated.View>746 drafts={props.activePendingUserInputDrafts}
695 ) : null}747 answers={props.activePendingUserInputAnswers}
696 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>748 respondingUserInputId={props.respondingUserInputId}
697 {props.activePendingApproval || props.activePendingUserInput ? (749 onSelectOption={props.onSelectUserInputOption}
698 <Animated.View750 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
699 className="shrink-0 gap-3 px-4 pb-3"751 onSubmit={props.onSubmitUserInput}
700 // The questionnaire replaces the composer, so it must pad752 />
701 // the home indicator the composer normally covers.753 ) : null}
702 style={754 </Animated.View>
703 activeUserInputRequestId !== null755 ) : null}
704 ? { paddingBottom: composerBottomInset }756 </View>
705 : undefined757
706 }758 {/* Hidden (not unmounted) while a user-input request owns the
707 entering={FadeInDown.duration(220)}
708 exiting={FadeOut.duration(140)}
709 >
710 {props.activePendingApproval ? (
711 <PendingApprovalCard
712 approval={props.activePendingApproval}
713 respondingApprovalId={props.respondingApprovalId}
714 onRespond={props.onRespondToApproval}
715 />
716 ) : null}
717 {props.activePendingUserInput ? (
718 <PendingUserInputCard
719 pendingUserInput={props.activePendingUserInput}
720 maxHeight={pendingUserInputMaxHeight}
721 collapsed={userInputCollapsed}
722 onToggleCollapsed={handleToggleUserInputCollapsed}
723 onStopThread={props.onStopThread}
724 cardProgress={userInputCardProgress}
725 cardCoverage={userInputCardCoverage}
726 onInputFocusChange={handleOwnedInputFocusChange}
727 drafts={props.activePendingUserInputDrafts}
728 answers={props.activePendingUserInputAnswers}
729 respondingUserInputId={props.respondingUserInputId}
730 onSelectOption={props.onSelectUserInputOption}
731 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
732 onSubmit={props.onSubmitUserInput}
733 />
734 ) : null}
735 </Animated.View>
736 ) : null}
737 </View>
738
739 {/* Hidden (not unmounted) while a user-input request owns the
740 composer slot, so composer drafts and editor state survive. */}759 composer slot, so composer drafts and editor state survive. */}
741 <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>760 <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>
742 <ThreadComposer761 <ThreadComposer
743 editorRef={composerEditorRef}762 editorRef={composerEditorRef}
744 draftMessage={props.draftMessage}763 draftMessage={props.draftMessage}
745 draftAttachments={props.draftAttachments}764 draftAttachments={props.draftAttachments}
746 placeholder="Ask the repo agent, or run a command…"765 placeholder="Ask the repo agent, or run a command…"
747 contentMaxWidth={contentMaxWidth}766 contentMaxWidth={contentMaxWidth}
748 connectionState={props.connectionStateLabel}767 connectionState={props.connectionStateLabel}
749 connectionError={props.connectionError}768 connectionError={props.connectionError}
750 environmentLabel={props.environmentLabel}769 environmentLabel={props.environmentLabel}
751 threadSyncPhase={threadSyncPhase}770 threadSyncPhase={threadSyncPhase}
752 selectedThread={props.selectedThread}771 selectedThread={props.selectedThread}
753 serverConfig={props.serverConfig}772 serverConfig={props.serverConfig}
754 queueCount={props.selectedThreadQueueCount}773 queueCount={props.selectedThreadQueueCount}
755 environmentId={props.environmentId}774 environmentId={props.environmentId}
756 projectCwd={props.projectWorkspaceRoot}775 projectCwd={props.projectWorkspaceRoot}
757 bottomInset={composerBottomInset}776 bottomInset={composerBottomInset}
758 onChangeDraftMessage={props.onChangeDraftMessage}777 onChangeDraftMessage={props.onChangeDraftMessage}
759 onPickDraftImages={props.onPickDraftImages}778 onPickDraftImages={props.onPickDraftImages}
760 onNativePasteImages={props.onNativePasteImages}779 onNativePasteImages={props.onNativePasteImages}
761 onRemoveDraftImage={props.onRemoveDraftImage}780 onRemoveDraftImage={props.onRemoveDraftImage}
762 onStopThread={props.onStopThread}781 onStopThread={props.onStopThread}
763 onSendMessage={handleSendMessage}782 onSendMessage={handleSendMessage}
764 onReconnectEnvironment={props.onReconnectEnvironment}783 onReconnectEnvironment={props.onReconnectEnvironment}
765 onUpdateModelSelection={props.onUpdateThreadModelSelection}784 onUpdateModelSelection={props.onUpdateThreadModelSelection}
766 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}785 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
767 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}786 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
768 onExpandedChange={setComposerExpanded}787 onExpandedChange={setComposerExpanded}
769 onEditorFocusChange={handleOwnedInputFocusChange}788 onEditorFocusChange={handleOwnedInputFocusChange}
770 />789 />
790 </View>
771 </View>791 </View>
772 </View>792 </Animated.View>
773 </KeyboardStickyView>793 </KeyboardStickyView>
774 ) : null}794 ) : null}
775 </View>795 </View>
apps/mobile/src/features/threads/ThreadFeed.tsx+122-95
...@@ -4,7 +4,6 @@ import { type LegendListRef } from "@legendapp/list/react-native";...@@ -4,7 +4,6 @@ import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";5import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
6import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";6import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
7import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
8import { SymbolView } from "../../components/AppSymbol";7import { SymbolView } from "../../components/AppSymbol";
9import { HeaderHeightContext } from "@react-navigation/elements";8import { HeaderHeightContext } from "@react-navigation/elements";
10import { useNavigation } from "@react-navigation/native";9import { useNavigation } from "@react-navigation/native";
...@@ -45,7 +44,13 @@ import {...@@ -45,7 +44,13 @@ import {
45import { TouchableOpacity } from "react-native-gesture-handler";44import { TouchableOpacity } from "react-native-gesture-handler";
46import ImageViewing from "react-native-image-viewing";45import ImageViewing from "react-native-image-viewing";
47import { useSafeAreaInsets } from "react-native-safe-area-context";46import { useSafeAreaInsets } from "react-native-safe-area-context";
48import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";47import Animated, {
48 FadeIn,
49 FadeInUp,
50 FadeOut,
51 LinearTransition,
52 type SharedValue,
53} from "react-native-reanimated";
49import { useUniwindTheme } from "../../lib/useUniwindTheme";54import { useUniwindTheme } from "../../lib/useUniwindTheme";
50import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";55import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
51import { useFontFamily } from "../../lib/useFontFamily";56import { useFontFamily } from "../../lib/useFontFamily";
...@@ -84,9 +89,7 @@ import {...@@ -84,9 +89,7 @@ import {
84import {89import {
85 resolveMarkdownFontSizes,90 resolveMarkdownFontSizes,
86 resolveNativeMarkdownTypography,91 resolveNativeMarkdownTypography,
87 scaledTypographyLineHeight,
88} from "../../lib/appearancePreferences";92} from "../../lib/appearancePreferences";
89import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
90import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";93import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
91import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";94import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
92import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";95import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
...@@ -105,6 +108,7 @@ import {...@@ -105,6 +108,7 @@ import {
105 collapsedWorkLogHeight,108 collapsedWorkLogHeight,
106 ThreadWorkGroupToggle,109 ThreadWorkGroupToggle,
107 ThreadWorkLog,110 ThreadWorkLog,
111 THREAD_DISCLOSURE_TRANSITION_MS,
108 WORK_GROUP_TOGGLE_HEIGHT,112 WORK_GROUP_TOGGLE_HEIGHT,
109} from "./thread-work-log";113} from "./thread-work-log";
110import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";114import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
...@@ -134,9 +138,10 @@ function formatMessageTime(input: string): string {...@@ -134,9 +138,10 @@ function formatMessageTime(input: string): string {
134// so its height is a constant; a drifted value costs one correction on138// so its height is a constant; a drifted value costs one correction on
135// measure, not a persistent offset.139// measure, not a persistent offset.
136const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)140const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
137// The working row has no min-height clamp — its height follows the scaled141const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
138// text-xs line height (see workingRowHeight in ThreadFeed).142const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
139const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16)143const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
144const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
140145
141// Entering animations must only play for rows born just now — LegendList146// Entering animations must only play for rows born just now — LegendList
142// remounts rows when they scroll back into view, and replaying an entrance for147// remounts rows when they scroll back into view, and replaying an entrance for
...@@ -985,10 +990,6 @@ function renderFeedEntry(...@@ -985,10 +990,6 @@ function renderFeedEntry(
985 const entry = info.item;990 const entry = info.item;
986 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;991 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
987992
988 if (entry.type === "working") {
989 return <WorkingTimelineRow startedAt={entry.createdAt} />;
990 }
991
992 if (entry.type === "turn-fold") {993 if (entry.type === "turn-fold") {
993 return (994 return (
994 <Pressable995 <Pressable
...@@ -1017,7 +1018,10 @@ function renderFeedEntry(...@@ -1017,7 +1018,10 @@ function renderFeedEntry(
1017 expanded={entry.expanded}1018 expanded={entry.expanded}
1018 hiddenCount={entry.hiddenCount}1019 hiddenCount={entry.hiddenCount}
1019 iconSubtleColor={iconSubtleColor}1020 iconSubtleColor={iconSubtleColor}
1020 onlyToolActivities={entry.onlyToolActivities}1021 summary={entry.summary}
1022 summaryKind={entry.summaryKind}
1023 hasFailure={entry.hasFailure}
1024 shimmer={entry.shimmer}
1021 onToggle={() => props.onToggleWorkGroup(entry.groupId)}1025 onToggle={() => props.onToggleWorkGroup(entry.groupId)}
1022 />1026 />
1023 );1027 );
...@@ -1180,32 +1184,6 @@ function renderFeedEntry(...@@ -1180,32 +1184,6 @@ function renderFeedEntry(
1180 );1184 );
1181}1185}
11821186
1183const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
1184 const [nowMs, setNowMs] = useState(() => Date.now());
1185
1186 useEffect(() => {
1187 const intervalId = setInterval(() => {
1188 setNowMs(Date.now());
1189 }, 1_000);
1190 return () => clearInterval(intervalId);
1191 }, [props.startedAt]);
1192
1193 const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
1194
1195 return (
1196 <View className="mb-4 flex-row items-center gap-2 px-1.5 py-1">
1197 <View className="flex-row items-center gap-1">
1198 <View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-500" />
1199 <View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a80-500-a80" />
1200 <View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a60-500-a60" />
1201 </View>
1202 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
1203 Working for {durationLabel}
1204 </Text>
1205 </View>
1206 );
1207});
1208
1209function UserMessageContent(props: {1187function UserMessageContent(props: {
1210 readonly text: string;1188 readonly text: string;
1211 readonly markdownStyles: MarkdownStyleSet;1189 readonly markdownStyles: MarkdownStyleSet;
...@@ -1479,14 +1457,14 @@ function ThreadFeedPlaceholder(props: {...@@ -1479,14 +1457,14 @@ function ThreadFeedPlaceholder(props: {
1479export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1457export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1480 const navigation = useNavigation();1458 const navigation = useNavigation();
1481 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1459 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1482 const foldSettleFrameRef = useRef<number | null>(null);1460 const disclosureSettleFrameRef = useRef<number | null>(null);
1483 const foldSettleSecondFrameRef = useRef<number | null>(null);1461 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
1484 const disclosureAnchorKeyRef = useRef<string | null>(null);1462 const disclosureAnchorKeyRef = useRef<string | null>(null);
1463 const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
1485 const headerMaterialVisibleRef = useRef(false);1464 const headerMaterialVisibleRef = useRef(false);
1486 const previousLatestTurnRef = useRef(props.latestTurn);1465 const previousLatestTurnRef = useRef(props.latestTurn);
1487 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);1466 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1488 const { width: windowWidth } = useWindowDimensions();1467 const { width: windowWidth } = useWindowDimensions();
1489 const { appearance } = useAppearancePreferences();
1490 const [viewportWidth, setViewportWidth] = useState(() =>1468 const [viewportWidth, setViewportWidth] = useState(() =>
1491 props.layoutVariant === "split" ? 0 : windowWidth,1469 props.layoutVariant === "split" ? 0 : windowWidth,
1492 );1470 );
...@@ -1791,14 +1769,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1791,14 +1769,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1791 props.latestTurn,1769 props.latestTurn,
1792 ],1770 ],
1793 );1771 );
1772 const disclosureEnteringEntryIds = useMemo(() => {
1773 const anchorKey = disclosureAnchorKeyRef.current;
1774 const previousPresentedFeed = previousPresentedFeedRef.current;
1775 if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
1776 return EMPTY_DISCLOSURE_ENTRY_IDS;
1777 }
1778
1779 const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
1780 const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
1781 const enteringIds = new Set<string>();
1782 if (anchorIndex < 0) {
1783 return enteringIds;
1784 }
1785 for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
1786 const entryId = presentedFeed[index]!.id;
1787 if (previousIds.has(entryId)) {
1788 break;
1789 }
1790 enteringIds.add(entryId);
1791 }
1792 return enteringIds;
1793 }, [disclosureToggleSettling, presentedFeed]);
1794
1795 useLayoutEffect(() => {
1796 previousPresentedFeedRef.current = presentedFeed;
1797 }, [presentedFeed]);
17941798
1795 // The empty↔filled key below remounts the list, which resets its imperative1799 // The empty↔filled key below remounts the list and resets its imperative
1796 // content-inset override — and useKeyboardChatComposerInset (mounted above1800 // content-inset override. Seed the fresh instance synchronously with the
1797 // the remount boundary) deduplicates by height, so it never re-reports the1801 // current overlay height before the scroll integration's next reaction;
1798 // composer inset to the fresh instance. Re-report the measured overlay height1802 // on Android the declarative contentInset floor covers this same window.
1799 // (composer plus any pending approval / user-input card) so the remounted
1800 // list's scroll math gets the true value; on Android the declarative
1801 // contentInset floor below covers the window before this effect lands.
1802 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;1803 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
1803 useLayoutEffect(() => {1804 useLayoutEffect(() => {
1804 const bottom = props.contentInsetEndAdjustment.value;1805 const bottom = props.contentInsetEndAdjustment.value;
...@@ -1863,34 +1864,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1863,34 +1864,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1863 if (copyFeedbackTimeoutRef.current) {1864 if (copyFeedbackTimeoutRef.current) {
1864 clearTimeout(copyFeedbackTimeoutRef.current);1865 clearTimeout(copyFeedbackTimeoutRef.current);
1865 }1866 }
1866 if (foldSettleFrameRef.current !== null) {1867 if (disclosureSettleFrameRef.current !== null) {
1867 cancelAnimationFrame(foldSettleFrameRef.current);1868 cancelAnimationFrame(disclosureSettleFrameRef.current);
1868 }1869 }
1869 if (foldSettleSecondFrameRef.current !== null) {1870 if (disclosureSettleSecondFrameRef.current !== null) {
1870 cancelAnimationFrame(foldSettleSecondFrameRef.current);1871 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
1871 }1872 }
1872 };1873 };
1873 }, []);1874 }, []);
18741875
1875 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {1876 const settleDisclosureAfterLayout = useCallback(() => {
1876 disclosureAnchorKeyRef.current = anchorKey;1877 if (disclosureSettleFrameRef.current !== null) {
1877 setDisclosureToggleSettling(true);1878 cancelAnimationFrame(disclosureSettleFrameRef.current);
1878 if (foldSettleFrameRef.current !== null) {
1879 cancelAnimationFrame(foldSettleFrameRef.current);
1880 }1879 }
1881 if (foldSettleSecondFrameRef.current !== null) {1880 if (disclosureSettleSecondFrameRef.current !== null) {
1882 cancelAnimationFrame(foldSettleSecondFrameRef.current);1881 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
1883 }1882 }
1884 foldSettleFrameRef.current = requestAnimationFrame(() => {1883 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
1885 foldSettleSecondFrameRef.current = requestAnimationFrame(() => {1884 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
1886 disclosureAnchorKeyRef.current = null;1885 disclosureAnchorKeyRef.current = null;
1887 setDisclosureToggleSettling(false);1886 setDisclosureToggleSettling(false);
1888 foldSettleFrameRef.current = null;1887 disclosureSettleFrameRef.current = null;
1889 foldSettleSecondFrameRef.current = null;1888 disclosureSettleSecondFrameRef.current = null;
1890 });1889 });
1891 });1890 });
1892 }, []);1891 }, []);
18931892
1893 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
1894 disclosureAnchorKeyRef.current = anchorKey;
1895 setDisclosureToggleSettling(true);
1896 }, []);
1897
1898 // Start the quiet-frame countdown after React has committed the disclosure.
1899 // Every measured item-size change restarts it, so end maintenance cannot
1900 // wake between the data mutation and LegendList's final layout correction.
1901 useLayoutEffect(() => {
1902 if (disclosureAnchorKeyRef.current !== null) {
1903 settleDisclosureAfterLayout();
1904 }
1905 }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
1906
1907 const handleItemSizeChanged = useCallback(() => {
1908 if (disclosureAnchorKeyRef.current !== null) {
1909 settleDisclosureAfterLayout();
1910 }
1911 }, [settleDisclosureAfterLayout]);
1912
1894 const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {1913 const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
1895 const disclosureAnchorKey = disclosureAnchorKeyRef.current;1914 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
1896 return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;1915 return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
...@@ -1975,11 +1994,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1975,11 +1994,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1975 // scrolling up through unmeasured content corrects each row's height as it1994 // scrolling up through unmeasured content corrects each row's height as it
1976 // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows1995 // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
1977 // exact; message rows stay undefined and use LegendList's per-type running1996 // exact; message rows stay undefined and use LegendList's per-type running
1978 // average once one of their type has been measured. Text-driven heights1997 // average once one of their type has been measured.
1979 // follow the configurable base font size via scaledTypographyLineHeight.
1980 const workingRowHeight =
1981 WORKING_ROW_VERTICAL_EXTRAS +
1982 scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize);
1983 const getFixedItemSize = useCallback(1998 const getFixedItemSize = useCallback(
1984 (entry: ThreadFeedEntry) => {1999 (entry: ThreadFeedEntry) => {
1985 switch (entry.type) {2000 switch (entry.type) {
...@@ -1987,46 +2002,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1987,46 +2002,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1987 return TURN_FOLD_HEIGHT;2002 return TURN_FOLD_HEIGHT;
1988 case "work-toggle":2003 case "work-toggle":
1989 return WORK_GROUP_TOGGLE_HEIGHT;2004 return WORK_GROUP_TOGGLE_HEIGHT;
1990 case "working":
1991 return workingRowHeight;
1992 case "activity-group":2005 case "activity-group":
1993 // Expanded rows append a variable detail block — fall back to2006 // Expanded rows append a variable detail block — fall back to
1994 // measurement for those groups.2007 // measurement for those groups.
1995 return entry.activities.some((activity) => expandedWorkRows[activity.id])2008 return entry.activities.some((activity) => expandedWorkRows[activity.id])
1996 ? undefined2009 ? undefined
1997 : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize);2010 : collapsedWorkLogHeight(entry.activities);
1998 default:2011 default:
1999 return undefined;2012 return undefined;
2000 }2013 }
2001 },2014 },
2002 [expandedWorkRows, workingRowHeight, appearance.baseFontSize],2015 [expandedWorkRows],
2003 );2016 );
20042017
2005 const renderItem = useCallback(2018 const renderItem = useCallback(
2006 (info: { item: ThreadFeedEntry; index: number }) =>2019 (info: { item: ThreadFeedEntry; index: number }) => (
2007 renderFeedEntry(info, {2020 <Animated.View
2008 environmentId: props.environmentId,2021 key={info.item.id}
2009 copiedRowId,2022 entering={
2010 expandedWorkRows,2023 disclosureEnteringEntryIds.has(info.item.id)
2011 terminalAssistantMessageIds,2024 ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2012 unsettledTurnId,2025 : undefined
2013 onCopyWorkRow,2026 }
2014 onToggleWorkGroup,2027 exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
2015 onToggleWorkRow,2028 >
2016 onToggleTurnFold,2029 {renderFeedEntry(info, {
2017 onPressImage,2030 environmentId: props.environmentId,
2018 onMarkdownLinkPress,2031 copiedRowId,
2019 renderMarkdownImage,2032 expandedWorkRows,
2020 iconSubtleColor,2033 terminalAssistantMessageIds,
2021 userBubbleColor,2034 unsettledTurnId,
2022 markdownStyles,2035 onCopyWorkRow,
2023 reviewCommentColors,2036 onToggleWorkGroup,
2024 reviewCommentBubbleWidth,2037 onToggleWorkRow,
2025 userBubbleMaxWidth,2038 onToggleTurnFold,
2026 skills: props.skills,2039 onPressImage,
2027 }),2040 onMarkdownLinkPress,
2041 renderMarkdownImage,
2042 iconSubtleColor,
2043 userBubbleColor,
2044 markdownStyles,
2045 reviewCommentColors,
2046 reviewCommentBubbleWidth,
2047 userBubbleMaxWidth,
2048 skills: props.skills,
2049 })}
2050 </Animated.View>
2051 ),
2028 [2052 [
2029 copiedRowId,2053 copiedRowId,
2054 disclosureEnteringEntryIds,
2030 expandedWorkRows,2055 expandedWorkRows,
2031 terminalAssistantMessageIds,2056 terminalAssistantMessageIds,
2032 unsettledTurnId,2057 unsettledTurnId,
...@@ -2093,7 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2093,7 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2093 }2118 }
2094 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}2119 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
2095 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}2120 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2096 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):2121 // Patched LegendList prop (patches/@legendapp__list@3.3.5.patch):
2097 // lets its scroll math clamp programmatic scrolls to -headerInset2122 // lets its scroll math clamp programmatic scrolls to -headerInset
2098 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short2123 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
2099 // content rest below the transparent header rather than at frame top.2124 // content rest below the transparent header rather than at frame top.
...@@ -2148,6 +2173,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2148,6 +2173,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2148 entry.type === "message" ? `message:${entry.message.role}` : entry.type2173 entry.type === "message" ? `message:${entry.message.role}` : entry.type
2149 }2174 }
2150 getFixedItemSize={getFixedItemSize}2175 getFixedItemSize={getFixedItemSize}
2176 itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION}
2177 onItemSizeChanged={handleItemSizeChanged}
2151 // Measure rows well before they scroll into view so estimate→actual2178 // Measure rows well before they scroll into view so estimate→actual
2152 // corrections land offscreen instead of under the user's finger.2179 // corrections land offscreen instead of under the user's finger.
2153 drawDistance={500}2180 drawDistance={500}
apps/mobile/src/features/threads/floating-working-control.tsx created+205
...@@ -0,0 +1,205 @@
1import {
2 isLiquidGlassSupported,
3 LiquidGlassContainerView,
4 LiquidGlassView,
5} from "@callstack/liquid-glass";
6import { useEffect, useState } from "react";
7import { Text as SystemText, View } from "react-native";
8import Animated, {
9 Easing,
10 FadeIn,
11 FadeOut,
12 ReduceMotion,
13 useAnimatedStyle,
14 useSharedValue,
15 withTiming,
16} from "react-native-reanimated";
17import { withUniwind } from "uniwind";
18
19import { AppText as Text } from "../../components/AppText";
20import { ControlPill } from "../../components/ControlPill";
21
22const CONTROL_HEIGHT = 44;
23const CONTROL_COMPOSER_GAP = 8;
24const GLASS_MERGE_SPACING = 12;
25const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
26const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
27const CONTROL_TIMING = {
28 duration: 240,
29 easing: Easing.out(Easing.cubic),
30 reduceMotion: ReduceMotion.System,
31} as const;
32const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2;
33
34const UniwindLiquidGlassView = withUniwind(LiquidGlassView);
35const UniwindLiquidGlassContainerView = withUniwind(LiquidGlassContainerView);
36const AnimatedLiquidGlassView = Animated.createAnimatedComponent(UniwindLiquidGlassView);
37
38export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP;
39
40export function FloatingWorkingControl(props: {
41 readonly colorScheme: "light" | "dark";
42 readonly startedAt: string | null;
43 readonly showScrollToEnd: boolean;
44 readonly onScrollToEnd: () => void;
45}) {
46 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
47
48 useEffect(() => {
49 separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);
50 }, [props.showScrollToEnd, separationProgress]);
51
52 const timerStyle = useAnimatedStyle(() => ({
53 transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],
54 }));
55 const arrowTransformStyle = useAnimatedStyle(() => ({
56 transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],
57 }));
58 const arrowContentStyle = useAnimatedStyle(() => ({
59 opacity: separationProgress.value,
60 }));
61
62 if (props.startedAt === null && !props.showScrollToEnd) {
63 return null;
64 }
65
66 return (
67 <Animated.View
68 pointerEvents="box-none"
69 className="absolute left-0 right-0 z-20 items-center"
70 style={{ top: -FLOATING_WORKING_CONTROL_COVERAGE }}
71 entering={isLiquidGlassSupported ? undefined : CONTROL_ENTERING}
72 exiting={isLiquidGlassSupported ? undefined : CONTROL_EXITING}
73 >
74 {props.startedAt !== null && isLiquidGlassSupported ? (
75 <UniwindLiquidGlassContainerView
76 spacing={GLASS_MERGE_SPACING}
77 pointerEvents="box-none"
78 className="flex-row items-center gap-4"
79 >
80 <AnimatedLiquidGlassView
81 colorScheme={props.colorScheme}
82 effect="regular"
83 pointerEvents="none"
84 className="h-11 justify-center overflow-hidden rounded-full"
85 style={timerStyle}
86 >
87 <WorkingDuration startedAt={props.startedAt} />
88 </AnimatedLiquidGlassView>
89
90 <AnimatedLiquidGlassView
91 colorScheme={props.colorScheme}
92 effect="regular"
93 interactive
94 pointerEvents={props.showScrollToEnd ? "auto" : "none"}
95 accessibilityElementsHidden={!props.showScrollToEnd}
96 importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
97 className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
98 style={arrowTransformStyle}
99 >
100 <Animated.View style={arrowContentStyle}>
101 <ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} />
102 </Animated.View>
103 </AnimatedLiquidGlassView>
104 </UniwindLiquidGlassContainerView>
105 ) : props.startedAt !== null ? (
106 <View pointerEvents="box-none" className="flex-row items-center gap-4">
107 <Animated.View
108 pointerEvents="none"
109 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"
110 style={timerStyle}
111 >
112 <WorkingDuration startedAt={props.startedAt} />
113 </Animated.View>
114
115 <Animated.View
116 pointerEvents={props.showScrollToEnd ? "auto" : "none"}
117 accessibilityElementsHidden={!props.showScrollToEnd}
118 importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
119 style={[arrowTransformStyle, arrowContentStyle]}
120 >
121 <ControlPill
122 accessibilityLabel="Scroll to end"
123 activateOnPressIn
124 className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"
125 disabled={!props.showScrollToEnd}
126 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
127 onPress={props.onScrollToEnd}
128 />
129 </Animated.View>
130 </View>
131 ) : isLiquidGlassSupported ? (
132 <UniwindLiquidGlassView
133 colorScheme={props.colorScheme}
134 effect="regular"
135 interactive
136 className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
137 >
138 <ScrollToEndButton onPress={props.onScrollToEnd} />
139 </UniwindLiquidGlassView>
140 ) : (
141 <ControlPill
142 accessibilityLabel="Scroll to end"
143 activateOnPressIn
144 className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"
145 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
146 onPress={props.onScrollToEnd}
147 />
148 )}
149 </Animated.View>
150 );
151}
152
153function WorkingDuration(props: { readonly startedAt: string }) {
154 const [nowMs, setNowMs] = useState(() => Date.now());
155
156 useEffect(() => {
157 setNowMs(Date.now());
158 const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
159 return () => clearInterval(intervalId);
160 }, [props.startedAt]);
161
162 const duration = formatWorkingDuration(props.startedAt, nowMs);
163 const label = `Working for ${duration}`;
164
165 return (
166 <View accessible accessibilityLabel={label} className="h-11 flex-row items-center px-4">
167 <Text className="font-t3-medium text-xs text-foreground">Working for </Text>
168 <SystemText
169 className="text-xs text-foreground"
170 style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
171 >
172 {duration}
173 </SystemText>
174 </View>
175 );
176}
177
178function formatWorkingDuration(startedAt: string, nowMs: number): string {
179 const startedAtMs = Date.parse(startedAt);
180 if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) {
181 return "0s";
182 }
183
184 const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000);
185 if (totalSeconds < 60) {
186 return `${totalSeconds}s`;
187 }
188
189 const minutes = Math.floor(totalSeconds / 60);
190 const seconds = String(totalSeconds % 60).padStart(2, "0");
191 return `${minutes}m ${seconds}s`;
192}
193
194function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) {
195 return (
196 <ControlPill
197 accessibilityLabel="Scroll to end"
198 activateOnPressIn
199 className="h-11 w-11 bg-transparent"
200 disabled={props.disabled}
201 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
202 onPress={props.onPress}
203 />
204 );
205}
apps/mobile/src/features/threads/thread-work-log.tsx+311-125
...@@ -1,30 +1,201 @@...@@ -1,30 +1,201 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { LayoutAnimation, Pressable, ScrollView, View } from "react-native";3import { MaskedView } from "@expo/ui/community/masked-view";
4import { useIsFocused } from "@react-navigation/native";
5import { useEffect, useId, useState, type ComponentProps } from "react";
6import {
7 AccessibilityInfo,
8 AppState,
9 type ColorValue,
10 Pressable,
11 ScrollView,
12 View,
13} from "react-native";
14import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
415
5import { AppText as Text } from "../../components/AppText";16import { AppText as Text } from "../../components/AppText";
6import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
7import { cn } from "../../lib/cn";17import { cn } from "../../lib/cn";
8import type { ThreadFeedActivity } from "../../lib/threadActivity";18import type { ThreadFeedActivity } from "../../lib/threadActivity";
9import { MOBILE_TYPOGRAPHY } from "../../lib/typography";19import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation";
10import Animated, { FadeIn } from "react-native-reanimated";20import Animated, {
21 cancelAnimation,
22 Easing,
23 FadeIn,
24 FadeOut,
25 LinearTransition,
26 ReduceMotion,
27 useAnimatedStyle,
28 useSharedValue,
29 withDelay,
30 withRepeat,
31 withSequence,
32 withTiming,
33} from "react-native-reanimated";
1134
12const WORK_LOG_LAYOUT_ANIMATION = {35const SHIMMER_WIDTH = 72;
13 duration: 180,36const SHIMMER_SWEEP_MS = 1_350;
14 create: {37const SHIMMER_PAUSE_MS = 1_450;
15 type: LayoutAnimation.Types.easeInEaseOut,38const SHIMMER_ICON_AND_GAP_WIDTH = 30;
16 property: LayoutAnimation.Properties.opacity,39export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
17 },40const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
18 update: { type: LayoutAnimation.Types.easeInEaseOut },41const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
19 delete: {42const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
20 type: LayoutAnimation.Types.easeInEaseOut,
21 property: LayoutAnimation.Properties.opacity,
22 },
23} as const;
2443
25function triggerDisclosureFeedback() {44function ShimmerWorkContent(props: {
26 LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION);45 readonly highlighted: boolean;
27 void Haptics.selectionAsync();46 readonly icon: AppSymbolName;
47 readonly iconSubtleColor: ColorValue;
48 readonly label: string;
49 readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"];
50 readonly showIcon: boolean;
51}) {
52 return (
53 <View className="flex-row items-center gap-1.5">
54 <View className="h-6 w-6 shrink-0 items-center justify-center">
55 {props.showIcon ? (
56 <SymbolView
57 name={props.icon}
58 size={14}
59 weight="medium"
60 {...(props.highlighted
61 ? { tintColorClassName: "accent-foreground" as const }
62 : { tintColor: props.iconSubtleColor })}
63 type="monochrome"
64 />
65 ) : null}
66 </View>
67 <Text
68 className={cn(
69 "min-w-0 shrink text-sm",
70 props.highlighted ? "text-foreground" : "text-foreground-muted",
71 )}
72 numberOfLines={1}
73 onTextLayout={props.onTextLayout}
74 >
75 {props.label}
76 </Text>
77 </View>
78 );
79}
80
81function ShimmeringWorkContent(props: {
82 readonly icon: AppSymbolName;
83 readonly iconSubtleColor: ColorValue;
84 readonly label: string;
85 readonly showIcon: boolean;
86}) {
87 const [availableWidth, setAvailableWidth] = useState(0);
88 const [textWidth, setTextWidth] = useState(0);
89 const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active");
90 const [reducedMotion, setReducedMotion] = useState(true);
91 const screenIsFocused = useIsFocused();
92 const progress = useSharedValue(0);
93 const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`;
94 const contentWidth = Math.min(availableWidth, SHIMMER_ICON_AND_GAP_WIDTH + Math.ceil(textWidth));
95
96 useEffect(() => {
97 const subscription = AppState.addEventListener("change", (state) => {
98 setAppIsActive(state === "active");
99 });
100 return () => subscription.remove();
101 }, []);
102
103 useEffect(() => {
104 void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion);
105 const subscription = AccessibilityInfo.addEventListener(
106 "reduceMotionChanged",
107 setReducedMotion,
108 );
109 return () => subscription.remove();
110 }, []);
111
112 useEffect(() => {
113 cancelAnimation(progress);
114 progress.value = 0;
115 if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return;
116
117 progress.value = withRepeat(
118 withSequence(
119 withTiming(1, {
120 duration: SHIMMER_SWEEP_MS,
121 easing: Easing.linear,
122 reduceMotion: ReduceMotion.Never,
123 }),
124 withDelay(
125 SHIMMER_PAUSE_MS,
126 withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }),
127 ),
128 ),
129 -1,
130 false,
131 undefined,
132 ReduceMotion.Never,
133 );
134 return () => cancelAnimation(progress);
135 }, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]);
136
137 const sweepStyle = useAnimatedStyle(() => ({
138 transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }],
139 }));
140 const counterSweepStyle = useAnimatedStyle(() => ({
141 transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }],
142 }));
143
144 return (
145 <View
146 className="min-w-0 flex-1"
147 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
148 >
149 <ShimmerWorkContent
150 highlighted={false}
151 icon={props.icon}
152 iconSubtleColor={props.iconSubtleColor}
153 label={props.label}
154 showIcon={props.showIcon}
155 onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)}
156 />
157 {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? (
158 <Animated.View
159 className="absolute inset-y-0 left-0 overflow-hidden"
160 pointerEvents="none"
161 accessible={false}
162 accessibilityElementsHidden
163 importantForAccessibility="no-hide-descendants"
164 style={[{ width: SHIMMER_WIDTH }, sweepStyle]}
165 >
166 <MaskedView
167 className="absolute inset-0"
168 maskElement={
169 <Svg width="100%" height="100%">
170 <Defs>
171 <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
172 <Stop offset="0" stopColor="white" stopOpacity={0} />
173 <Stop offset="0.15" stopColor="white" stopOpacity={0.12} />
174 <Stop offset="0.35" stopColor="white" stopOpacity={0.55} />
175 <Stop offset="0.5" stopColor="white" stopOpacity={1} />
176 <Stop offset="0.65" stopColor="white" stopOpacity={0.55} />
177 <Stop offset="0.85" stopColor="white" stopOpacity={0.12} />
178 <Stop offset="1" stopColor="white" stopOpacity={0} />
179 </LinearGradient>
180 </Defs>
181 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
182 </Svg>
183 }
184 >
185 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
186 <ShimmerWorkContent
187 highlighted
188 icon={props.icon}
189 iconSubtleColor={props.iconSubtleColor}
190 label={props.label}
191 showIcon={props.showIcon}
192 />
193 </Animated.View>
194 </MaskedView>
195 </Animated.View>
196 ) : null}
197 </View>
198 );
28}199}
29200
30function stripShellWrapper(value: string): string {201function stripShellWrapper(value: string): string {
...@@ -79,44 +250,23 @@ function isFreshRow(createdAt: string): boolean {...@@ -79,44 +250,23 @@ function isFreshRow(createdAt: string): boolean {
79 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;250 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
80}251}
81252
82// Tool-like activities with a neutral status carry no signal worth a row.
83export function visibleWorkLogActivities(
84 activities: ReadonlyArray<ThreadFeedActivity>,
85): ReadonlyArray<ThreadFeedActivity> {
86 return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral"));
87}
88
89// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log253// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log
90// rows are single-line (numberOfLines={1}) inside a min-height that stays254// rows are single-line (numberOfLines={1}) inside a min-height that stays
91// taller than the text at every supported base font size (text-xs reaches255// taller than text-sm at every supported base font size, so row height is
92// 23px at the 22pt maximum, under the 32px min-h-8), so row height is256// deterministic. Values mirror the classNames below. A mismatch only costs a
93// deterministic. The "work log" label has no such clamp — its height follows257// one-time correction on measure.
94// the scaled text-2xs line height. Values mirror the classNames below — keep
95// them in sync; a mismatch only costs a one-time correction on measure.
96const WORK_ROW_HEIGHT = 32; // min-h-8258const WORK_ROW_HEIGHT = 32; // min-h-8
97const WORK_ROW_GAP = 1; // gap-px259const WORK_ROW_GAP = 1; // gap-px
98const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label
99const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1260const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1
100261
101export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)262export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
102263
103export function collapsedWorkLogHeight(264export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
104 activities: ReadonlyArray<ThreadFeedActivity>,265 const rows = activities;
105 baseFontSize: number,
106): number {
107 const rows = visibleWorkLogActivities(activities);
108 if (rows.length === 0) {266 if (rows.length === 0) {
109 return 0;267 return 0;
110 }268 }
111 const onlyToolRows = rows.every((row) => row.toolLike);269 return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP;
112 const headerHeight =
113 scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING;
114 return (
115 WORK_LOG_BOTTOM_MARGIN +
116 (onlyToolRows ? 0 : headerHeight) +
117 rows.length * WORK_ROW_HEIGHT +
118 (rows.length - 1) * WORK_ROW_GAP
119 );
120}270}
121271
122export function ThreadWorkLog(props: {272export function ThreadWorkLog(props: {
...@@ -127,7 +277,7 @@ export function ThreadWorkLog(props: {...@@ -127,7 +277,7 @@ export function ThreadWorkLog(props: {
127 readonly onCopyRow: (rowId: string, value: string) => void;277 readonly onCopyRow: (rowId: string, value: string) => void;
128 readonly onToggleRow: (rowId: string) => void;278 readonly onToggleRow: (rowId: string) => void;
129}) {279}) {
130 const rows = visibleWorkLogActivities(props.activities).map((activity) => ({280 const rows = props.activities.map((activity) => ({
131 ...activity,281 ...activity,
132 detail: compactActivityDetail(activity.detail),282 detail: compactActivityDetail(activity.detail),
133 }));283 }));
...@@ -136,32 +286,28 @@ export function ThreadWorkLog(props: {...@@ -136,32 +286,28 @@ export function ThreadWorkLog(props: {
136 return null;286 return null;
137 }287 }
138288
139 const onlyToolRows = rows.every((row) => row.toolLike);
140
141 return (289 return (
142 <View className="-mx-1 mb-1 px-1 py-0">290 <View className="-mx-1 mb-1 px-1 py-0">
143 {!onlyToolRows ? (
144 <Text className="px-0.5 pb-0.5 font-t3-medium text-2xs text-foreground-muted opacity-60">
145 work log
146 </Text>
147 ) : null}
148
149 <View className="gap-px">291 <View className="gap-px">
150 {rows.map((row) => {292 {rows.map((row) => {
151 const expanded = props.expandedRows[row.id] ?? false;293 const expanded = props.expandedRows[row.id] ?? false;
152 const canExpand = row.canExpand;294 const canExpand = row.canExpand;
153 const fullDetail = expanded ? row.getFullDetail() : null;295 const fullDetail = expanded ? row.getFullDetail() : null;
154 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;296 const displayText = row.detail ?? row.summary;
155 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";297 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
298 const failed = row.status === "failure";
299 const showIcon = !row.groupedToolDetail || iconIsDestructive || failed;
156300
157 return (301 return (
158 <Animated.View302 <Animated.View
159 key={row.id}303 key={row.id}
304 layout={WORK_LOG_LAYOUT_TRANSITION}
305 className="overflow-hidden"
160 {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}306 {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
161 >307 >
162 <Pressable308 <Pressable
163 accessibilityRole={canExpand ? "button" : undefined}309 accessibilityRole={canExpand ? "button" : undefined}
164 accessibilityLabel={displayText}310 accessibilityLabel={failed ? `${displayText}, tool call failed` : displayText}
165 accessibilityHint={311 accessibilityHint={
166 canExpand312 canExpand
167 ? "Double tap to show full details. Long press to copy."313 ? "Double tap to show full details. Long press to copy."
...@@ -171,7 +317,7 @@ export function ThreadWorkLog(props: {...@@ -171,7 +317,7 @@ export function ThreadWorkLog(props: {
171 hitSlop={4}317 hitSlop={4}
172 onPress={() => {318 onPress={() => {
173 if (canExpand) {319 if (canExpand) {
174 triggerDisclosureFeedback();320 void Haptics.selectionAsync();
175 props.onToggleRow(row.id);321 props.onToggleRow(row.id);
176 }322 }
177 }}323 }}
...@@ -179,29 +325,41 @@ export function ThreadWorkLog(props: {...@@ -179,29 +325,41 @@ export function ThreadWorkLog(props: {
179 className="rounded-md px-0.5 py-0 active:bg-subtle"325 className="rounded-md px-0.5 py-0 active:bg-subtle"
180 >326 >
181 <View className="min-h-8 flex-row items-center gap-1.5">327 <View className="min-h-8 flex-row items-center gap-1.5">
182 <View className="h-[18px] w-5 shrink-0 items-center justify-center">328 {row.live ? (
183 <SymbolView329 <ShimmeringWorkContent
184 name={workRowSymbolName(row.icon)}330 icon={workRowSymbolName(row.icon)}
185 size={13}331 iconSubtleColor={props.iconSubtleColor}
186 weight="medium"332 label={displayText}
187 tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}333 showIcon={showIcon}
188 type="monochrome"
189 />334 />
190 </View>335 ) : (
191336 <>
192 <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}>337 <View className="h-6 w-6 shrink-0 items-center justify-center">
193 <Text338 {showIcon ? (
194 className={cn(339 <SymbolView
195 "font-t3-medium text-foreground",340 name={
196 iconIsDestructive && "text-adaptive-rose-600-400",341 failed
197 )}342 ? { ios: "xmark", android: "close" }
198 >343 : workRowSymbolName(row.icon)
199 {row.summary}344 }
200 </Text>345 size={14}
201 {row.detail ? (346 weight="medium"
202 <Text className="text-foreground-muted opacity-60"> {row.detail}</Text>347 tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
203 ) : null}348 type="monochrome"
204 </Text>349 />
350 ) : null}
351 </View>
352 <Text
353 className={cn(
354 "min-w-0 flex-1 text-sm text-foreground-muted",
355 iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400",
356 )}
357 numberOfLines={1}
358 >
359 {displayText}
360 </Text>
361 </>
362 )}
205363
206 <View className="shrink-0 flex-row items-center gap-px">364 <View className="shrink-0 flex-row items-center gap-px">
207 {props.copiedRowId === row.id ? (365 {props.copiedRowId === row.id ? (
...@@ -223,28 +381,17 @@ export function ThreadWorkLog(props: {...@@ -223,28 +381,17 @@ export function ThreadWorkLog(props: {
223 />381 />
224 ) : null}382 ) : null}
225 </View>383 </View>
226 <View className="h-4 w-4 items-center justify-center">
227 {row.status ? (
228 <SymbolView
229 name={
230 row.status === "failure"
231 ? { ios: "xmark", android: "close" }
232 : row.status === "success"
233 ? { ios: "checkmark", android: "check" }
234 : { ios: "minus", android: "remove" }
235 }
236 size={11}
237 tintColor={props.iconSubtleColor}
238 type="monochrome"
239 />
240 ) : null}
241 </View>
242 </View>384 </View>
243 </View>385 </View>
244 </Pressable>386 </Pressable>
245387
246 {fullDetail ? (388 {fullDetail ? (
247 <View className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5">389 <Animated.View
390 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
391 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
392 layout={WORK_LOG_LAYOUT_TRANSITION}
393 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
394 >
248 <ScrollView395 <ScrollView
249 nestedScrollEnabled396 nestedScrollEnabled
250 directionalLockEnabled397 directionalLockEnabled
...@@ -259,7 +406,7 @@ export function ThreadWorkLog(props: {...@@ -259,7 +406,7 @@ export function ThreadWorkLog(props: {
259 {fullDetail}406 {fullDetail}
260 </Text>407 </Text>
261 </ScrollView>408 </ScrollView>
262 </View>409 </Animated.View>
263 ) : null}410 ) : null}
264 </Animated.View>411 </Animated.View>
265 );412 );
...@@ -273,27 +420,24 @@ export function ThreadWorkGroupToggle(props: {...@@ -273,27 +420,24 @@ export function ThreadWorkGroupToggle(props: {
273 readonly expanded: boolean;420 readonly expanded: boolean;
274 readonly hiddenCount: number;421 readonly hiddenCount: number;
275 readonly iconSubtleColor: import("react-native").ColorValue;422 readonly iconSubtleColor: import("react-native").ColorValue;
276 readonly onlyToolActivities: boolean;423 readonly summary: string;
424 readonly summaryKind: ToolGroupSummaryKind;
425 readonly hasFailure: boolean;
426 readonly shimmer: boolean;
277 readonly onToggle: () => void;427 readonly onToggle: () => void;
278}) {428}) {
279 const noun = props.onlyToolActivities429 const accessibilityLabel = props.hasFailure
280 ? props.hiddenCount === 1430 ? `${props.summary}, tool call failed`
281 ? "tool call"431 : props.summary;
282 : "tool calls"432 const icon = toolGroupSummarySymbolName(props.summaryKind);
283 : props.hiddenCount === 1
284 ? "log entry"
285 : "log entries";
286 const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`;
287 const expandedLabel = props.onlyToolActivities
288 ? "Show fewer tool calls"
289 : "Show fewer log entries";
290433
291 return (434 return (
292 <View className="-mx-1 mb-1 px-1 py-0">435 <View className="-mx-1 mb-1 px-1 py-0">
293 <Pressable436 <Pressable
294 accessibilityRole="button"437 accessibilityRole="button"
295 accessibilityState={{ expanded: props.expanded }}438 accessibilityState={{ expanded: props.expanded }}
296 accessibilityLabel={props.expanded ? expandedLabel : collapsedLabel}439 accessibilityLabel={accessibilityLabel}
440 accessibilityHint={`Double tap to ${props.expanded ? "hide" : "show"} ${props.hiddenCount} tool ${props.hiddenCount === 1 ? "call" : "calls"}.`}
297 hitSlop={4}441 hitSlop={4}
298 onPress={() => {442 onPress={() => {
299 void Haptics.selectionAsync();443 void Haptics.selectionAsync();
...@@ -301,22 +445,64 @@ export function ThreadWorkGroupToggle(props: {...@@ -301,22 +445,64 @@ export function ThreadWorkGroupToggle(props: {
301 }}445 }}
302 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"446 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
303 >447 >
304 <View className="h-[18px] w-5 items-center justify-center">448 {props.shimmer ? (
305 <SymbolView449 <ShimmeringWorkContent
306 name={450 icon={icon}
307 props.expanded451 iconSubtleColor={props.iconSubtleColor}
308 ? { ios: "chevron.up", android: "keyboard_arrow_up" }452 label={props.summary}
309 : { ios: "chevron.down", android: "keyboard_arrow_down" }453 showIcon
310 }
311 size={12}
312 tintColor={props.iconSubtleColor}
313 type="monochrome"
314 />454 />
315 </View>455 ) : (
316 <Text className="font-t3-medium text-xs text-foreground opacity-80">456 <>
317 {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`}457 <View className="h-6 w-6 items-center justify-center">
318 </Text>458 <SymbolView
459 name={icon}
460 size={14}
461 tintColor={props.iconSubtleColor}
462 type="monochrome"
463 />
464 </View>
465 <Text className="min-w-0 flex-1 text-sm text-foreground-muted" numberOfLines={1}>
466 {props.summary}
467 </Text>
468 </>
469 )}
470 <SymbolView
471 name={
472 props.expanded
473 ? { ios: "chevron.up", android: "keyboard_arrow_up" }
474 : { ios: "chevron.down", android: "keyboard_arrow_down" }
475 }
476 size={11}
477 tintColor={props.iconSubtleColor}
478 type="monochrome"
479 />
319 </Pressable>480 </Pressable>
320 </View>481 </View>
321 );482 );
322}483}
484
485function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
486 switch (kind) {
487 case "read":
488 return { ios: "eye", android: "visibility" };
489 case "edit":
490 return { ios: "square.and.pencil", android: "edit" };
491 case "command":
492 return { ios: "terminal", android: "terminal" };
493 case "search":
494 return { ios: "globe", android: "public" };
495 case "code-search":
496 return "magnifyingglass";
497 case "other":
498 return { ios: "wrench", android: "build" };
499 case "agent-tool":
500 return { ios: "sparkles", android: "auto_awesome" };
501 case "tone-tool":
502 return { ios: "bolt", android: "bolt" };
503 case "dynamic-tool":
504 case "update":
505 case "mixed":
506 return { ios: "hammer", android: "construction" };
507 }
508}
apps/mobile/src/lib/threadActivity.test.ts+242-26
...@@ -411,8 +411,8 @@ describe("buildThreadFeed", () => {...@@ -411,8 +411,8 @@ describe("buildThreadFeed", () => {
411411
412 expect(group.activities).toHaveLength(1);412 expect(group.activities).toHaveLength(1);
413 expect(group.activities[0]).toMatchObject({413 expect(group.activities[0]).toMatchObject({
414 id: "tool-completed",414 id: "tool-updated",
415 createdAt: "2026-04-01T00:00:02.000Z",415 createdAt: "2026-04-01T00:00:01.000Z",
416 turnId: "turn-1",416 turnId: "turn-1",
417 summary: "Run tests",417 summary: "Run tests",
418 detail: "bun run test",418 detail: "bun run test",
...@@ -592,7 +592,7 @@ describe("buildThreadFeed", () => {...@@ -592,7 +592,7 @@ describe("buildThreadFeed", () => {
592 expect(expanded.map((entry) => entry.id)).toEqual([592 expect(expanded.map((entry) => entry.id)).toEqual([
593 "assistant-first",593 "assistant-first",
594 "turn-fold:turn-1",594 "turn-fold:turn-1",
595 "tool-completed",595 "work-toggle:work-group:tool-completed",
596 "assistant-final",596 "assistant-final",
597 ]);597 ]);
598 });598 });
...@@ -745,6 +745,20 @@ describe("buildThreadFeed", () => {...@@ -745,6 +745,20 @@ describe("buildThreadFeed", () => {
745 assistantMessageId: null,745 assistantMessageId: null,
746 },746 },
747 activities: [747 activities: [
748 makeActivity({
749 id: EventId.make("tool-succeeded"),
750 kind: "tool.completed",
751 tone: "tool",
752 summary: "Run command",
753 createdAt: "2026-04-01T00:00:04.000Z",
754 turnId,
755 payload: {
756 title: "Run command",
757 itemType: "command_execution",
758 detail: "done",
759 status: "completed",
760 },
761 }),
748 makeActivity({762 makeActivity({
749 id: EventId.make("tool-failed"),763 id: EventId.make("tool-failed"),
750 kind: "tool.completed",764 kind: "tool.completed",
...@@ -763,25 +777,26 @@ describe("buildThreadFeed", () => {...@@ -763,25 +777,26 @@ describe("buildThreadFeed", () => {
763 });777 });
764778
765 const feed = buildThreadFeed(thread);779 const feed = buildThreadFeed(thread);
766 expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed);780 expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toMatchObject([
767 expect(feed[0]).toMatchObject({
768 type: "activity-group",
769 activities: [{ status: "failure" }],
770 });
771 });
772
773 it("appends active work as a normal timeline row", () => {
774 const startedAt = "2026-04-01T00:00:01.000Z";
775 const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt);
776
777 expect(presented).toEqual([
778 {781 {
779 type: "working",782 type: "work-toggle",
780 id: "working-indicator-row",783 summary: "Ran 2 commands",
781 createdAt: startedAt,784 hiddenCount: 2,
785 hasFailure: true,
782 },786 },
783 ]);787 ]);
784 expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]);788 expect(feed[0]).toMatchObject({
789 type: "activity-group",
790 activities: [{ status: "success" }, { status: "failure" }],
791 });
792 expect(
793 deriveThreadFeedPresentation(
794 feed,
795 thread.latestTurn,
796 new Set(),
797 new Set(["work-group:tool-succeeded"]),
798 ).map((entry) => entry.id),
799 ).toEqual(["work-toggle:work-group:tool-succeeded", "tool-succeeded", "tool-failed"]);
785 });800 });
786801
787 it("models work-log overflow as list rows", () => {802 it("models work-log overflow as list rows", () => {
...@@ -801,6 +816,14 @@ describe("buildThreadFeed", () => {...@@ -801,6 +816,14 @@ describe("buildThreadFeed", () => {
801 icon: "command",816 icon: "command",
802 toolLike: true,817 toolLike: true,
803 status,818 status,
819 workEntry: {
820 id,
821 createdAt,
822 turnId: null,
823 label: `Tool ${id}`,
824 command: `command ${id}`,
825 tone: "tool",
826 },
804 });827 });
805 const feed: ThreadFeedEntry[] = [828 const feed: ThreadFeedEntry[] = [
806 {829 {
...@@ -818,26 +841,216 @@ describe("buildThreadFeed", () => {...@@ -818,26 +841,216 @@ describe("buildThreadFeed", () => {
818 ];841 ];
819842
820 const collapsed = deriveThreadFeedPresentation(feed, null, new Set());843 const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
821 expect(collapsed.map((entry) => entry.id)).toEqual(["activity-3", "work-toggle:work-group-1"]);844 expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-1"]);
822 expect(collapsed[1]).toMatchObject({845 expect(collapsed[0]).toMatchObject({
823 type: "work-toggle",846 type: "work-toggle",
824 groupId: "work-group-1",847 groupId: "work-group:activity-1",
825 hiddenCount: 2,848 hiddenCount: 3,
826 expanded: false,849 expanded: false,
850 summary: "Ran 3 commands",
827 });851 });
828852
829 const expanded = deriveThreadFeedPresentation(feed, null, new Set(), new Set(["work-group-1"]));853 const expanded = deriveThreadFeedPresentation(
854 feed,
855 null,
856 new Set(),
857 new Set(["work-group:activity-1"]),
858 );
830 expect(expanded.map((entry) => entry.id)).toEqual([859 expect(expanded.map((entry) => entry.id)).toEqual([
860 "work-toggle:work-group:activity-1",
831 "activity-1",861 "activity-1",
832 "activity-2",862 "activity-2",
833 "activity-3",863 "activity-3",
834 "work-toggle:work-group-1",
835 ]);864 ]);
836 expect(expanded.at(-1)).toMatchObject({865 expect(expanded[0]).toMatchObject({
837 type: "work-toggle",866 type: "work-toggle",
838 expanded: true,867 expanded: true,
839 });868 });
840 });869 });
870
871 it("keeps live state on the active uninterrupted tool run", () => {
872 const turnId = TurnId.make("turn-live-tools");
873 const activity = (
874 id: string,
875 status: ThreadFeedActivity["status"],
876 lifecycleStatus: ThreadFeedActivity["lifecycleStatus"],
877 tone: "tool" | "error" = "tool",
878 command?: string,
879 ): ThreadFeedActivity => ({
880 id,
881 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
882 turnId,
883 summary: `Tool ${id}`,
884 detail: null,
885 canExpand: false,
886 getFullDetail: () => null,
887 getCopyText: () => id,
888 icon: "command",
889 toolLike: true,
890 status,
891 lifecycleStatus,
892 workEntry: {
893 id,
894 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
895 turnId,
896 label: `Tool ${id}`,
897 tone,
898 toolLifecycleStatus: lifecycleStatus,
899 ...(command ? { command, itemType: "command_execution" as const } : {}),
900 },
901 });
902 const feed: ThreadFeedEntry[] = [
903 {
904 type: "activity-group",
905 id: "activity-1",
906 createdAt: "2026-04-01T00:00:01.000Z",
907 turnId,
908 activities: [
909 activity("activity-1", "success", "completed"),
910 activity("activity-2", "failure", "failed", "error"),
911 activity("activity-3", "success", "completed", "tool", "sudo -u root pnpm test"),
912 ],
913 },
914 ];
915 const latestTurn = {
916 turnId,
917 state: "running" as const,
918 requestedAt: "2026-04-01T00:00:00.000Z",
919 startedAt: "2026-04-01T00:00:00.000Z",
920 completedAt: null,
921 assistantMessageId: null,
922 };
923
924 const rows = deriveThreadFeedPresentation(
925 feed,
926 latestTurn,
927 new Set(),
928 new Set(),
929 latestTurn.startedAt,
930 );
931 expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
932 ["work-toggle:work-group:activity-1", "work-toggle"],
933 ["activity-2", "activity-group"],
934 ["work-live:work-group:activity-3", "work-toggle"],
935 ]);
936 expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
937 false,
938 false,
939 true,
940 ]);
941 expect(rows[2]).toMatchObject({
942 summary: "Running pnpm",
943 summaryKind: "command",
944 live: true,
945 shimmer: false,
946 });
947 });
948
949 it("does not revive cached in-progress tools after work stops", () => {
950 const turnId = TurnId.make("turn-stale-tool");
951 const feed: ThreadFeedEntry[] = [
952 {
953 type: "activity-group",
954 id: "stale-tool",
955 createdAt: "2026-04-01T00:00:01.000Z",
956 turnId,
957 activities: [
958 {
959 id: "stale-tool",
960 createdAt: "2026-04-01T00:00:01.000Z",
961 turnId,
962 summary: "Running tests",
963 detail: null,
964 canExpand: false,
965 getFullDetail: () => null,
966 getCopyText: () => "",
967 icon: "command",
968 toolLike: true,
969 status: "neutral",
970 lifecycleStatus: "inProgress",
971 workEntry: {
972 id: "stale-tool",
973 createdAt: "2026-04-01T00:00:01.000Z",
974 turnId,
975 label: "Running tests",
976 tone: "tool",
977 toolLifecycleStatus: "inProgress",
978 },
979 },
980 ],
981 },
982 ];
983 const latestTurn = {
984 turnId,
985 state: "running" as const,
986 requestedAt: "2026-04-01T00:00:00.000Z",
987 startedAt: "2026-04-01T00:00:00.000Z",
988 completedAt: null,
989 assistantMessageId: null,
990 };
991
992 expect(deriveThreadFeedPresentation(feed, latestTurn, new Set())).toEqual([]);
993 expect(
994 deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), latestTurn.startedAt),
995 ).toMatchObject([{ type: "work-toggle", live: true, shimmer: true }]);
996 });
997
998 it("collapses interleaved tool lifecycles by call identity", () => {
999 const turnId = TurnId.make("turn-parallel-tools");
1000 const toolActivity = (
1001 id: string,
1002 toolCallId: string,
1003 kind: "tool.updated" | "tool.completed",
1004 status: "inProgress" | "completed",
1005 detail: string,
1006 nestedId = false,
1007 ) =>
1008 makeActivity({
1009 id: EventId.make(id),
1010 kind,
1011 tone: "tool",
1012 summary: `Run ${toolCallId} command`,
1013 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
1014 turnId,
1015 payload: {
1016 ...(nestedId ? { data: { toolCallId } } : { toolCallId }),
1017 itemType: "command_execution",
1018 status,
1019 detail,
1020 },
1021 });
1022 const thread = makeThread({
1023 id: ThreadId.make("thread-parallel-tools"),
1024 projectId: ProjectId.make("project-1"),
1025 title: "Parallel tools",
1026 activities: [
1027 toolActivity("call-a-1", "call-a", "tool.updated", "inProgress", "starting"),
1028 toolActivity("call-b-2", "call-b", "tool.updated", "inProgress", "starting", true),
1029 toolActivity("call-a-3", "call-a", "tool.completed", "completed", "first output"),
1030 toolActivity("call-b-4", "call-b", "tool.completed", "completed", "second output", true),
1031 ],
1032 });
1033
1034 const feed = buildThreadFeed(thread);
1035 const activityGroup = feed.find((entry) => entry.type === "activity-group");
1036 expect(activityGroup).toMatchObject({
1037 type: "activity-group",
1038 activities: [
1039 { id: "call-a-1", lifecycleStatus: "completed", detail: "first output" },
1040 { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" },
1041 ],
1042 });
1043 expect(
1044 deriveThreadFeedPresentation(feed, null, new Set([turnId])).find(
1045 (entry) => entry.type === "work-toggle",
1046 ),
1047 ).toMatchObject({
1048 type: "work-toggle",
1049 hiddenCount: 2,
1050 summary: "Ran 2 commands",
1051 live: false,
1052 });
1053 });
841});1054});
8421055
843describe("quiet timeline: nested agents", () => {1056describe("quiet timeline: nested agents", () => {
...@@ -873,5 +1086,8 @@ describe("quiet timeline: nested agents", () => {...@@ -873,5 +1086,8 @@ describe("quiet timeline: nested agents", () => {
873 );1086 );
874 expect(ids).toContain("nested-done");1087 expect(ids).toContain("nested-done");
875 expect(ids).not.toContain("shell-done");1088 expect(ids).not.toContain("shell-done");
1089 expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([
1090 { type: "activity-group", id: "nested-done" },
1091 ]);
876 });1092 });
877});1093});
apps/mobile/src/lib/threadActivity.ts+234-59
...@@ -13,6 +13,14 @@ import type {...@@ -13,6 +13,14 @@ import type {
13 UserInputQuestion,13 UserInputQuestion,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { formatDuration } from "@t3tools/shared/orchestrationTiming";15import { formatDuration } from "@t3tools/shared/orchestrationTiming";
16import {
17 normalizeCompactToolLabel,
18 omitSupersededLifecycleMarkers,
19 summarizeToolGroup,
20 toolGroupSummaryKind,
21 type ToolGroupSummaryKind,
22} from "@t3tools/client-runtime/work-log/presentation";
23import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
1624
17import * as Arr from "effect/Array";25import * as Arr from "effect/Array";
18import * as Order from "effect/Order";26import * as Order from "effect/Order";
...@@ -65,13 +73,15 @@ export interface ThreadFeedActivity {...@@ -65,13 +73,15 @@ export interface ThreadFeedActivity {
65 | "zap";73 | "zap";
66 readonly toolLike: boolean;74 readonly toolLike: boolean;
67 readonly status: "success" | "failure" | "neutral" | null;75 readonly status: "success" | "failure" | "neutral" | null;
76 readonly lifecycleStatus?: WorkLogToolLifecycleStatus;
77 readonly workEntry: WorkLogEntry;
78 readonly groupedToolDetail?: boolean;
79 readonly live?: boolean;
68}80}
6981
70const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
71
72type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";82type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
7383
74interface WorkLogEntry {84export interface WorkLogEntry {
75 id: string;85 id: string;
76 createdAt: string;86 createdAt: string;
77 turnId: TurnId | null;87 turnId: TurnId | null;
...@@ -85,11 +95,14 @@ interface WorkLogEntry {...@@ -85,11 +95,14 @@ interface WorkLogEntry {
85 itemType?: ToolLifecycleItemType;95 itemType?: ToolLifecycleItemType;
86 requestKind?: PendingApproval["requestKind"];96 requestKind?: PendingApproval["requestKind"];
87 toolLifecycleStatus?: WorkLogToolLifecycleStatus;97 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
98 sourceActivityKind?: OrchestrationThreadActivity["kind"];
99 toolCallId?: string;
100 agentSpawn?: boolean;
88 toolData?: unknown;101 toolData?: unknown;
89}102}
90103
91interface DerivedWorkLogEntry extends WorkLogEntry {104interface DerivedWorkLogEntry extends WorkLogEntry {
92 activityKind: OrchestrationThreadActivity["kind"];105 sourceActivityKind: OrchestrationThreadActivity["kind"];
93 collapseKey?: string;106 collapseKey?: string;
94 /** Grouping key for subagent lifecycle rows (one row per agent). */107 /** Grouping key for subagent lifecycle rows (one row per agent). */
95 taskId?: string;108 taskId?: string;
...@@ -112,11 +125,6 @@ type RawThreadFeedEntry =...@@ -112,11 +125,6 @@ type RawThreadFeedEntry =
112125
113export type ThreadFeedEntry =126export type ThreadFeedEntry =
114 | Extract<RawThreadFeedEntry, { type: "message" }>127 | Extract<RawThreadFeedEntry, { type: "message" }>
115 | {
116 readonly type: "working";
117 readonly id: string;
118 readonly createdAt: string;
119 }
120 | {128 | {
121 readonly type: "activity-group";129 readonly type: "activity-group";
122 readonly id: string;130 readonly id: string;
...@@ -132,7 +140,11 @@ export type ThreadFeedEntry =...@@ -132,7 +140,11 @@ export type ThreadFeedEntry =
132 readonly groupId: string;140 readonly groupId: string;
133 readonly hiddenCount: number;141 readonly hiddenCount: number;
134 readonly expanded: boolean;142 readonly expanded: boolean;
135 readonly onlyToolActivities: boolean;143 readonly summary: string;
144 readonly summaryKind: ToolGroupSummaryKind;
145 readonly hasFailure: boolean;
146 readonly live: boolean;
147 readonly shimmer: boolean;
136 }148 }
137 | {149 | {
138 readonly type: "turn-fold";150 readonly type: "turn-fold";
...@@ -412,8 +424,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -412,8 +424,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
412 : activity.tone === "approval"424 : activity.tone === "approval"
413 ? "info"425 ? "info"
414 : activity.tone,426 : activity.tone,
415 activityKind: activity.kind,427 sourceActivityKind: activity.kind,
416 };428 };
429 const toolCallId =
430 asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
431 if (toolCallId) {
432 entry.toolCallId = toolCallId;
433 }
434 if (isTaskActivity && payload?.agentKind === "agent") {
435 entry.agentSpawn = true;
436 }
417 const itemType = extractWorkLogItemType(payload);437 const itemType = extractWorkLogItemType(payload);
418 const requestKind = extractWorkLogRequestKind(payload);438 const requestKind = extractWorkLogRequestKind(payload);
419 if (439 if (
...@@ -472,12 +492,13 @@ function collapseDerivedWorkLogEntries(...@@ -472,12 +492,13 @@ function collapseDerivedWorkLogEntries(
472 // Subagent rows collapse by identity, not adjacency (quiet-timeline492 // Subagent rows collapse by identity, not adjacency (quiet-timeline
473 // guarantee; mirrors web's session-logic).493 // guarantee; mirrors web's session-logic).
474 const taskRowIndex = new Map<string, number>();494 const taskRowIndex = new Map<string, number>();
495 const toolLifecycleRowIndex = new Map<string, number>();
475 for (const entry of entries) {496 for (const entry of entries) {
476 const isTaskRow =497 const isTaskRow =
477 entry.taskId !== undefined &&498 entry.taskId !== undefined &&
478 (entry.activityKind === "task.progress" ||499 (entry.sourceActivityKind === "task.progress" ||
479 entry.activityKind === "task.completed" ||500 entry.sourceActivityKind === "task.completed" ||
480 entry.activityKind === "task.updated");501 entry.sourceActivityKind === "task.updated");
481 if (isTaskRow && entry.taskId !== undefined) {502 if (isTaskRow && entry.taskId !== undefined) {
482 const existingIndex = taskRowIndex.get(entry.taskId);503 const existingIndex = taskRowIndex.get(entry.taskId);
483 if (existingIndex !== undefined) {504 if (existingIndex !== undefined) {
...@@ -488,30 +509,78 @@ function collapseDerivedWorkLogEntries(...@@ -488,30 +509,78 @@ function collapseDerivedWorkLogEntries(
488 collapsed.push(entry);509 collapsed.push(entry);
489 continue;510 continue;
490 }511 }
512 const lifecycleKey = toolLifecycleCollapseMapKey(entry);
513 if (lifecycleKey !== undefined) {
514 const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey);
515 const matchingEntry = matchingIndex === undefined ? undefined : collapsed[matchingIndex];
516 if (
517 matchingIndex !== undefined &&
518 matchingEntry &&
519 shouldCollapseToolLifecycleEntries(matchingEntry, entry)
520 ) {
521 collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry);
522 continue;
523 }
524 toolLifecycleRowIndex.delete(lifecycleKey);
525 }
491 const previous = collapsed.at(-1);526 const previous = collapsed.at(-1);
492 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {527 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
493 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);528 const previousIndex = collapsed.length - 1;
529 const previousKey = toolLifecycleCollapseMapKey(previous);
530 if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
531 const merged = mergeDerivedWorkLogEntries(previous, entry);
532 collapsed[previousIndex] = merged;
533 const mergedKey = toolLifecycleCollapseMapKey(merged);
534 if (mergedKey !== undefined) toolLifecycleRowIndex.set(mergedKey, previousIndex);
494 continue;535 continue;
495 }536 }
496 collapsed.push(entry);537 collapsed.push(entry);
538 if (lifecycleKey !== undefined) {
539 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
540 }
497 }541 }
498 return collapsed;542 return collapsed;
499}543}
500544
545function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
546 if (
547 entry.sourceActivityKind !== "tool.updated" &&
548 entry.sourceActivityKind !== "tool.completed"
549 ) {
550 return undefined;
551 }
552 return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined;
553}
554
501function shouldCollapseToolLifecycleEntries(555function shouldCollapseToolLifecycleEntries(
502 previous: DerivedWorkLogEntry,556 previous: DerivedWorkLogEntry,
503 next: DerivedWorkLogEntry,557 next: DerivedWorkLogEntry,
504): boolean {558): boolean {
505 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {559 if (
560 previous.sourceActivityKind !== "tool.updated" &&
561 previous.sourceActivityKind !== "tool.completed"
562 ) {
506 return false;563 return false;
507 }564 }
508 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {565 if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") {
509 return false;566 return false;
510 }567 }
511 if (previous.activityKind === "tool.completed") {568 if (previous.turnId !== next.turnId) {
512 return false;569 return false;
513 }570 }
514 return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey;571 if (previous.sourceActivityKind === "tool.completed") {
572 return false;
573 }
574 if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
575 return true;
576 }
577 return (
578 previous.toolCallId !== undefined &&
579 next.toolCallId === undefined &&
580 previous.itemType === next.itemType &&
581 normalizeCompactToolLabel(previous.toolTitle ?? previous.label) ===
582 normalizeCompactToolLabel(next.toolTitle ?? next.label)
583 );
515}584}
516585
517function mergeDerivedWorkLogEntries(586function mergeDerivedWorkLogEntries(
...@@ -527,10 +596,13 @@ function mergeDerivedWorkLogEntries(...@@ -527,10 +596,13 @@ function mergeDerivedWorkLogEntries(
527 const requestKind = next.requestKind ?? previous.requestKind;596 const requestKind = next.requestKind ?? previous.requestKind;
528 const collapseKey = next.collapseKey ?? previous.collapseKey;597 const collapseKey = next.collapseKey ?? previous.collapseKey;
529 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;598 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
599 const toolCallId = next.toolCallId ?? previous.toolCallId;
530 const toolData = next.toolData ?? previous.toolData;600 const toolData = next.toolData ?? previous.toolData;
531 return {601 return {
532 ...previous,602 ...previous,
533 ...next,603 ...next,
604 id: previous.id,
605 createdAt: previous.createdAt,
534 ...(detail ? { detail } : {}),606 ...(detail ? { detail } : {}),
535 ...(command ? { command } : {}),607 ...(command ? { command } : {}),
536 ...(rawCommand ? { rawCommand } : {}),608 ...(rawCommand ? { rawCommand } : {}),
...@@ -540,6 +612,7 @@ function mergeDerivedWorkLogEntries(...@@ -540,6 +612,7 @@ function mergeDerivedWorkLogEntries(
540 ...(requestKind ? { requestKind } : {}),612 ...(requestKind ? { requestKind } : {}),
541 ...(collapseKey ? { collapseKey } : {}),613 ...(collapseKey ? { collapseKey } : {}),
542 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),614 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
615 ...(toolCallId ? { toolCallId } : {}),
543 ...(toolData !== undefined ? { toolData } : {}),616 ...(toolData !== undefined ? { toolData } : {}),
544 };617 };
545}618}
...@@ -556,9 +629,15 @@ function mergeChangedFiles(...@@ -556,9 +629,15 @@ function mergeChangedFiles(
556}629}
557630
558function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {631function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
559 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {632 if (
633 entry.sourceActivityKind !== "tool.updated" &&
634 entry.sourceActivityKind !== "tool.completed"
635 ) {
560 return undefined;636 return undefined;
561 }637 }
638 if (entry.toolCallId) {
639 return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
640 }
562 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);641 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
563 const detail = entry.detail?.trim() ?? "";642 const detail = entry.detail?.trim() ?? "";
564 const itemType = entry.itemType ?? "";643 const itemType = entry.itemType ?? "";
...@@ -568,10 +647,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un...@@ -568,10 +647,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
568 return [itemType, normalizedLabel, detail].join("\u001f");647 return [itemType, normalizedLabel, detail].join("\u001f");
569}648}
570649
571function normalizeCompactToolLabel(value: string): string {
572 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
573}
574
575function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {650function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
576 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {651 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
577 return true;652 return true;
...@@ -648,12 +723,12 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {...@@ -648,12 +723,12 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
648723
649function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {724function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
650 if (725 if (
651 entry.activityKind === "user-input.requested" ||726 entry.sourceActivityKind === "user-input.requested" ||
652 entry.activityKind === "user-input.resolved"727 entry.sourceActivityKind === "user-input.resolved"
653 ) {728 ) {
654 return "message";729 return "message";
655 }730 }
656 if (entry.activityKind === "runtime.warning") return "warning";731 if (entry.sourceActivityKind === "runtime.warning") return "warning";
657 if (entry.requestKind === "command") return "command";732 if (entry.requestKind === "command") return "command";
658 if (entry.requestKind === "file-read") return "eye";733 if (entry.requestKind === "file-read") return "eye";
659 if (entry.requestKind === "file-change") return "edit";734 if (entry.requestKind === "file-change") return "edit";
...@@ -1287,10 +1362,14 @@ export function deriveThreadFeedPresentation(...@@ -1287,10 +1362,14 @@ export function deriveThreadFeedPresentation(
1287 activeWorkStartedAt: string | null = null,1362 activeWorkStartedAt: string | null = null,
1288): ThreadFeedEntry[] {1363): ThreadFeedEntry[] {
1289 const sourceFeed = feed.filter(1364 const sourceFeed = feed.filter(
1290 (entry) =>1365 (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle",
1291 entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working",1366 );
1367 const activeTailGroup = sourceFeed.findLast(
1368 (entry) => entry.type !== "message" || !isEmptyMessage(entry),
1292 );1369 );
1293 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);1370 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
1371 const unsettledTurnId = deriveUnsettledTurnId(latestTurn);
1372 const isWorking = activeWorkStartedAt !== null;
1294 const collapsedEntryIds = new Set<string>();1373 const collapsedEntryIds = new Set<string>();
1295 for (const fold of foldsByAnchorId.values()) {1374 for (const fold of foldsByAnchorId.values()) {
1296 if (!expandedTurnIds.has(fold.turnId)) {1375 if (!expandedTurnIds.has(fold.turnId)) {
...@@ -1302,6 +1381,13 @@ export function deriveThreadFeedPresentation(...@@ -1302,6 +1381,13 @@ export function deriveThreadFeedPresentation(
13021381
1303 const result: ThreadFeedEntry[] = [];1382 const result: ThreadFeedEntry[] = [];
1304 for (const entry of sourceFeed) {1383 for (const entry of sourceFeed) {
1384 const isActiveTailGroup =
1385 isWorking &&
1386 unsettledTurnId !== null &&
1387 entry.type === "activity-group" &&
1388 activeTailGroup?.type === "activity-group" &&
1389 activeTailGroup.id === entry.id &&
1390 entry.turnId === unsettledTurnId;
1305 const fold = foldsByAnchorId.get(entry.id);1391 const fold = foldsByAnchorId.get(entry.id);
1306 if (fold) {1392 if (fold) {
1307 result.push({1393 result.push({
...@@ -1314,49 +1400,65 @@ export function deriveThreadFeedPresentation(...@@ -1314,49 +1400,65 @@ export function deriveThreadFeedPresentation(
1314 });1400 });
1315 }1401 }
1316 if (!collapsedEntryIds.has(entry.id)) {1402 if (!collapsedEntryIds.has(entry.id)) {
1317 appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);1403 appendPresentedFeedEntry(
1404 result,
1405 entry,
1406 expandedWorkGroupIds,
1407 unsettledTurnId,
1408 isWorking,
1409 isActiveTailGroup,
1410 );
1318 }1411 }
1319 }1412 }
1320 if (activeWorkStartedAt !== null) {
1321 result.push({
1322 type: "working",
1323 id: "working-indicator-row",
1324 createdAt: activeWorkStartedAt,
1325 });
1326 }
1327 return result;1413 return result;
1328}1414}
13291415
1330function appendPresentedFeedEntry(1416function appendPresentedFeedEntry(
1331 result: ThreadFeedEntry[],1417 result: ThreadFeedEntry[],
1332 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>,1418 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>,
1333 expandedWorkGroupIds: ReadonlySet<string>,1419 expandedWorkGroupIds: ReadonlySet<string>,
1420 unsettledTurnId: TurnId | null,
1421 isWorking: boolean,
1422 activeTail: boolean,
1334): void {1423): void {
1335 if (entry.type !== "activity-group") {1424 if (entry.type !== "activity-group") {
1336 result.push(entry);1425 result.push(entry);
1337 return;1426 return;
1338 }1427 }
13391428
1340 const activities = entry.activities.filter(1429 const activities = omitSupersededLifecycleMarkers(
1341 (activity) => !(activity.toolLike && activity.status === "neutral"),1430 entry.activities.filter(
1431 (activity) =>
1432 !(activity.toolLike && activity.status === "neutral") ||
1433 (isWorking &&
1434 activity.lifecycleStatus === "inProgress" &&
1435 activity.turnId === unsettledTurnId),
1436 ),
1437 (activity) => activity.workEntry,
1342 );1438 );
1343 if (activities.length === 0) {1439 if (activities.length === 0) {
1344 return;1440 return;
1345 }1441 }
1346 if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {1442 let groupableRun: ThreadFeedActivity[] = [];
1347 result.push({1443 const flushGroupableRun = (isTrailingRun: boolean) => {
1348 ...entry,1444 if (groupableRun.length === 0) return;
1349 activities,1445 appendToolGroupRows(
1350 });1446 result,
1351 return;1447 entry,
1352 }1448 groupableRun,
13531449 expandedWorkGroupIds,
1354 const groupId = entry.id;1450 unsettledTurnId,
1355 const expanded = expandedWorkGroupIds.has(groupId);1451 isWorking,
1356 const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES;1452 activeTail && isTrailingRun,
1357 const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);1453 );
13581454 groupableRun = [];
1359 for (const activity of visibleActivities) {1455 };
1456 for (const activity of activities) {
1457 if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) {
1458 groupableRun.push(activity);
1459 continue;
1460 }
1461 flushGroupableRun(false);
1360 result.push({1462 result.push({
1361 type: "activity-group",1463 type: "activity-group",
1362 id: activity.id,1464 id: activity.id,
...@@ -1365,16 +1467,87 @@ function appendPresentedFeedEntry(...@@ -1365,16 +1467,87 @@ function appendPresentedFeedEntry(
1365 activities: [activity],1467 activities: [activity],
1366 });1468 });
1367 }1469 }
1470 flushGroupableRun(true);
1471}
1472
1473function appendToolGroupRows(
1474 result: ThreadFeedEntry[],
1475 sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
1476 activities: ReadonlyArray<ThreadFeedActivity>,
1477 expandedWorkGroupIds: ReadonlySet<string>,
1478 unsettledTurnId: TurnId | null,
1479 isWorking: boolean,
1480 activeTail: boolean,
1481): void {
1482 const firstEntry = activities[0]!.workEntry;
1483 const identity = firstEntry.toolCallId
1484 ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}`
1485 : activities[0]!.id;
1486 const groupId = `work-group:${identity}`;
1487 const expanded = expandedWorkGroupIds.has(groupId);
1488 const latestInProgressActivity = activities.findLast(
1489 (activity) =>
1490 isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId,
1491 );
1492 const live = activeTail || latestInProgressActivity !== undefined;
1493 const latestActivity = activeTail
1494 ? activities.at(-1)!
1495 : (latestInProgressActivity ?? activities.at(-1)!);
1496 const summary = live
1497 ? liveToolActivitySummary(latestActivity)
1498 : activities.length === 1 && !activities[0]!.toolLike
1499 ? activities[0]!.workEntry.label
1500 : summarizeToolGroup(activities.map((activity) => activity.workEntry));
1368 result.push({1501 result.push({
1369 type: "work-toggle",1502 type: "work-toggle",
1370 id: `work-toggle:${groupId}`,1503 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,
1371 createdAt: entry.createdAt,1504 createdAt: sourceGroup.createdAt,
1372 turnId: entry.turnId,1505 turnId: sourceGroup.turnId,
1373 groupId,1506 groupId,
1374 hiddenCount,1507 hiddenCount: activities.length,
1375 expanded,1508 expanded,
1376 onlyToolActivities: activities.every((activity) => activity.toolLike),1509 summary,
1510 summaryKind: toolGroupSummaryKind(
1511 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
1512 ),
1513 hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure",
1514 live,
1515 shimmer:
1516 isWorking &&
1517 latestActivity.lifecycleStatus === "inProgress" &&
1518 latestActivity.turnId === unsettledTurnId,
1377 });1519 });
1520 if (!expanded) {
1521 return;
1522 }
1523 for (const activity of activities) {
1524 result.push({
1525 type: "activity-group",
1526 id: activity.id,
1527 createdAt: activity.createdAt,
1528 turnId: activity.turnId,
1529 activities: [
1530 {
1531 ...activity,
1532 groupedToolDetail: true,
1533 live:
1534 isWorking &&
1535 activity.id === latestActivity.id &&
1536 activity.lifecycleStatus === "inProgress" &&
1537 activity.turnId === unsettledTurnId,
1538 },
1539 ],
1540 });
1541 }
1542}
1543
1544function liveToolActivitySummary(activity: ThreadFeedActivity): string {
1545 const command = activity.workEntry.command?.trim();
1546 if (command) {
1547 const program = commandProgramName(command);
1548 return program ? `Running ${program}` : "Running command";
1549 }
1550 return activity.detail ?? activity.summary;
1378}1551}
13791552
1380/**1553/**
...@@ -1609,6 +1782,8 @@ export function buildThreadFeed(...@@ -1609,6 +1782,8 @@ export function buildThreadFeed(
1609 icon: workEntryIcon(entry),1782 icon: workEntryIcon(entry),
1610 toolLike: workLogEntryIsToolLike(entry),1783 toolLike: workLogEntryIsToolLike(entry),
1611 status: workEntryStatus(entry),1784 status: workEntryStatus(entry),
1785 ...(entry.toolLifecycleStatus ? { lifecycleStatus: entry.toolLifecycleStatus } : {}),
1786 workEntry: entry,
1612 },1787 },
1613 };1788 };
1614 }),1789 }),
apps/web/src/components/chat/MessagesTimeline.tsx+1-170
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 type TurnId,7 type TurnId,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";9import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
10import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
10import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";11import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
11import {12import {
12 emptyAgentPanelModel,13 emptyAgentPanelModel,
...@@ -2289,176 +2290,6 @@ function workEntryRawCommand(...@@ -2289,176 +2290,6 @@ function workEntryRawCommand(
2289 return rawCommand === workEntry.command.trim() ? null : rawCommand;2290 return rawCommand === workEntry.command.trim() ? null : rawCommand;
2290}2291}
22912292
2292type CommandWrapper = "env" | "sudo";
2293
2294const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
2295 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
2296 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
2297};
2298
2299const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
2300 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]),
2301 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
2302};
2303
2304function tokenizeShellCommand(command: string): string[] | null {
2305 const input = command.trim();
2306 const tokens: string[] = [];
2307 let current = "";
2308 let quote: '"' | "'" | null = null;
2309 let escaping = false;
2310 let substitutionDepth = 0;
2311 let tokenStarted = false;
2312
2313 for (let index = 0; index < input.length; index += 1) {
2314 const character = input[index]!;
2315 if (escaping) {
2316 current += character;
2317 escaping = false;
2318 tokenStarted = true;
2319 continue;
2320 }
2321 if (character === "\\" && quote !== "'") {
2322 const nextCharacter = input[index + 1];
2323 const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current);
2324 if (
2325 (quote === '"' || isWindowsDrivePath) &&
2326 nextCharacter !== undefined &&
2327 nextCharacter !== '"' &&
2328 nextCharacter !== "\\" &&
2329 nextCharacter !== "$" &&
2330 nextCharacter !== "`" &&
2331 nextCharacter !== "\n"
2332 ) {
2333 current += character;
2334 tokenStarted = true;
2335 continue;
2336 }
2337 escaping = true;
2338 tokenStarted = true;
2339 continue;
2340 }
2341 if (quote !== null) {
2342 if (character === quote) {
2343 quote = null;
2344 } else {
2345 current += character;
2346 }
2347 tokenStarted = true;
2348 continue;
2349 }
2350 if (character === "$" && input[index + 1] === "(") {
2351 current += "$(";
2352 substitutionDepth += 1;
2353 tokenStarted = true;
2354 index += 1;
2355 continue;
2356 }
2357 if (character === ")" && substitutionDepth > 0) {
2358 current += character;
2359 substitutionDepth -= 1;
2360 tokenStarted = true;
2361 continue;
2362 }
2363 if (character === '"' || character === "'") {
2364 quote = character;
2365 tokenStarted = true;
2366 continue;
2367 }
2368 if (/\s/u.test(character)) {
2369 if (substitutionDepth > 0) {
2370 current += character;
2371 tokenStarted = true;
2372 continue;
2373 }
2374 if (tokenStarted) {
2375 tokens.push(current);
2376 current = "";
2377 tokenStarted = false;
2378 }
2379 continue;
2380 }
2381 current += character;
2382 tokenStarted = true;
2383 }
2384
2385 if (quote !== null || escaping || substitutionDepth > 0) return null;
2386 if (tokenStarted) tokens.push(current);
2387 return tokens;
2388}
2389
2390function commandProgramName(command: string, depth = 0): string | null {
2391 if (depth >= 8) return null;
2392 const tokens = tokenizeShellCommand(command);
2393 if (tokens === null) return null;
2394 let index = 0;
2395 let wrapper: CommandWrapper | null = null;
2396
2397 while (index < tokens.length) {
2398 const token = tokens[index];
2399 if (!token) return null;
2400 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
2401 index += 1;
2402 continue;
2403 }
2404 const tokenProgram = token.split(/[\\/]/).at(-1);
2405 if (tokenProgram === "env" || tokenProgram === "sudo") {
2406 wrapper = tokenProgram;
2407 index += 1;
2408 continue;
2409 }
2410 if (wrapper !== null && token === "--") {
2411 wrapper = null;
2412 index += 1;
2413 continue;
2414 }
2415 if (wrapper !== null && token.startsWith("-")) {
2416 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
2417 const splitCommand = tokens[index + 1];
2418 return splitCommand ? commandProgramName(splitCommand, depth + 1) : null;
2419 }
2420 if (wrapper === "env" && token.startsWith("--split-string=")) {
2421 return commandProgramName(token.slice("--split-string=".length), depth + 1);
2422 }
2423 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
2424 if (tokens[index + 1] === undefined) return null;
2425 index += 2;
2426 continue;
2427 }
2428 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) {
2429 index += 1;
2430 continue;
2431 }
2432 const equalsIndex = token.indexOf("=");
2433 if (token.startsWith("--") && equalsIndex > 2) {
2434 if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) {
2435 return null;
2436 }
2437 index += 1;
2438 continue;
2439 }
2440 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
2441 let consumesNextToken = false;
2442 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
2443 const shortOption = `-${option}`;
2444 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
2445 consumesNextToken = optionIndex === token.length - 2;
2446 break;
2447 }
2448 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
2449 }
2450 if (consumesNextToken && tokens[index + 1] === undefined) return null;
2451 index += consumesNextToken ? 2 : 1;
2452 continue;
2453 }
2454 return null;
2455 }
2456 return token.split(/[\\/]/).at(-1) || null;
2457 }
2458
2459 return null;
2460}
2461
2462function liveWorkEntryLabel(2293function liveWorkEntryLabel(
2463 workEntry: TimelineWorkEntry,2294 workEntry: TimelineWorkEntry,
2464 workspaceRoot: string | undefined,2295 workspaceRoot: string | undefined,
packages/client-runtime/package.json+8
...@@ -158,6 +158,14 @@...@@ -158,6 +158,14 @@
158 "./state/vcs": {158 "./state/vcs": {
159 "types": "./src/state/vcs.ts",159 "types": "./src/state/vcs.ts",
160 "default": "./src/state/vcs.ts"160 "default": "./src/state/vcs.ts"
161 },
162 "./work-log/presentation": {
163 "types": "./src/work-log/presentation.ts",
164 "default": "./src/work-log/presentation.ts"
165 },
166 "./work-log/command-label": {
167 "types": "./src/work-log/commandLabel.ts",
168 "default": "./src/work-log/commandLabel.ts"
161 }169 }
162 },170 },
163 "scripts": {171 "scripts": {
packages/client-runtime/src/work-log/commandLabel.ts created+169
...@@ -0,0 +1,169 @@
1type CommandWrapper = "env" | "sudo";
2
3const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
4 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
5 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
6};
7
8const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
9 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]),
10 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
11};
12
13function tokenizeShellCommand(command: string): string[] | null {
14 const input = command.trim();
15 const tokens: string[] = [];
16 let current = "";
17 let quote: '"' | "'" | null = null;
18 let escaping = false;
19 let substitutionDepth = 0;
20 let tokenStarted = false;
21
22 for (let index = 0; index < input.length; index += 1) {
23 const character = input[index]!;
24 if (escaping) {
25 current += character;
26 escaping = false;
27 tokenStarted = true;
28 continue;
29 }
30 if (character === "\\" && quote !== "'") {
31 const nextCharacter = input[index + 1];
32 const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current);
33 if (
34 (quote === '"' || isWindowsDrivePath) &&
35 nextCharacter !== undefined &&
36 nextCharacter !== '"' &&
37 nextCharacter !== "\\" &&
38 nextCharacter !== "$" &&
39 nextCharacter !== "`" &&
40 nextCharacter !== "\n"
41 ) {
42 current += character;
43 tokenStarted = true;
44 continue;
45 }
46 escaping = true;
47 tokenStarted = true;
48 continue;
49 }
50 if (quote !== null) {
51 if (character === quote) {
52 quote = null;
53 } else {
54 current += character;
55 }
56 tokenStarted = true;
57 continue;
58 }
59 if (character === "$" && input[index + 1] === "(") {
60 current += "$(";
61 substitutionDepth += 1;
62 tokenStarted = true;
63 index += 1;
64 continue;
65 }
66 if (character === ")" && substitutionDepth > 0) {
67 current += character;
68 substitutionDepth -= 1;
69 tokenStarted = true;
70 continue;
71 }
72 if (character === '"' || character === "'") {
73 quote = character;
74 tokenStarted = true;
75 continue;
76 }
77 if (/\s/u.test(character)) {
78 if (substitutionDepth > 0) {
79 current += character;
80 tokenStarted = true;
81 continue;
82 }
83 if (tokenStarted) {
84 tokens.push(current);
85 current = "";
86 tokenStarted = false;
87 }
88 continue;
89 }
90 current += character;
91 tokenStarted = true;
92 }
93
94 if (quote !== null || escaping || substitutionDepth > 0) return null;
95 if (tokenStarted) tokens.push(current);
96 return tokens;
97}
98
99export function commandProgramName(command: string, depth = 0): string | null {
100 if (depth >= 8) return null;
101 const tokens = tokenizeShellCommand(command);
102 if (tokens === null) return null;
103 let index = 0;
104 let wrapper: CommandWrapper | null = null;
105
106 while (index < tokens.length) {
107 const token = tokens[index];
108 if (!token) return null;
109 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
110 index += 1;
111 continue;
112 }
113 const tokenProgram = token.split(/[\\/]/).at(-1);
114 if (tokenProgram === "env" || tokenProgram === "sudo") {
115 wrapper = tokenProgram;
116 index += 1;
117 continue;
118 }
119 if (wrapper !== null && token === "--") {
120 wrapper = null;
121 index += 1;
122 continue;
123 }
124 if (wrapper !== null && token.startsWith("-")) {
125 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
126 const splitCommand = tokens[index + 1];
127 return splitCommand ? commandProgramName(splitCommand, depth + 1) : null;
128 }
129 if (wrapper === "env" && token.startsWith("--split-string=")) {
130 return commandProgramName(token.slice("--split-string=".length), depth + 1);
131 }
132 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
133 if (tokens[index + 1] === undefined) return null;
134 index += 2;
135 continue;
136 }
137 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) {
138 index += 1;
139 continue;
140 }
141 const equalsIndex = token.indexOf("=");
142 if (token.startsWith("--") && equalsIndex > 2) {
143 if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) {
144 return null;
145 }
146 index += 1;
147 continue;
148 }
149 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
150 let consumesNextToken = false;
151 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
152 const shortOption = `-${option}`;
153 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
154 consumesNextToken = optionIndex === token.length - 2;
155 break;
156 }
157 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
158 }
159 if (consumesNextToken && tokens[index + 1] === undefined) return null;
160 index += consumesNextToken ? 2 : 1;
161 continue;
162 }
163 return null;
164 }
165 return token.split(/[\\/]/).at(-1) || null;
166 }
167
168 return null;
169}
packages/client-runtime/src/work-log/presentation.ts created+188
...@@ -0,0 +1,188 @@
1import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts";
2
3export interface WorkLogPresentationEntry {
4 readonly label: string;
5 readonly toolTitle?: string;
6 readonly tone: "thinking" | "tool" | "info" | "error";
7 readonly command?: string;
8 readonly changedFiles?: ReadonlyArray<string>;
9 readonly itemType?: ToolLifecycleItemType;
10 readonly requestKind?: string;
11 readonly turnId?: string | null;
12 readonly toolCallId?: string;
13 readonly toolLifecycleStatus?: string;
14 readonly sourceActivityKind?: string;
15 readonly taskId?: string;
16}
17
18export type ToolGroupAction =
19 | "read"
20 | "edit"
21 | "command"
22 | "code-search"
23 | "search"
24 | "other"
25 | "update";
26
27export type ToolGroupSummaryKind =
28 | ToolGroupAction
29 | "dynamic-tool"
30 | "agent-tool"
31 | "tone-tool"
32 | "mixed";
33
34export function normalizeCompactToolLabel(value: string): string {
35 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
36}
37
38function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
39 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
40 if (entry.command !== undefined && entry.command.trim().length > 0) return true;
41 if (entry.requestKind !== undefined) return true;
42 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
43}
44
45export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
46 return (
47 entry.itemType === "web_search" &&
48 /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
49 );
50}
51
52export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
53 if (
54 entry.requestKind === "file-read" ||
55 entry.itemType === "image_view" ||
56 (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
57 ) {
58 return "read";
59 }
60 if (
61 entry.requestKind === "file-change" ||
62 entry.itemType === "file_change" ||
63 (entry.changedFiles?.length ?? 0) > 0
64 ) {
65 return "edit";
66 }
67 if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
68 return "command";
69 }
70 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
71 if (entry.itemType === "web_search") return "search";
72 return workLogEntryIsToolLike(entry) ? "other" : "update";
73}
74
75function toolGroupActionCount(
76 action: ToolGroupAction,
77 entries: ReadonlyArray<WorkLogPresentationEntry>,
78): number {
79 if (action !== "edit") return entries.length;
80
81 const changedFiles = new Set<string>();
82 let editsWithoutFileDetails = 0;
83 for (const entry of entries) {
84 if (!entry.changedFiles || entry.changedFiles.length === 0) {
85 editsWithoutFileDetails += 1;
86 continue;
87 }
88 for (const file of entry.changedFiles) changedFiles.add(file);
89 }
90 return changedFiles.size + editsWithoutFileDetails;
91}
92
93function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
94 switch (action) {
95 case "read":
96 return `Read ${count} ${count === 1 ? "file" : "files"}`;
97 case "edit":
98 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
99 case "command":
100 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
101 case "search":
102 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
103 case "code-search":
104 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
105 case "other":
106 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
107 case "update":
108 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
109 }
110}
111
112export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): string {
113 const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry);
114 const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>();
115 for (const entry of summaryEntries) {
116 const action = toolGroupAction(entry);
117 const group = groupedEntries.get(action);
118 if (group) group.push(entry);
119 else groupedEntries.set(action, [entry]);
120 }
121 const labels = [...groupedEntries].map(([action, actionEntries]) =>
122 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
123 );
124 const sentenceLabels = labels.map((label, index) =>
125 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
126 );
127 if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
128 if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
129 return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
130}
131
132export function omitSupersededLifecycleMarkers<T>(
133 entries: readonly T[],
134 workEntryFor: (entry: T) => WorkLogPresentationEntry,
135): T[] {
136 const laterTerminalIdentities = new Set<string>();
137 const reversedEntries: T[] = [];
138
139 for (let index = entries.length - 1; index >= 0; index -= 1) {
140 const entry = entries[index]!;
141 const workEntry = workEntryFor(entry);
142 const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label);
143 const identity = [
144 workEntry.turnId ?? "no-turn",
145 workEntry.itemType ?? "",
146 normalizedLabel,
147 ].join("\u001f");
148 const activityKind = workEntry.sourceActivityKind;
149 const isStatuslessIdlessMarker =
150 workEntry.toolCallId === undefined &&
151 workEntry.toolLifecycleStatus === undefined &&
152 (activityKind === "tool.started" || activityKind === "tool.updated");
153 if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue;
154
155 reversedEntries.push(entry);
156 if (
157 activityKind === "tool.completed" ||
158 (workEntry.toolLifecycleStatus !== undefined &&
159 workEntry.toolLifecycleStatus !== "inProgress")
160 ) {
161 laterTerminalIdentities.add(identity);
162 }
163 }
164
165 return reversedEntries.toReversed();
166}
167
168export function toolGroupSummaryKind(
169 entries: ReadonlyArray<WorkLogPresentationEntry>,
170): ToolGroupSummaryKind {
171 const actions = new Set(entries.map(toolGroupAction));
172 if (actions.size !== 1) return "mixed";
173
174 const action = actions.values().next().value!;
175 if (action !== "other") return action;
176
177 const fallbackKinds = new Set(
178 entries.map((entry): ToolGroupSummaryKind => {
179 if (entry.itemType === "mcp_tool_call") return "other";
180 if (entry.itemType === "dynamic_tool_call") return "dynamic-tool";
181 if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool";
182 if (entry.tone === "thinking") return "agent-tool";
183 if (entry.tone === "tool") return "tone-tool";
184 return "other";
185 }),
186 );
187 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
188}
patches/@legendapp__list@3.3.5.patch+696-82
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 1006442index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644
3--- a/keyboard.d.ts3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts4+++ b/keyboard.d.ts
5@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {5@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
...@@ -7,7 +7,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776...@@ -7,7 +7,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776
7 current: Pick<View, "measure"> | null;7 current: Pick<View, "measure"> | null;
8 };8 };
9-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {9-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {
10+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): {10+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number, animationDuration?: number): {
11 contentInsetEndAdjustment: SharedValue<number>;11 contentInsetEndAdjustment: SharedValue<number>;
12 onComposerLayout: (event: LayoutChangeEvent) => void;12 onComposerLayout: (event: LayoutChangeEvent) => void;
13 };13 };
...@@ -23,15 +23,15 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776...@@ -23,15 +23,15 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776
23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
24 24
25diff --git a/keyboard.js b/keyboard.js25diff --git a/keyboard.js b/keyboard.js
26index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 10064426index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644
27--- a/keyboard.js27--- a/keyboard.js
28+++ b/keyboard.js28+++ b/keyboard.js
29@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.29@@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
30 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."30 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
31 );31 );
32 }32 }
33-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {33-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
34+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {34+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) {
35 const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);35 const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
36 const lastHeightRef = React.useRef(void 0);36 const lastHeightRef = React.useRef(void 0);
37 const reportHeight = React.useCallback(37 const reportHeight = React.useCallback(
...@@ -40,17 +40,21 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc...@@ -40,17 +40,21 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
40+ const height = Math.max(0, rawHeight + heightAdjustment);40+ const height = Math.max(0, rawHeight + heightAdjustment);
41 var _a;41 var _a;
42 if (Number.isFinite(height) && height !== lastHeightRef.current) {42 if (Number.isFinite(height) && height !== lastHeightRef.current) {
43+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
43 lastHeightRef.current = height;44 lastHeightRef.current = height;
44 contentInsetEndAdjustment.value = height;45- contentInsetEndAdjustment.value = height;
45- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });46- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
47+ contentInsetEndAdjustment.value = shouldAnimate ? reactNativeReanimated.withTiming(height, {
48+ duration: animationDuration
49+ }) : height;
46 }50 }
47 },51 },
48- [contentInsetEndAdjustment, listRef]52- [contentInsetEndAdjustment, listRef]
49+ [contentInsetEndAdjustment, heightAdjustment, listRef]53+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
50 );54 );
51 React.useLayoutEffect(() => {55 React.useLayoutEffect(() => {
52 var _a;56 var _a;
53@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {57@@ -84,9 +87,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
54 }58 }
55 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {59 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
56 const {60 const {
...@@ -62,7 +66,15 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc...@@ -62,7 +66,15 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
62 freeze,66 freeze,
63 keyboardLiftBehavior,67 keyboardLiftBehavior,
64 keyboardOffset,68 keyboardOffset,
65@@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(69@@ -94,6 +99,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
70 } = props;
71 const refLegendList = React.useRef(null);
72 const combinedRef = useCombinedRef(forwardedRef, refLegendList);
73+ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment;
74 const blankSpace = reactNativeReanimated.useSharedValue(0);
75 React.useEffect(() => {
76 if (!anchoredEndSpace) {
77@@ -108,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
66 ...anchoredEndSpace,78 ...anchoredEndSpace,
67 onSizeChanged: (size) => {79 onSizeChanged: (size) => {
68 var _a;80 var _a;
...@@ -80,23 +92,25 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc...@@ -80,23 +92,25 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
80 const onContentInsetChange = React.useCallback((insets) => {92 const onContentInsetChange = React.useCallback((insets) => {
81 var _a;93 var _a;
82 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);94 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
83@@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(95@@ -123,6 +133,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
84 reactNativeKeyboardController.KeyboardChatScrollView,96 reactNativeKeyboardController.KeyboardChatScrollView,
85 {97 {
86 ...scrollProps,98 ...scrollProps,
87+ adjustedInsetCompensation,99+ adjustedInsetCompensation,
100+ adjustedStartInsetCompensation,
88 applyWorkaroundForContentInsetHitTestBug,101 applyWorkaroundForContentInsetHitTestBug,
89 blankSpace,102 blankSpace,
90 extraContentPadding: contentInsetEndAdjustment,103 extraContentPadding: contentInsetEndAdjustment,
91@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(104@@ -134,6 +146,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
92 );105 );
93 },106 },
94 [107 [
95+ adjustedInsetCompensation,108+ adjustedInsetCompensation,
109+ adjustedStartInsetCompensation,
96 applyWorkaroundForContentInsetHitTestBug,110 applyWorkaroundForContentInsetHitTestBug,
97 blankSpace,111 blankSpace,
98 contentInsetEndAdjustment,112 contentInsetEndAdjustment,
99@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(113@@ -149,6 +163,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
100 {114 {
101 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,115 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
102 anchoredEndSpaceOwnerInternal: "scroll",116 anchoredEndSpaceOwnerInternal: "scroll",
...@@ -105,15 +119,24 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc...@@ -105,15 +119,24 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
105 renderScrollComponent: memoList,119 renderScrollComponent: memoList,
106 ...rest120 ...rest
107diff --git a/keyboard.mjs b/keyboard.mjs121diff --git a/keyboard.mjs b/keyboard.mjs
108index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644122index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644
109--- a/keyboard.mjs123--- a/keyboard.mjs
110+++ b/keyboard.mjs124+++ b/keyboard.mjs
111@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {125@@ -1,7 +1,7 @@
126 import * as React from 'react';
127 import { useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react';
128 import { KeyboardChatScrollView, KeyboardController } from 'react-native-keyboard-controller';
129-import { useSharedValue } from 'react-native-reanimated';
130+import { useSharedValue, withTiming } from 'react-native-reanimated';
131 import { internal } from '@legendapp/list/react-native';
132 import { AnimatedLegendList } from '@legendapp/list/reanimated';
133
134@@ -12,19 +12,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
112 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."135 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
113 );136 );
114 }137 }
115-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {138-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
116+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {139+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) {
117 const contentInsetEndAdjustment = useSharedValue(initialHeight);140 const contentInsetEndAdjustment = useSharedValue(initialHeight);
118 const lastHeightRef = useRef(void 0);141 const lastHeightRef = useRef(void 0);
119 const reportHeight = useCallback(142 const reportHeight = useCallback(
...@@ -122,17 +145,21 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af...@@ -122,17 +145,21 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af
122+ const height = Math.max(0, rawHeight + heightAdjustment);145+ const height = Math.max(0, rawHeight + heightAdjustment);
123 var _a;146 var _a;
124 if (Number.isFinite(height) && height !== lastHeightRef.current) {147 if (Number.isFinite(height) && height !== lastHeightRef.current) {
148+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
125 lastHeightRef.current = height;149 lastHeightRef.current = height;
126 contentInsetEndAdjustment.value = height;150- contentInsetEndAdjustment.value = height;
127- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });151- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
152+ contentInsetEndAdjustment.value = shouldAnimate ? withTiming(height, {
153+ duration: animationDuration
154+ }) : height;
128 }155 }
129 },156 },
130- [contentInsetEndAdjustment, listRef]157- [contentInsetEndAdjustment, listRef]
131+ [contentInsetEndAdjustment, heightAdjustment, listRef]158+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
132 );159 );
133 useLayoutEffect(() => {160 useLayoutEffect(() => {
134 var _a;161 var _a;
135@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {162@@ -63,9 +66,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
136 }163 }
137 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {164 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
138 const {165 const {
...@@ -144,7 +171,15 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af...@@ -144,7 +171,15 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af
144 freeze,171 freeze,
145 keyboardLiftBehavior,172 keyboardLiftBehavior,
146 keyboardOffset,173 keyboardOffset,
147@@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(174@@ -73,6 +78,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
175 } = props;
176 const refLegendList = useRef(null);
177 const combinedRef = useCombinedRef(forwardedRef, refLegendList);
178+ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment;
179 const blankSpace = useSharedValue(0);
180 useEffect(() => {
181 if (!anchoredEndSpace) {
182@@ -87,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
148 ...anchoredEndSpace,183 ...anchoredEndSpace,
149 onSizeChanged: (size) => {184 onSizeChanged: (size) => {
150 var _a;185 var _a;
...@@ -162,23 +197,25 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af...@@ -162,23 +197,25 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af
162 const onContentInsetChange = useCallback((insets) => {197 const onContentInsetChange = useCallback((insets) => {
163 var _a;198 var _a;
164 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);199 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
165@@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(200@@ -102,6 +112,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
166 KeyboardChatScrollView,201 KeyboardChatScrollView,
167 {202 {
168 ...scrollProps,203 ...scrollProps,
169+ adjustedInsetCompensation,204+ adjustedInsetCompensation,
205+ adjustedStartInsetCompensation,
170 applyWorkaroundForContentInsetHitTestBug,206 applyWorkaroundForContentInsetHitTestBug,
171 blankSpace,207 blankSpace,
172 extraContentPadding: contentInsetEndAdjustment,208 extraContentPadding: contentInsetEndAdjustment,
173@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(209@@ -113,6 +125,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
174 );210 );
175 },211 },
176 [212 [
177+ adjustedInsetCompensation,213+ adjustedInsetCompensation,
214+ adjustedStartInsetCompensation,
178 applyWorkaroundForContentInsetHitTestBug,215 applyWorkaroundForContentInsetHitTestBug,
179 blankSpace,216 blankSpace,
180 contentInsetEndAdjustment,217 contentInsetEndAdjustment,
181@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(218@@ -128,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
182 {219 {
183 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,220 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
184 anchoredEndSpaceOwnerInternal: "scroll",221 anchoredEndSpaceOwnerInternal: "scroll",
...@@ -204,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a...@@ -204,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a
204 * Number of columns to render items in.241 * Number of columns to render items in.
205 * @default 1242 * @default 1
206diff --git a/react-native.js b/react-native.js243diff --git a/react-native.js b/react-native.js
207index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4a5493efb 100644244index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644
208--- a/react-native.js245--- a/react-native.js
209+++ b/react-native.js246+++ b/react-native.js
210@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, {247@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, {
...@@ -216,7 +253,27 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -216,7 +253,27 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
216 if (isReadyToRender && !peek$(ctx, "readyToRender")) {253 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
217 set$(ctx, "readyToRender", true);254 set$(ctx, "readyToRender", true);
218 setAdaptiveRender(ctx, "normal", "ready");255 setAdaptiveRender(ctx, "normal", "ready");
219@@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {256@@ -1090,7 +1090,7 @@ function getRawContentLength(ctx) {
257 function getAlignItemsAtEndPadding(ctx) {
258 const { state } = ctx;
259 const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0;
260- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
261+ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
262 }
263 function updateContentMetricsState(ctx) {
264 const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0;
265@@ -1115,6 +1115,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) {
266 totalSize += add;
267 }
268 if (prevTotalSize !== totalSize) {
269+ const now = Date.now();
270+ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0;
271+ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1;
272+ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300);
273 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
274 state.pendingTotalSize = totalSize;
275 } else {
276@@ -1304,18 +1308,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
220 }277 }
221 278
222 // src/core/clampScrollOffset.ts279 // src/core/clampScrollOffset.ts
...@@ -242,7 +299,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -242,7 +299,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
242 return clampedOffset;299 return clampedOffset;
243 }300 }
244 301
245@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) {302@@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) {
246 finishScrollTo(ctx);303 finishScrollTo(ctx);
247 }304 }
248 }305 }
...@@ -255,7 +312,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -255,7 +312,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
255 x: ctx.state.props.horizontal ? offset : 0,312 x: ctx.state.props.horizontal ? offset : 0,
256 y: ctx.state.props.horizontal ? 0 : offset313 y: ctx.state.props.horizontal ? 0 : offset
257 });314 });
258@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) {315@@ -1503,7 +1512,10 @@ function checkFinishedScrollFallback(ctx) {
259 );316 );
260 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);317 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
261 } else if (shouldRetryUnalignedEndScroll) {318 } else if (shouldRetryUnalignedEndScroll) {
...@@ -267,7 +324,17 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -267,7 +324,17 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
267 scheduleFallbackCheck(100);324 scheduleFallbackCheck(100);
268 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {325 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
269 finishScrollTo(ctx);326 finishScrollTo(ctx);
270@@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) {327@@ -1560,15 +1572,28 @@ function doMaintainScrollAtEnd(ctx) {
328 } = state;
329 const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
330 const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
331+ if (state.contentSizeAnimationActiveEpoch !== void 0) {
332+ state.pendingMaintainScrollAtEnd = false;
333+ return false;
334+ }
335 if (pendingNativeMVCPAdjust) {
336 state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
337 return false;
271 }338 }
272 if (shouldMaintainScrollAtEnd) {339 if (shouldMaintainScrollAtEnd) {
273 state.pendingMaintainScrollAtEnd = false;340 state.pendingMaintainScrollAtEnd = false;
...@@ -287,7 +354,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -287,7 +354,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
287 }354 }
288 if (!state.maintainingScrollAtEnd) {355 if (!state.maintainingScrollAtEnd) {
289 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";356 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
290@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) {357@@ -1591,9 +1616,18 @@ function doMaintainScrollAtEnd(ctx) {
291 y: 0358 y: 0
292 });359 });
293 } else {360 } else {
...@@ -309,7 +376,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -309,7 +376,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
309 }376 }
310 setTimeout(377 setTimeout(
311 () => {378 () => {
312@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) {379@@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) {
313 function requestAdjust(ctx, positionDiff, dataChanged) {380 function requestAdjust(ctx, positionDiff, dataChanged) {
314 const state = ctx.state;381 const state = ctx.state;
315 if (Math.abs(positionDiff) > 0.1) {382 if (Math.abs(positionDiff) > 0.1) {
...@@ -320,7 +387,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -320,7 +387,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
320 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;387 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
321 const doit = () => {388 const doit = () => {
322 if (needsScrollWorkaround) {389 if (needsScrollWorkaround) {
323@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {390@@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
324 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {391 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
325 return 0;392 return 0;
326 }393 }
...@@ -331,7 +398,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -331,7 +398,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
331 const clampDelta = maxScroll - state.scroll;398 const clampDelta = maxScroll - state.scroll;
332 if (unresolvedAmount < 0) {399 if (unresolvedAmount < 0) {
333 return Math.max(unresolvedAmount, Math.min(0, clampDelta));400 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
334@@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {401@@ -1790,7 +1830,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
335 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);402 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
336 return true;403 return true;
337 }404 }
...@@ -340,7 +407,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -340,7 +407,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
340 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);407 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
341 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;408 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
342 if (isAtExpectedNativeClamp) {409 if (isAtExpectedNativeClamp) {
343@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) {410@@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) {
344 if (diff > 0) {411 if (diff > 0) {
345 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);412 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
346 } else {413 } else {
...@@ -349,7 +416,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -349,7 +416,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
349 state.scroll = maxScroll;416 state.scroll = maxScroll;
350 state.scrollPending = maxScroll;417 state.scrollPending = maxScroll;
351 diff = 0;418 diff = 0;
352@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, {419@@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, {
353 }420 }
354 421
355 // src/core/initialScroll.ts422 // src/core/initialScroll.ts
...@@ -471,7 +538,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -471,7 +538,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
471 const requestedIndex = target.index;538 const requestedIndex = target.index;
472 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;539 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
473 const itemSize = getItemSizeAtIndex(ctx, index);540 const itemSize = getItemSizeAtIndex(ctx, index);
474@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {541@@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
475 return;542 return;
476 }543 }
477 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {544 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
...@@ -482,7 +549,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -482,7 +549,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
482 if (!shouldKeepEndTargetAlive) {549 if (!shouldKeepEndTargetAlive) {
483 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {550 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
484 clearPendingInitialScrollFooterLayout(ctx, {551 clearPendingInitialScrollFooterLayout(ctx, {
485@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {552@@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
486 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);553 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
487 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);554 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
488 isReady = !hasUnknownTailSize;555 isReady = !hasUnknownTailSize;
...@@ -492,7 +559,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -492,7 +559,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
492 } else if (anchorIndex >= 0) {559 } else if (anchorIndex >= 0) {
493 isReady = false;560 isReady = false;
494 }561 }
495@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {562@@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
496 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });563 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
497 }564 }
498 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });565 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
...@@ -505,7 +572,84 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -505,7 +572,84 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
505 }572 }
506 return nextSize;573 return nextSize;
507 }574 }
508@@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded575@@ -5715,6 +5877,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
576 horizontal
577 }) {
578 const ctx = useStateContext();
579+ const SizeComponent = ctx.state.props.sizeComponentInternal;
580 const columnWrapperStyle = ctx.columnWrapperStyle;
581 const animSize = useValue$("totalSize");
582 const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
583@@ -5725,6 +5888,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
584 opacity: isVisible ? 1 : 0,
585 width: animSize
586 } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
587+ if (SizeComponent) {
588+ if (horizontal) {
589+ delete style.width;
590+ } else {
591+ delete style.height;
592+ }
593+ }
594 if (columnWrapperStyle) {
595 const { columnGap, rowGap, gap } = columnWrapperStyle;
596 const gapX = columnGap || gap || 0;
597@@ -5745,7 +5915,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
598 }
599 }
600 }
601- return /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children));
602+ const content = /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children);
603+ return SizeComponent ? /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content);
604 });
605 var Containers = typedMemo(function Containers2({
606 freshDataTransitionEpoch,
607@@ -5896,7 +6067,12 @@ var StyleSheet = ReactNative.StyleSheet;
608
609 // src/components/ListComponent.tsx
610 var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
611+ const ctx = useStateContext();
612 const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]);
613+ const SizeComponent = ctx.state.props.sizeComponentInternal;
614+ if (SizeComponent) {
615+ return /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } });
616+ }
617 if (alignItemsAtEndPadding <= 0) {
618 return null;
619 }
620@@ -5929,8 +6105,12 @@ var ListComponent = typedMemo(function ListComponent2({
621 refScrollView,
622 renderScrollComponent,
623 onLayoutFooter,
624+ onInternalMomentumScrollBegin,
625 onInternalScrollBeginDrag,
626+ onInternalScrollEndDrag,
627 onInternalScrollEnd,
628+ onMomentumScrollBegin,
629+ onScrollEndDrag,
630 scrollAdjustHandler,
631 snapToIndices,
632 stickyHeaderConfig,
633@@ -6001,7 +6181,17 @@ var ListComponent = typedMemo(function ListComponent2({
634 SnapOrScroll,
635 {
636 ...rest,
637- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag },
638+ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : {
639+ onMomentumScrollBegin: (event) => {
640+ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event);
641+ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event);
642+ },
643+ onScrollBeginDrag: onInternalScrollBeginDrag,
644+ onScrollEndDrag: (event) => {
645+ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event);
646+ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event);
647+ }
648+ },
649 contentContainerStyle: [
650 horizontal ? { height: "100%" } : {},
651 contentContainerStyle,
652@@ -7075,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
509 dataVersion,653 dataVersion,
510 drawDistance = 250,654 drawDistance = 250,
511 contentInsetEndAdjustment,655 contentInsetEndAdjustment,
...@@ -513,7 +657,20 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -513,7 +657,20 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
513 estimatedItemSize = 100,657 estimatedItemSize = 100,
514 estimatedListSize,658 estimatedListSize,
515 extraData,659 extraData,
516@@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded660@@ -7132,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
661 const animatedPropsInternal = props.animatedPropsInternal;
662 const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
663 const positionComponentInternal = props.positionComponentInternal;
664+ const sizeComponentInternal = props.sizeComponentInternal;
665 const stickyPositionComponentInternal = props.stickyPositionComponentInternal;
666 const {
667 anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal,
668 positionComponentInternal: _positionComponentInternal,
669+ sizeComponentInternal: _sizeComponentInternal,
670 stickyPositionComponentInternal: _stickyPositionComponentInternal,
671 ...restProps
672 } = rest;
673@@ -7200,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
517 const combinedRef = useCombinedRef(refScroller, refScrollView);674 const combinedRef = useCombinedRef(refScroller, refScrollView);
518 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());675 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
519 const stickyHeaderIndices = stickyHeaderIndicesProp;676 const stickyHeaderIndices = stickyHeaderIndicesProp;
...@@ -522,7 +679,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -522,7 +679,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
522 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);679 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
523 const alwaysRenderIndices = React2.useMemo(() => {680 const alwaysRenderIndices = React2.useMemo(() => {
524 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);681 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
525@@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded682@@ -7341,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
526 contentContainerAlignItems: contentContainerStyle.alignItems,683 contentContainerAlignItems: contentContainerStyle.alignItems,
527 contentInset,684 contentInset,
528 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,685 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
...@@ -530,7 +687,15 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -530,7 +687,15 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
530 data: dataProp,687 data: dataProp,
531 dataKey,688 dataKey,
532 dataVersion,689 dataVersion,
533@@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded690@@ -7372,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
691 renderItem,
692 rtl,
693 snapToIndices,
694+ sizeComponentInternal,
695 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
696 stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
697 stickyPositionComponentInternal,
698@@ -7423,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
534 return void 0;699 return void 0;
535 }700 }
536 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);701 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
...@@ -544,15 +709,43 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -544,15 +709,43 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
544 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;709 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
545 }, [usesBootstrapInitialScroll]);710 }, [usesBootstrapInitialScroll]);
546 React2.useLayoutEffect(() => {711 React2.useLayoutEffect(() => {
547@@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded712@@ -7547,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
713 [
714 dataKey,
715 dataVersion,
716+ contentInsetStartAdjustment,
717 memoizedLastItemKeys.join(","),
718 numColumnsProp,
719 nextScrollAxisGap,
720@@ -7643,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
721 () => ({
722 getRenderedItem: (key) => getRenderedItem(ctx, key),
723 onMomentumScrollEnd: (event) => {
724+ state.isMomentumScrolling = false;
725 checkFinishedScrollFallback(ctx);
726 if (state.props.onMomentumScrollEnd) {
727 state.props.onMomentumScrollEnd(event);
728@@ -7651,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
548 onScroll: (event) => onScroll(ctx, event),729 onScroll: (event) => onScroll(ctx, event),
549 onScrollBeginDrag: (event) => {730 onScrollBeginDrag: (event) => {
550 var _a4, _b2;731 var _a4, _b2;
551+ ctx.state.didUserDrag = true;732+ ctx.state.didUserDrag = true;
733+ ctx.state.isUserDragging = true;
552 prepareReachedEdgeForNextUserScroll(ctx);734 prepareReachedEdgeForNextUserScroll(ctx);
553 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);735 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
554 },736 },
555@@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded737@@ -7676,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
738 ListFooterComponent,
739 ListFooterComponentStyle,
740 ListHeaderComponent,
741+ onInternalMomentumScrollBegin: () => {
742+ ctx.state.isMomentumScrolling = true;
743+ },
744 onInternalScrollBeginDrag: fns.onScrollBeginDrag,
745+ onInternalScrollEndDrag: () => {
746+ ctx.state.isUserDragging = false;
747+ },
748 onInternalScrollEnd: fns.onScrollEnd,
556 onLayout,749 onLayout,
557 onLayoutFooter,750 onLayoutFooter,
558 onMomentumScrollEnd: fns.onMomentumScrollEnd,751 onMomentumScrollEnd: fns.onMomentumScrollEnd,
...@@ -561,7 +754,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4...@@ -561,7 +754,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
561 recycleItems,754 recycleItems,
562 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {755 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
563diff --git a/react-native.mjs b/react-native.mjs756diff --git a/react-native.mjs b/react-native.mjs
564index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644757index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644
565--- a/react-native.mjs758--- a/react-native.mjs
566+++ b/react-native.mjs759+++ b/react-native.mjs
567@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, {760@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, {
...@@ -573,7 +766,27 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -573,7 +766,27 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
573 if (isReadyToRender && !peek$(ctx, "readyToRender")) {766 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
574 set$(ctx, "readyToRender", true);767 set$(ctx, "readyToRender", true);
575 setAdaptiveRender(ctx, "normal", "ready");768 setAdaptiveRender(ctx, "normal", "ready");
576@@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {769@@ -1069,7 +1069,7 @@ function getRawContentLength(ctx) {
770 function getAlignItemsAtEndPadding(ctx) {
771 const { state } = ctx;
772 const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0;
773- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
774+ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
775 }
776 function updateContentMetricsState(ctx) {
777 const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0;
778@@ -1094,6 +1094,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) {
779 totalSize += add;
780 }
781 if (prevTotalSize !== totalSize) {
782+ const now = Date.now();
783+ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0;
784+ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1;
785+ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300);
786 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
787 state.pendingTotalSize = totalSize;
788 } else {
789@@ -1283,18 +1287,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
577 }790 }
578 791
579 // src/core/clampScrollOffset.ts792 // src/core/clampScrollOffset.ts
...@@ -599,7 +812,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -599,7 +812,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
599 return clampedOffset;812 return clampedOffset;
600 }813 }
601 814
602@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) {815@@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) {
603 finishScrollTo(ctx);816 finishScrollTo(ctx);
604 }817 }
605 }818 }
...@@ -612,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -612,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
612 x: ctx.state.props.horizontal ? offset : 0,825 x: ctx.state.props.horizontal ? offset : 0,
613 y: ctx.state.props.horizontal ? 0 : offset826 y: ctx.state.props.horizontal ? 0 : offset
614 });827 });
615@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) {828@@ -1482,7 +1491,10 @@ function checkFinishedScrollFallback(ctx) {
616 );829 );
617 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);830 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
618 } else if (shouldRetryUnalignedEndScroll) {831 } else if (shouldRetryUnalignedEndScroll) {
...@@ -624,7 +837,17 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -624,7 +837,17 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
624 scheduleFallbackCheck(100);837 scheduleFallbackCheck(100);
625 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {838 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
626 finishScrollTo(ctx);839 finishScrollTo(ctx);
627@@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) {840@@ -1539,15 +1551,28 @@ function doMaintainScrollAtEnd(ctx) {
841 } = state;
842 const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
843 const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
844+ if (state.contentSizeAnimationActiveEpoch !== void 0) {
845+ state.pendingMaintainScrollAtEnd = false;
846+ return false;
847+ }
848 if (pendingNativeMVCPAdjust) {
849 state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
850 return false;
628 }851 }
629 if (shouldMaintainScrollAtEnd) {852 if (shouldMaintainScrollAtEnd) {
630 state.pendingMaintainScrollAtEnd = false;853 state.pendingMaintainScrollAtEnd = false;
...@@ -644,7 +867,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -644,7 +867,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
644 }867 }
645 if (!state.maintainingScrollAtEnd) {868 if (!state.maintainingScrollAtEnd) {
646 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";869 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
647@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) {870@@ -1570,9 +1595,18 @@ function doMaintainScrollAtEnd(ctx) {
648 y: 0871 y: 0
649 });872 });
650 } else {873 } else {
...@@ -666,7 +889,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -666,7 +889,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
666 }889 }
667 setTimeout(890 setTimeout(
668 () => {891 () => {
669@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) {892@@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) {
670 function requestAdjust(ctx, positionDiff, dataChanged) {893 function requestAdjust(ctx, positionDiff, dataChanged) {
671 const state = ctx.state;894 const state = ctx.state;
672 if (Math.abs(positionDiff) > 0.1) {895 if (Math.abs(positionDiff) > 0.1) {
...@@ -677,7 +900,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -677,7 +900,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
677 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;900 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
678 const doit = () => {901 const doit = () => {
679 if (needsScrollWorkaround) {902 if (needsScrollWorkaround) {
680@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {903@@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
681 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {904 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
682 return 0;905 return 0;
683 }906 }
...@@ -688,7 +911,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -688,7 +911,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
688 const clampDelta = maxScroll - state.scroll;911 const clampDelta = maxScroll - state.scroll;
689 if (unresolvedAmount < 0) {912 if (unresolvedAmount < 0) {
690 return Math.max(unresolvedAmount, Math.min(0, clampDelta));913 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
691@@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {914@@ -1769,7 +1809,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
692 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);915 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
693 return true;916 return true;
694 }917 }
...@@ -697,7 +920,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -697,7 +920,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
697 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);920 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
698 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;921 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
699 if (isAtExpectedNativeClamp) {922 if (isAtExpectedNativeClamp) {
700@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) {923@@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) {
701 if (diff > 0) {924 if (diff > 0) {
702 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);925 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
703 } else {926 } else {
...@@ -706,7 +929,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -706,7 +929,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
706 state.scroll = maxScroll;929 state.scroll = maxScroll;
707 state.scrollPending = maxScroll;930 state.scrollPending = maxScroll;
708 diff = 0;931 diff = 0;
709@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, {932@@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, {
710 }933 }
711 934
712 // src/core/initialScroll.ts935 // src/core/initialScroll.ts
...@@ -828,7 +1051,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -828,7 +1051,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
828 const requestedIndex = target.index;1051 const requestedIndex = target.index;
829 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;1052 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
830 const itemSize = getItemSizeAtIndex(ctx, index);1053 const itemSize = getItemSizeAtIndex(ctx, index);
831@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {1054@@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
832 return;1055 return;
833 }1056 }
834 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {1057 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
...@@ -839,7 +1062,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -839,7 +1062,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
839 if (!shouldKeepEndTargetAlive) {1062 if (!shouldKeepEndTargetAlive) {
840 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {1063 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
841 clearPendingInitialScrollFooterLayout(ctx, {1064 clearPendingInitialScrollFooterLayout(ctx, {
842@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {1065@@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
843 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);1066 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
844 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);1067 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
845 isReady = !hasUnknownTailSize;1068 isReady = !hasUnknownTailSize;
...@@ -849,7 +1072,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -849,7 +1072,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
849 } else if (anchorIndex >= 0) {1072 } else if (anchorIndex >= 0) {
850 isReady = false;1073 isReady = false;
851 }1074 }
852@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {1075@@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
853 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });1076 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
854 }1077 }
855 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });1078 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
...@@ -862,7 +1085,84 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -862,7 +1085,84 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
862 }1085 }
863 return nextSize;1086 return nextSize;
864 }1087 }
865@@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1088@@ -5694,6 +5856,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1089 horizontal
1090 }) {
1091 const ctx = useStateContext();
1092+ const SizeComponent = ctx.state.props.sizeComponentInternal;
1093 const columnWrapperStyle = ctx.columnWrapperStyle;
1094 const animSize = useValue$("totalSize");
1095 const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
1096@@ -5704,6 +5867,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1097 opacity: isVisible ? 1 : 0,
1098 width: animSize
1099 } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
1100+ if (SizeComponent) {
1101+ if (horizontal) {
1102+ delete style.width;
1103+ } else {
1104+ delete style.height;
1105+ }
1106+ }
1107 if (columnWrapperStyle) {
1108 const { columnGap, rowGap, gap } = columnWrapperStyle;
1109 const gapX = columnGap || gap || 0;
1110@@ -5724,7 +5894,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1111 }
1112 }
1113 }
1114- return /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children));
1115+ const content = /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children);
1116+ return SizeComponent ? /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content);
1117 });
1118 var Containers = typedMemo(function Containers2({
1119 freshDataTransitionEpoch,
1120@@ -5875,7 +6046,12 @@ var StyleSheet = StyleSheet$1;
1121
1122 // src/components/ListComponent.tsx
1123 var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
1124+ const ctx = useStateContext();
1125 const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]);
1126+ const SizeComponent = ctx.state.props.sizeComponentInternal;
1127+ if (SizeComponent) {
1128+ return /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } });
1129+ }
1130 if (alignItemsAtEndPadding <= 0) {
1131 return null;
1132 }
1133@@ -5908,8 +6084,12 @@ var ListComponent = typedMemo(function ListComponent2({
1134 refScrollView,
1135 renderScrollComponent,
1136 onLayoutFooter,
1137+ onInternalMomentumScrollBegin,
1138 onInternalScrollBeginDrag,
1139+ onInternalScrollEndDrag,
1140 onInternalScrollEnd,
1141+ onMomentumScrollBegin,
1142+ onScrollEndDrag,
1143 scrollAdjustHandler,
1144 snapToIndices,
1145 stickyHeaderConfig,
1146@@ -5980,7 +6160,17 @@ var ListComponent = typedMemo(function ListComponent2({
1147 SnapOrScroll,
1148 {
1149 ...rest,
1150- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag },
1151+ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : {
1152+ onMomentumScrollBegin: (event) => {
1153+ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event);
1154+ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event);
1155+ },
1156+ onScrollBeginDrag: onInternalScrollBeginDrag,
1157+ onScrollEndDrag: (event) => {
1158+ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event);
1159+ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event);
1160+ }
1161+ },
1162 contentContainerStyle: [
1163 horizontal ? { height: "100%" } : {},
1164 contentContainerStyle,
1165@@ -7054,6 +7244,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
866 dataVersion,1166 dataVersion,
867 drawDistance = 250,1167 drawDistance = 250,
868 contentInsetEndAdjustment,1168 contentInsetEndAdjustment,
...@@ -870,7 +1170,20 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -870,7 +1170,20 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
870 estimatedItemSize = 100,1170 estimatedItemSize = 100,
871 estimatedListSize,1171 estimatedListSize,
872 extraData,1172 extraData,
873@@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1173@@ -7111,10 +7302,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1174 const animatedPropsInternal = props.animatedPropsInternal;
1175 const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
1176 const positionComponentInternal = props.positionComponentInternal;
1177+ const sizeComponentInternal = props.sizeComponentInternal;
1178 const stickyPositionComponentInternal = props.stickyPositionComponentInternal;
1179 const {
1180 anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal,
1181 positionComponentInternal: _positionComponentInternal,
1182+ sizeComponentInternal: _sizeComponentInternal,
1183 stickyPositionComponentInternal: _stickyPositionComponentInternal,
1184 ...restProps
1185 } = rest;
1186@@ -7179,7 +7372,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
874 const combinedRef = useCombinedRef(refScroller, refScrollView);1187 const combinedRef = useCombinedRef(refScroller, refScrollView);
875 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());1188 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
876 const stickyHeaderIndices = stickyHeaderIndicesProp;1189 const stickyHeaderIndices = stickyHeaderIndicesProp;
...@@ -879,7 +1192,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -879,7 +1192,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
879 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);1192 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
880 const alwaysRenderIndices = useMemo(() => {1193 const alwaysRenderIndices = useMemo(() => {
881 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);1194 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
882@@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1195@@ -7320,6 +7513,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
883 contentContainerAlignItems: contentContainerStyle.alignItems,1196 contentContainerAlignItems: contentContainerStyle.alignItems,
884 contentInset,1197 contentInset,
885 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,1198 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
...@@ -887,7 +1200,15 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -887,7 +1200,15 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
887 data: dataProp,1200 data: dataProp,
888 dataKey,1201 dataKey,
889 dataVersion,1202 dataVersion,
890@@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1203@@ -7351,6 +7545,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1204 renderItem,
1205 rtl,
1206 snapToIndices,
1207+ sizeComponentInternal,
1208 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
1209 stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
1210 stickyPositionComponentInternal,
1211@@ -7402,6 +7597,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
891 return void 0;1212 return void 0;
892 }1213 }
893 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);1214 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
...@@ -901,15 +1222,43 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -901,15 +1222,43 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
901 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;1222 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
902 }, [usesBootstrapInitialScroll]);1223 }, [usesBootstrapInitialScroll]);
903 useLayoutEffect(() => {1224 useLayoutEffect(() => {
904@@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1225@@ -7526,6 +7728,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1226 [
1227 dataKey,
1228 dataVersion,
1229+ contentInsetStartAdjustment,
1230 memoizedLastItemKeys.join(","),
1231 numColumnsProp,
1232 nextScrollAxisGap,
1233@@ -7622,6 +7825,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1234 () => ({
1235 getRenderedItem: (key) => getRenderedItem(ctx, key),
1236 onMomentumScrollEnd: (event) => {
1237+ state.isMomentumScrolling = false;
1238 checkFinishedScrollFallback(ctx);
1239 if (state.props.onMomentumScrollEnd) {
1240 state.props.onMomentumScrollEnd(event);
1241@@ -7630,6 +7834,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
905 onScroll: (event) => onScroll(ctx, event),1242 onScroll: (event) => onScroll(ctx, event),
906 onScrollBeginDrag: (event) => {1243 onScrollBeginDrag: (event) => {
907 var _a4, _b2;1244 var _a4, _b2;
908+ ctx.state.didUserDrag = true;1245+ ctx.state.didUserDrag = true;
1246+ ctx.state.isUserDragging = true;
909 prepareReachedEdgeForNextUserScroll(ctx);1247 prepareReachedEdgeForNextUserScroll(ctx);
910 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);1248 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
911 },1249 },
912@@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1250@@ -7655,11 +7861,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1251 ListFooterComponent,
1252 ListFooterComponentStyle,
1253 ListHeaderComponent,
1254+ onInternalMomentumScrollBegin: () => {
1255+ ctx.state.isMomentumScrolling = true;
1256+ },
1257 onInternalScrollBeginDrag: fns.onScrollBeginDrag,
1258+ onInternalScrollEndDrag: () => {
1259+ ctx.state.isUserDragging = false;
1260+ },
1261 onInternalScrollEnd: fns.onScrollEnd,
913 onLayout,1262 onLayout,
914 onLayoutFooter,1263 onLayoutFooter,
915 onMomentumScrollEnd: fns.onMomentumScrollEnd,1264 onMomentumScrollEnd: fns.onMomentumScrollEnd,
...@@ -918,7 +1267,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c...@@ -918,7 +1267,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
918 recycleItems,1267 recycleItems,
919 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {1268 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
920diff --git a/react.js b/react.js1269diff --git a/react.js b/react.js
921index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9b774d49b 1006441270index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644
922--- a/react.js1271--- a/react.js
923+++ b/react.js1272+++ b/react.js
924@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {1273@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
...@@ -931,8 +1280,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9...@@ -931,8 +1280,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9
931 } else if (anchorIndex >= 0) {1280 } else if (anchorIndex >= 0) {
932 isReady = false;1281 isReady = false;
933 }1282 }
934@@ -4721,7 +4722,13 @@ function maybeUpdateAnchoredEndSpace(ctx) {1283@@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
935 updateContentMetricsState(ctx);
936 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });1284 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
937 }1285 }
938 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });1286 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
...@@ -945,7 +1293,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9...@@ -945,7 +1293,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9
945 }1293 }
946 return nextSize;1294 return nextSize;
947 }1295 }
948@@ -6448,8 +6455,8 @@ function ScrollAdjust() {1296@@ -6446,8 +6453,8 @@ function ScrollAdjust() {
949 window.getComputedStyle(contentNode)[axis.paddingEndProp]1297 window.getComputedStyle(contentNode)[axis.paddingEndProp]
950 );1298 );
951 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;1299 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
...@@ -956,7 +1304,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9...@@ -956,7 +1304,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9
956 scrollBy();1304 scrollBy();
957 if (resetPaddingRafRef.current !== void 0) {1305 if (resetPaddingRafRef.current !== void 0) {
958diff --git a/react.mjs b/react.mjs1306diff --git a/react.mjs b/react.mjs
959index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e86820262da4 1006441307index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644
960--- a/react.mjs1308--- a/react.mjs
961+++ b/react.mjs1309+++ b/react.mjs
962@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {1310@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
...@@ -969,8 +1317,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868...@@ -969,8 +1317,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868
969 } else if (anchorIndex >= 0) {1317 } else if (anchorIndex >= 0) {
970 isReady = false;1318 isReady = false;
971 }1319 }
972@@ -4700,7 +4701,13 @@ function maybeUpdateAnchoredEndSpace(ctx) {1320@@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
973 updateContentMetricsState(ctx);
974 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });1321 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
975 }1322 }
976 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });1323 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
...@@ -983,7 +1330,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868...@@ -983,7 +1330,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868
983 }1330 }
984 return nextSize;1331 return nextSize;
985 }1332 }
986@@ -6427,8 +6434,8 @@ function ScrollAdjust() {1333@@ -6425,8 +6432,8 @@ function ScrollAdjust() {
987 window.getComputedStyle(contentNode)[axis.paddingEndProp]1334 window.getComputedStyle(contentNode)[axis.paddingEndProp]
988 );1335 );
989 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;1336 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
...@@ -1011,19 +1358,22 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613...@@ -1011,19 +1358,22 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613
1011 * Number of columns to render items in.1358 * Number of columns to render items in.
1012 * @default 11359 * @default 1
1013diff --git a/reanimated.js b/reanimated.js1360diff --git a/reanimated.js b/reanimated.js
1014index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 1006441361index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644
1015--- a/reanimated.js1362--- a/reanimated.js
1016+++ b/reanimated.js1363+++ b/reanimated.js
1017@@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1364@@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1365 const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props;
1018 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);1366 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
1019 const prevItemKeyRef = React__namespace.useRef(void 0);1367 const prevItemKeyRef = React__namespace.useRef(void 0);
1368+ const previousPositionRef = React__namespace.useRef(positionValue);
1369+ const lastContentSizeEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1020 let shouldSkipTransitionForRecycleReuse = false;1370 let shouldSkipTransitionForRecycleReuse = false;
1021- if (recycleItems && layoutTransition) {1371- if (recycleItems && layoutTransition) {
1022+ if (layoutTransition) {1372+ if (layoutTransition) {
1023 const itemKeySignal = `containerItemKey${id}`;1373 const itemKeySignal = `containerItemKey${id}`;
1024 const itemKey = peek$(ctx, itemKeySignal);1374 const itemKey = peek$(ctx, itemKeySignal);
1025 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;1375 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
1026@@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1376@@ -130,10 +132,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1027 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],1377 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
1028 [horizontal, positionValue, style]1378 [horizontal, positionValue, style]
1029 );1379 );
...@@ -1035,30 +1385,168 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274...@@ -1035,30 +1385,168 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274
1035+ // transition exists to smooth at-rest shifts (streaming text, work-log1385+ // transition exists to smooth at-rest shifts (streaming text, work-log
1036+ // folds), so gate it to at-rest moments.1386+ // folds), so gate it to at-rest moments.
1037+ const now = Date.now();1387+ const now = Date.now();
1038+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||1388+ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1039+ now - (ctx.state.lastNativeScrollTime || 0) < 300;1389+ const didPositionChange = positionValue !== previousPositionRef.current;
1390+ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function";
1391+ React__namespace.useLayoutEffect(() => {
1392+ if (didPositionChange) {
1393+ previousPositionRef.current = positionValue;
1394+ lastContentSizeEpochRef.current = contentSizeEpoch;
1395+ }
1396+ }, [contentSizeEpoch, didPositionChange, positionValue]);
1397+ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300;
1398+ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition;
1040 return /* @__PURE__ */ React__namespace.createElement(1399 return /* @__PURE__ */ React__namespace.createElement(
1041 Reanimated__default.default.View,1400 Reanimated__default.default.View,
1042 {1401 {
1043- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,1402- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1044+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,1403+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
1045 ref: refView,1404 ref: refView,
1046 style: viewStyle,1405 style: viewStyle,
1047 ...rest1406 ...rest
1407@@ -141,6 +162,96 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1408 children
1409 );
1410 });
1411+var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) {
1412+ const ctx = useStateContext();
1413+ const { horizontal, signalName, style, children, ...rest } = props;
1414+ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]);
1415+ const baseSize = Reanimated.useSharedValue(logicalSize);
1416+ const animatedDelta = Reanimated.useSharedValue(0);
1417+ const previousLogicalSizeRef = React__namespace.useRef(logicalSize);
1418+ const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1419+ const animationRunRef = React__namespace.useRef(0);
1420+ const isAnimatingRef = React__namespace.useRef(false);
1421+ const [isAnimating, setIsAnimating] = React__namespace.useState(false);
1422+ const transition = props.layoutTransition;
1423+ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
1424+ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1425+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible;
1426+ const useAnimatedAxis = isAnimating || startsEligibleAnimation;
1427+ const animatedStyle = Reanimated.useAnimatedStyle(() => {
1428+ const size = Math.max(0, baseSize.value + animatedDelta.value);
1429+ return horizontal ? { width: size } : { height: size };
1430+ }, [horizontal]);
1431+ const completeAnimation = React__namespace.useCallback((run, epoch) => {
1432+ if (animationRunRef.current !== run) {
1433+ return;
1434+ }
1435+ isAnimatingRef.current = false;
1436+ setIsAnimating(false);
1437+ const state = ctx.state;
1438+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1439+ return;
1440+ }
1441+ state.contentSizeAnimationActiveSignals.delete(signalName);
1442+ if (state.contentSizeAnimationActiveSignals.size === 0) {
1443+ state.contentSizeAnimationActiveEpoch = void 0;
1444+ state.contentSizeAnimationEligible = false;
1445+ }
1446+ }, [ctx.state, signalName]);
1447+ React__namespace.useLayoutEffect(() => {
1448+ const state = ctx.state;
1449+ const epoch = state.contentSizeAnimationEpoch || 0;
1450+ const previousLogicalSize = previousLogicalSizeRef.current;
1451+ const delta = logicalSize - previousLogicalSize;
1452+ const isNewEpoch = epoch !== previousEpochRef.current;
1453+ const isNewEligibleEpoch = canAnimate && isNewEpoch && state.contentSizeAnimationEligible;
1454+ previousLogicalSizeRef.current = logicalSize;
1455+ previousEpochRef.current = epoch;
1456+ if (!delta) {
1457+ return;
1458+ }
1459+ if (!isNewEligibleEpoch) {
1460+ baseSize.value = logicalSize;
1461+ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
1462+ state.contentSizeAnimationEligible = false;
1463+ }
1464+ return;
1465+ }
1466+ const [animation, config] = transition.getAnimationAndConfig();
1467+ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value;
1468+ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0;
1469+ if (typeof animation !== "function" || typeof delayFunction !== "function") {
1470+ baseSize.value = logicalSize;
1471+ if (signalName === "totalSize") {
1472+ state.contentSizeAnimationEligible = false;
1473+ }
1474+ return;
1475+ }
1476+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1477+ state.contentSizeAnimationActiveEpoch = epoch;
1478+ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set();
1479+ }
1480+ state.contentSizeAnimationActiveSignals.add(signalName);
1481+ const wasAnimating = isAnimatingRef.current;
1482+ isAnimatingRef.current = true;
1483+ setIsAnimating(true);
1484+ const run = ++animationRunRef.current;
1485+ Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
1486+ "worklet";
1487+ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize;
1488+ baseSize.value = absoluteSize;
1489+ animatedDelta.value = currentVisualSize - absoluteSize;
1490+ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => {
1491+ "worklet";
1492+ if (finished) {
1493+ Reanimated.runOnJS(completeAnimation)(animationRun, animationEpoch);
1494+ }
1495+ }));
1496+ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
1497+ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
1498+ const sizeStyle = useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize };
1499+ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, sizeStyle] }, children);
1500+});
1501 function setSharedValueValue(sharedValue, value) {
1502 if (!sharedValue) {
1503 return;
1504@@ -245,10 +356,19 @@ var LegendListForwardedRef = typedMemo(
1505 );
1506 };
1507 }, [hasItemLayoutAnimation, recycleItems]);
1508+ const sizeComponentInternal = React__namespace.useMemo(() => {
1509+ if (!hasItemLayoutAnimation) {
1510+ return void 0;
1511+ }
1512+ return function SizeComponent(sizeProps) {
1513+ return /* @__PURE__ */ React__namespace.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current });
1514+ };
1515+ }, [hasItemLayoutAnimation]);
1516 const legendListProps = {
1517 ...rest,
1518 positionComponentInternal,
1519 recycleItems,
1520+ sizeComponentInternal,
1521 ...{
1522 renderScrollComponent: renderReanimatedScrollComponent,
1523 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
1048diff --git a/reanimated.mjs b/reanimated.mjs1524diff --git a/reanimated.mjs b/reanimated.mjs
1049index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 1006441525index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644
1050--- a/reanimated.mjs1526--- a/reanimated.mjs
1051+++ b/reanimated.mjs1527+++ b/reanimated.mjs
1052@@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1528@@ -1,7 +1,7 @@
1529 import * as React from 'react';
1530 import { useCallback } from 'react';
1531 import { View } from 'react-native';
1532-import Reanimated, { useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated';
1533+import Reanimated, { runOnJS, runOnUI, useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated';
1534 import { internal, LegendList } from '@legendapp/list/react-native';
1535
1536 // src/integrations/reanimated.tsx
1537@@ -91,8 +91,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1538 const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props;
1053 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);1539 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
1054 const prevItemKeyRef = React.useRef(void 0);1540 const prevItemKeyRef = React.useRef(void 0);
1541+ const previousPositionRef = React.useRef(positionValue);
1542+ const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1055 let shouldSkipTransitionForRecycleReuse = false;1543 let shouldSkipTransitionForRecycleReuse = false;
1056- if (recycleItems && layoutTransition) {1544- if (recycleItems && layoutTransition) {
1057+ if (layoutTransition) {1545+ if (layoutTransition) {
1058 const itemKeySignal = `containerItemKey${id}`;1546 const itemKeySignal = `containerItemKey${id}`;
1059 const itemKey = peek$(ctx, itemKeySignal);1547 const itemKey = peek$(ctx, itemKeySignal);
1060 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;1548 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
1061@@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1549@@ -106,10 +108,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1062 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],1550 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
1063 [horizontal, positionValue, style]1551 [horizontal, positionValue, style]
1064 );1552 );
...@@ -1070,13 +1558,139 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a...@@ -1070,13 +1558,139 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a
1070+ // transition exists to smooth at-rest shifts (streaming text, work-log1558+ // transition exists to smooth at-rest shifts (streaming text, work-log
1071+ // folds), so gate it to at-rest moments.1559+ // folds), so gate it to at-rest moments.
1072+ const now = Date.now();1560+ const now = Date.now();
1073+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||1561+ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1074+ now - (ctx.state.lastNativeScrollTime || 0) < 300;1562+ const didPositionChange = positionValue !== previousPositionRef.current;
1563+ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function";
1564+ React.useLayoutEffect(() => {
1565+ if (didPositionChange) {
1566+ previousPositionRef.current = positionValue;
1567+ lastContentSizeEpochRef.current = contentSizeEpoch;
1568+ }
1569+ }, [contentSizeEpoch, didPositionChange, positionValue]);
1570+ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300;
1571+ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition;
1075 return /* @__PURE__ */ React.createElement(1572 return /* @__PURE__ */ React.createElement(
1076 Reanimated.View,1573 Reanimated.View,
1077 {1574 {
1078- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,1575- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1079+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,1576+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
1080 ref: refView,1577 ref: refView,
1081 style: viewStyle,1578 style: viewStyle,
1082 ...rest1579 ...rest
1580@@ -117,6 +138,96 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1581 children
1582 );
1583 });
1584+var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) {
1585+ const ctx = useStateContext();
1586+ const { horizontal, signalName, style, children, ...rest } = props;
1587+ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]);
1588+ const baseSize = useSharedValue(logicalSize);
1589+ const animatedDelta = useSharedValue(0);
1590+ const previousLogicalSizeRef = React.useRef(logicalSize);
1591+ const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1592+ const animationRunRef = React.useRef(0);
1593+ const isAnimatingRef = React.useRef(false);
1594+ const [isAnimating, setIsAnimating] = React.useState(false);
1595+ const transition = props.layoutTransition;
1596+ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
1597+ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1598+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible;
1599+ const useAnimatedAxis = isAnimating || startsEligibleAnimation;
1600+ const animatedStyle = useAnimatedStyle(() => {
1601+ const size = Math.max(0, baseSize.value + animatedDelta.value);
1602+ return horizontal ? { width: size } : { height: size };
1603+ }, [horizontal]);
1604+ const completeAnimation = useCallback((run, epoch) => {
1605+ if (animationRunRef.current !== run) {
1606+ return;
1607+ }
1608+ isAnimatingRef.current = false;
1609+ setIsAnimating(false);
1610+ const state = ctx.state;
1611+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1612+ return;
1613+ }
1614+ state.contentSizeAnimationActiveSignals.delete(signalName);
1615+ if (state.contentSizeAnimationActiveSignals.size === 0) {
1616+ state.contentSizeAnimationActiveEpoch = void 0;
1617+ state.contentSizeAnimationEligible = false;
1618+ }
1619+ }, [ctx.state, signalName]);
1620+ React.useLayoutEffect(() => {
1621+ const state = ctx.state;
1622+ const epoch = state.contentSizeAnimationEpoch || 0;
1623+ const previousLogicalSize = previousLogicalSizeRef.current;
1624+ const delta = logicalSize - previousLogicalSize;
1625+ const isNewEpoch = epoch !== previousEpochRef.current;
1626+ const isNewEligibleEpoch = canAnimate && isNewEpoch && state.contentSizeAnimationEligible;
1627+ previousLogicalSizeRef.current = logicalSize;
1628+ previousEpochRef.current = epoch;
1629+ if (!delta) {
1630+ return;
1631+ }
1632+ if (!isNewEligibleEpoch) {
1633+ baseSize.value = logicalSize;
1634+ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
1635+ state.contentSizeAnimationEligible = false;
1636+ }
1637+ return;
1638+ }
1639+ const [animation, config] = transition.getAnimationAndConfig();
1640+ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value;
1641+ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0;
1642+ if (typeof animation !== "function" || typeof delayFunction !== "function") {
1643+ baseSize.value = logicalSize;
1644+ if (signalName === "totalSize") {
1645+ state.contentSizeAnimationEligible = false;
1646+ }
1647+ return;
1648+ }
1649+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1650+ state.contentSizeAnimationActiveEpoch = epoch;
1651+ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set();
1652+ }
1653+ state.contentSizeAnimationActiveSignals.add(signalName);
1654+ const wasAnimating = isAnimatingRef.current;
1655+ isAnimatingRef.current = true;
1656+ setIsAnimating(true);
1657+ const run = ++animationRunRef.current;
1658+ runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
1659+ "worklet";
1660+ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize;
1661+ baseSize.value = absoluteSize;
1662+ animatedDelta.value = currentVisualSize - absoluteSize;
1663+ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => {
1664+ "worklet";
1665+ if (finished) {
1666+ runOnJS(completeAnimation)(animationRun, animationEpoch);
1667+ }
1668+ }));
1669+ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
1670+ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
1671+ const sizeStyle = useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize };
1672+ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, sizeStyle] }, children);
1673+});
1674 function setSharedValueValue(sharedValue, value) {
1675 if (!sharedValue) {
1676 return;
1677@@ -221,10 +332,19 @@ var LegendListForwardedRef = typedMemo(
1678 );
1679 };
1680 }, [hasItemLayoutAnimation, recycleItems]);
1681+ const sizeComponentInternal = React.useMemo(() => {
1682+ if (!hasItemLayoutAnimation) {
1683+ return void 0;
1684+ }
1685+ return function SizeComponent(sizeProps) {
1686+ return /* @__PURE__ */ React.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current });
1687+ };
1688+ }, [hasItemLayoutAnimation]);
1689 const legendListProps = {
1690 ...rest,
1691 positionComponentInternal,
1692 recycleItems,
1693+ sizeComponentInternal,
1694 ...{
1695 renderScrollComponent: renderReanimatedScrollComponent,
1696 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
patches/react-native-keyboard-controller@1.21.13.patch+456-70
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js1diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js
2index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 1006442index db8cfb1d289f91563f13c4dd842c783c99facc32..64c7e38ad8d9e866b15a53e561509e6b0ad33cb9 100644
3--- a/lib/commonjs/components/KeyboardChatScrollView/index.js3--- a/lib/commonjs/components/KeyboardChatScrollView/index.js
4+++ b/lib/commonjs/components/KeyboardChatScrollView/index.js4+++ b/lib/commonjs/components/KeyboardChatScrollView/index.js
5@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({5@@ -26,9 +26,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
6 offset = 0,6 offset = 0,
7 extraContentPadding = ZERO_CONTENT_PADDING,7 extraContentPadding = ZERO_CONTENT_PADDING,
8 blankSpace = ZERO_BLANK_SPACE,8 blankSpace = ZERO_BLANK_SPACE,
9+ adjustedInsetCompensation = 0,9+ adjustedInsetCompensation = 0,
10+ adjustedStartInsetCompensation = 0,
10 applyWorkaroundForContentInsetHitTestBug = false,11 applyWorkaroundForContentInsetHitTestBug = false,
11 onLayout: onLayoutProp,12 onLayout: onLayoutProp,
12 onContentSizeChange: onContentSizeChangeProp,13 onContentSizeChange: onContentSizeChangeProp,
...@@ -14,13 +15,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414...@@ -14,13 +15,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414
14 onEndVisible,15 onEndVisible,
15 ...rest16 ...rest
16 }, ref) => {17 }, ref) => {
17@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({18@@ -50,13 +53,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
18 freeze: freezeSV,19 freeze: freezeSV,
19 offset,20 offset,
20 blankSpace,21 blankSpace,
21- extraContentPadding22- extraContentPadding
22+ extraContentPadding,23+ extraContentPadding,
23+ adjustedInsetCompensation24+ adjustedInsetCompensation,
25+ adjustedStartInsetCompensation
24 });26 });
25 (0, _useExtraContentPadding.useExtraContentPadding)({27 (0, _useExtraContentPadding.useExtraContentPadding)({
26 scrollViewRef,28 scrollViewRef,
...@@ -28,17 +30,23 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414...@@ -28,17 +30,23 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414
28 keyboardPadding: padding,30 keyboardPadding: padding,
29 blankSpace,31 blankSpace,
30+ adjustedInsetCompensation,32+ adjustedInsetCompensation,
33+ adjustedStartInsetCompensation,
31 scroll,34 scroll,
32 layout,35 layout,
33 size,36 size,
34@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({37@@ -82,10 +89,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
35 // a bug for you, please open an issue.38 // a bug for you, please open an issue.
36 const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));39 const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
37 40
41+ // iOS applies the destination contentInset and contentOffset together at
42+ // keyboard-animation start. Keep that native target behavior, but report
43+ // the presentation height so virtualized-list layout follows the keyboard.
44+ const reportedPadding = (0, _reactNativeReanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value);
45+
38+ // Mirror the effective bottom padding (keyboard + composer + blank floor)46+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
39+ // to the consumer - a virtualized list needs it in its own scroll math or47+ // to the consumer - a virtualized list needs it in its own scroll math or
40+ // its end/maintain targets point at the under-the-keyboard resting offset.48+ // its end/maintain targets point at the under-the-keyboard resting offset.
41+ (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => {49+ (0, _reactNativeReanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => {
42+ if (onContentInsetChange && current !== previous) {50+ if (onContentInsetChange && current !== previous) {
43+ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({51+ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({
44+ bottom: current52+ bottom: current
...@@ -54,21 +62,57 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414...@@ -54,21 +62,57 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414
54 const onLayout = (0, _react.useCallback)(e => {62 const onLayout = (0, _react.useCallback)(e => {
55 onLayoutInternal(e);63 onLayoutInternal(e);
56 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);64 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
65diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
66index 4d33b3fe426070cf312fc493ce902b2c24d853ea..e619708b95865afd8b43dd8f0a6523fcf67b1f53 100644
67--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
68+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
69@@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight,
70 * ```
71 */
72 exports.clampedScrollTarget = clampedScrollTarget;
73-const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => {
74+const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => {
75 "worklet";
76
77 const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight;
78@@ -214,8 +214,9 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight,
79 const maxScroll = Math.max(contentHeight - layoutHeight, 0);
80 return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax);
81 }
82- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
83- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
84+ const minScroll = -startInsetCompensation;
85+ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll);
86+ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll);
87 };
88 exports.computeIOSContentOffset = computeIOSContentOffset;
89 //# sourceMappingURL=helpers.js.map
57diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js90diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
58index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 10064491index 2073da84b8b2be291aa3181700c2b18a75d0fc56..7ae9bb220ce3cc23c788a79677afdc7d9547a27c 100644
59--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js92--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
60+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js93+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
61@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) {94@@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) {
62 freeze,95 freeze,
63 offset,96 offset,
64 blankSpace,97 blankSpace,
65- extraContentPadding98- extraContentPadding
66+ extraContentPadding,99+ extraContentPadding,
67+ adjustedInsetCompensation100+ adjustedInsetCompensation,
101+ adjustedStartInsetCompensation
68 } = options;102 } = options;
69 const padding = (0, _reactNativeReanimated.useSharedValue)(0);103 const padding = (0, _reactNativeReanimated.useSharedValue)(0);
70 const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0);104 const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0);
71@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) {105@@ -58,6 +60,9 @@ function useChatKeyboard(scrollViewRef, options) {
106 targetKeyboardHeight.value = e.height;
107 }
108 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
109+ if (e.duration === 0) {
110+ currentHeight.value = effective;
111+ }
112 const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted);
113
114 // Scale minimum padding absorption by how much of it is visible.
115@@ -66,7 +71,7 @@ function useChatKeyboard(scrollViewRef, options) {
72 const visiblePadding = visibleFraction * blankSpace.value;116 const visiblePadding = visibleFraction * blankSpace.value;
73 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);117 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
74 const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed);118 const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed);
...@@ -77,28 +121,81 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e...@@ -77,28 +121,81 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e
77 121
78 // persistent mode: when keyboard shrinks, clamp to valid range122 // persistent mode: when keyboard shrinks, clamp to valid range
79 if (keyboardLiftBehavior === "persistent" && effective < padding.value) {123 if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
80@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) {124@@ -76,8 +81,8 @@ function useChatKeyboard(scrollViewRef, options) {
125 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
126 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
127 } else {
128- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
129- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
130+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
131+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
132 }
133 return;
134 }
135@@ -91,8 +96,8 @@ function useChatKeyboard(scrollViewRef, options) {
136 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
137 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
138 } else {
139- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
140- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
141+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
142+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
143 }
144 return;
145 }
146@@ -118,12 +123,23 @@ function useChatKeyboard(scrollViewRef, options) {
147 contentOffsetY.value = scroll.value;
148 return;
149 }
150- contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding);
151+ contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation);
152+ },
153+ onMove: e => {
154+ "worklet";
155+
156+ if (freeze.value) {
157+ return;
158+ }
159+ currentHeight.value = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
160 },
161- onMove: () => {
162+ onInteractive: e => {
163 "worklet";
164
165- // iOS doesn't need per-frame updates (contentOffset handles it)
166+ if (freeze.value) {
167+ return;
168+ }
169+ currentHeight.value = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
170 },
171 onEnd: e => {
172 "worklet";
173@@ -132,9 +148,10 @@ function useChatKeyboard(scrollViewRef, options) {
174 return;
175 }
81 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);176 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
177+ currentHeight.value = effective;
82 padding.value = effective;178 padding.value = effective;
83 }179 }
84- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);180- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
85+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);181+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]);
86 return {182 return {
87 padding,183 padding,
88 currentHeight,184 currentHeight,
89diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js185diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
90index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644186index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..e0b73653fd1dd23f0f2e2c85835a3d107e3f666d 100644
91--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js187--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
92+++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js188+++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
93@@ -29,6 +29,7 @@ function useExtraContentPadding(options) {189@@ -29,6 +29,8 @@ function useExtraContentPadding(options) {
94 extraContentPadding,190 extraContentPadding,
95 keyboardPadding,191 keyboardPadding,
96 blankSpace,192 blankSpace,
97+ adjustedInsetCompensation,193+ adjustedInsetCompensation,
194+ adjustedStartInsetCompensation,
98 scroll,195 scroll,
99 layout,196 layout,
100 size,197 size,
101@@ -68,8 +69,8 @@ function useExtraContentPadding(options) {198@@ -68,8 +70,8 @@ function useExtraContentPadding(options) {
102 }199 }
103 200
104 // Compute effective delta considering blankSpace floor201 // Compute effective delta considering blankSpace floor
...@@ -109,33 +206,41 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da...@@ -109,33 +206,41 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da
109 const effectiveDelta = currentTotal - previousTotal;206 const effectiveDelta = currentTotal - previousTotal;
110 if (effectiveDelta === 0) {207 if (effectiveDelta === 0) {
111 // blankSpace absorbed the change208 // blankSpace absorbed the change
112@@ -92,6 +93,6 @@ function useExtraContentPadding(options) {209@@ -88,10 +90,11 @@ function useExtraContentPadding(options) {
113 const target = Math.min(scroll.value + effectiveDelta, maxScroll);210 const target = Math.max(scroll.value - effectiveDelta, -currentTotal);
211 scrollToTarget(target);
212 } else {
213- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0);
214- const target = Math.min(scroll.value + effectiveDelta, maxScroll);
215+ const minScroll = -adjustedStartInsetCompensation;
216+ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll);
217+ const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll));
114 scrollToTarget(target);218 scrollToTarget(target);
115 }219 }
116- }, [inverted, keyboardLiftBehavior]);220- }, [inverted, keyboardLiftBehavior]);
117+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);221+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]);
118 }222 }
119 //# sourceMappingURL=index.js.map223 //# sourceMappingURL=index.js.map
120\ No newline at end of file
121diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js224diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js
122index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644225index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..76b858a6590ad0592cc663864ea822db55eaeceb 100644
123--- a/lib/module/components/KeyboardChatScrollView/index.js226--- a/lib/module/components/KeyboardChatScrollView/index.js
124+++ b/lib/module/components/KeyboardChatScrollView/index.js227+++ b/lib/module/components/KeyboardChatScrollView/index.js
125@@ -1,7 +1,7 @@228@@ -1,7 +1,7 @@
126 function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }229 function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
127 import React, { forwardRef, useCallback, useMemo } from "react";230 import React, { forwardRef, useCallback, useMemo } from "react";
128 import { StyleSheet } from "react-native";231-import { StyleSheet } from "react-native";
129-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";232-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
233+import { Platform, StyleSheet } from "react-native";
130+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";234+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
131 import Reanimated from "react-native-reanimated";235 import Reanimated from "react-native-reanimated";
132 import useCombinedRef from "../hooks/useCombinedRef";236 import useCombinedRef from "../hooks/useCombinedRef";
133 import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";237 import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
134@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({238@@ -19,9 +19,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
135 offset = 0,239 offset = 0,
136 extraContentPadding = ZERO_CONTENT_PADDING,240 extraContentPadding = ZERO_CONTENT_PADDING,
137 blankSpace = ZERO_BLANK_SPACE,241 blankSpace = ZERO_BLANK_SPACE,
138+ adjustedInsetCompensation = 0,242+ adjustedInsetCompensation = 0,
243+ adjustedStartInsetCompensation = 0,
139 applyWorkaroundForContentInsetHitTestBug = false,244 applyWorkaroundForContentInsetHitTestBug = false,
140 onLayout: onLayoutProp,245 onLayout: onLayoutProp,
141 onContentSizeChange: onContentSizeChangeProp,246 onContentSizeChange: onContentSizeChangeProp,
...@@ -143,13 +248,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116...@@ -143,13 +248,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116
143 onEndVisible,248 onEndVisible,
144 ...rest249 ...rest
145 }, ref) => {250 }, ref) => {
146@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({251@@ -43,13 +46,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
147 freeze: freezeSV,252 freeze: freezeSV,
148 offset,253 offset,
149 blankSpace,254 blankSpace,
150- extraContentPadding255- extraContentPadding
151+ extraContentPadding,256+ extraContentPadding,
152+ adjustedInsetCompensation257+ adjustedInsetCompensation,
258+ adjustedStartInsetCompensation
153 });259 });
154 useExtraContentPadding({260 useExtraContentPadding({
155 scrollViewRef,261 scrollViewRef,
...@@ -157,17 +263,23 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116...@@ -157,17 +263,23 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116
157 keyboardPadding: padding,263 keyboardPadding: padding,
158 blankSpace,264 blankSpace,
159+ adjustedInsetCompensation,265+ adjustedInsetCompensation,
266+ adjustedStartInsetCompensation,
160 scroll,267 scroll,
161 layout,268 layout,
162 size,269 size,
163@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({270@@ -75,10 +82,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
164 // a bug for you, please open an issue.271 // a bug for you, please open an issue.
165 const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));272 const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
166 273
274+ // iOS applies the destination contentInset and contentOffset together at
275+ // keyboard-animation start. Keep that native target behavior, but report
276+ // the presentation height so virtualized-list layout follows the keyboard.
277+ const reportedPadding = useDerivedValue(() => Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value);
278+
167+ // Mirror the effective bottom padding (keyboard + composer + blank floor)279+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
168+ // to the consumer - a virtualized list needs it in its own scroll math or280+ // to the consumer - a virtualized list needs it in its own scroll math or
169+ // its end/maintain targets point at the under-the-keyboard resting offset.281+ // its end/maintain targets point at the under-the-keyboard resting offset.
170+ useAnimatedReaction(() => totalPadding.value, (current, previous) => {282+ useAnimatedReaction(() => reportedPadding.value, (current, previous) => {
171+ if (onContentInsetChange && current !== previous) {283+ if (onContentInsetChange && current !== previous) {
172+ runOnJS(onContentInsetChange)({284+ runOnJS(onContentInsetChange)({
173+ bottom: current285+ bottom: current
...@@ -183,21 +295,56 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116...@@ -183,21 +295,56 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116
183 const onLayout = useCallback(e => {295 const onLayout = useCallback(e => {
184 onLayoutInternal(e);296 onLayoutInternal(e);
185 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);297 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
298diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
299index 295e2219b560519b8d033968b5105d1da3e77315..ff7ee3fe0d880c2287a7b70d5ecf952e99357adf 100644
300--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
301+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
302@@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH
303 * computeIOSContentOffset(100, 300, 1000, 800, false); // 400
304 * ```
305 */
306-export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => {
307+export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => {
308 "worklet";
309
310 const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight;
311@@ -201,7 +201,8 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH
312 const maxScroll = Math.max(contentHeight - layoutHeight, 0);
313 return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax);
314 }
315- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
316- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
317+ const minScroll = -startInsetCompensation;
318+ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll);
319+ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll);
320 };
321 //# sourceMappingURL=helpers.js.map
186diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js322diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
187index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644323index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..cc4bfae859191aeb848179028b7217098cba2340 100644
188--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js324--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
189+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js325+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
190@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) {326@@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) {
191 freeze,327 freeze,
192 offset,328 offset,
193 blankSpace,329 blankSpace,
194- extraContentPadding330- extraContentPadding
195+ extraContentPadding,331+ extraContentPadding,
196+ adjustedInsetCompensation332+ adjustedInsetCompensation,
333+ adjustedStartInsetCompensation
197 } = options;334 } = options;
198 const padding = useSharedValue(0);335 const padding = useSharedValue(0);
199 const currentHeight = useSharedValue(0);336 const currentHeight = useSharedValue(0);
200@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) {337@@ -51,6 +53,9 @@ function useChatKeyboard(scrollViewRef, options) {
338 targetKeyboardHeight.value = e.height;
339 }
340 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
341+ if (e.duration === 0) {
342+ currentHeight.value = effective;
343+ }
344 const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted);
345
346 // Scale minimum padding absorption by how much of it is visible.
347@@ -59,7 +64,7 @@ function useChatKeyboard(scrollViewRef, options) {
201 const visiblePadding = visibleFraction * blankSpace.value;348 const visiblePadding = visibleFraction * blankSpace.value;
202 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);349 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
203 const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);350 const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);
...@@ -206,28 +353,81 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669...@@ -206,28 +353,81 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669
206 353
207 // persistent mode: when keyboard shrinks, clamp to valid range354 // persistent mode: when keyboard shrinks, clamp to valid range
208 if (keyboardLiftBehavior === "persistent" && effective < padding.value) {355 if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
209@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) {356@@ -69,8 +74,8 @@ function useChatKeyboard(scrollViewRef, options) {
357 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
358 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
359 } else {
360- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
361- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
362+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
363+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
364 }
365 return;
366 }
367@@ -84,8 +89,8 @@ function useChatKeyboard(scrollViewRef, options) {
368 const maxScroll = Math.max(size.value.height - layout.value.height, 0);
369 contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
370 } else {
371- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
372- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
373+ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation);
374+ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll));
375 }
376 return;
377 }
378@@ -111,12 +116,23 @@ function useChatKeyboard(scrollViewRef, options) {
379 contentOffsetY.value = scroll.value;
380 return;
381 }
382- contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding);
383+ contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation);
384+ },
385+ onMove: e => {
386+ "worklet";
387+
388+ if (freeze.value) {
389+ return;
390+ }
391+ currentHeight.value = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
392 },
393- onMove: () => {
394+ onInteractive: e => {
395 "worklet";
396
397- // iOS doesn't need per-frame updates (contentOffset handles it)
398+ if (freeze.value) {
399+ return;
400+ }
401+ currentHeight.value = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
402 },
403 onEnd: e => {
404 "worklet";
405@@ -125,9 +141,10 @@ function useChatKeyboard(scrollViewRef, options) {
406 return;
407 }
210 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);408 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
409+ currentHeight.value = effective;
211 padding.value = effective;410 padding.value = effective;
212 }411 }
213- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);412- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
214+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);413+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]);
215 return {414 return {
216 padding,415 padding,
217 currentHeight,416 currentHeight,
218diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js417diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
219index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644418index 1afa50987a8d2a5fe3f36b20945efe804d48a873..7b05d0721c50e118cf78c07913b580a31293db77 100644
220--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js419--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
221+++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js420+++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
222@@ -23,6 +23,7 @@ function useExtraContentPadding(options) {421@@ -23,6 +23,8 @@ function useExtraContentPadding(options) {
223 extraContentPadding,422 extraContentPadding,
224 keyboardPadding,423 keyboardPadding,
225 blankSpace,424 blankSpace,
226+ adjustedInsetCompensation,425+ adjustedInsetCompensation,
426+ adjustedStartInsetCompensation,
227 scroll,427 scroll,
228 layout,428 layout,
229 size,429 size,
230@@ -62,8 +63,8 @@ function useExtraContentPadding(options) {430@@ -62,8 +64,8 @@ function useExtraContentPadding(options) {
231 }431 }
232 432
233 // Compute effective delta considering blankSpace floor433 // Compute effective delta considering blankSpace floor
...@@ -238,61 +438,85 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3...@@ -238,61 +438,85 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3
238 const effectiveDelta = currentTotal - previousTotal;438 const effectiveDelta = currentTotal - previousTotal;
239 if (effectiveDelta === 0) {439 if (effectiveDelta === 0) {
240 // blankSpace absorbed the change440 // blankSpace absorbed the change
241@@ -86,7 +87,7 @@ function useExtraContentPadding(options) {441@@ -82,11 +84,12 @@ function useExtraContentPadding(options) {
242 const target = Math.min(scroll.value + effectiveDelta, maxScroll);442 const target = Math.max(scroll.value - effectiveDelta, -currentTotal);
443 scrollToTarget(target);
444 } else {
445- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0);
446- const target = Math.min(scroll.value + effectiveDelta, maxScroll);
447+ const minScroll = -adjustedStartInsetCompensation;
448+ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll);
449+ const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll));
243 scrollToTarget(target);450 scrollToTarget(target);
244 }451 }
245- }, [inverted, keyboardLiftBehavior]);452- }, [inverted, keyboardLiftBehavior]);
246+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);453+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]);
247 }454 }
248 export { useExtraContentPadding };455 export { useExtraContentPadding };
249 //# sourceMappingURL=index.js.map456 //# sourceMappingURL=index.js.map
250\ No newline at end of file
251diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts457diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
252index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644458index a036b431f03efb9d5379527c17db6fce62bfee09..5e56bf7edec9161698a745e8e6292bfccd68d3db 100644
253--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts459--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts
254+++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts460+++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
255@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = {461@@ -86,6 +86,10 @@ export type KeyboardChatScrollViewProps = {
256 * Default is `undefined` (equivalent to `0` — no minimum floor).462 * Default is `undefined` (equivalent to `0` — no minimum floor).
257 */463 */
258 blankSpace?: SharedValue<number>;464 blankSpace?: SharedValue<number>;
259+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */465+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
260+ adjustedInsetCompensation?: number;466+ adjustedInsetCompensation?: number;
467+ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */
468+ adjustedStartInsetCompensation?: number;
261 /**469 /**
262 * Fires whenever the effective content inset changes — the static `contentInset`470 * Fires whenever the effective content inset changes — the static `contentInset`
263 * prop combined with the dynamic keyboard-driven padding.471 * prop combined with the dynamic keyboard-driven padding.
472diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
473index 2b51cc4737b4a05b5396da31e67e54177f43c498..3acec1fbddee6227915590a6c8d449757cb4d7b2 100644
474--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
475+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
476@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH
477 * computeIOSContentOffset(100, 300, 1000, 800, false); // 400
478 * ```
479 */
480-export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number;
481+export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number;
264diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts482diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
265index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644483index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..fdd83953271123be6caefaa786c469f0d92cce2b 100644
266--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts484--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
267+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts485+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
268@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = {486@@ -9,6 +9,10 @@ type UseChatKeyboardOptions = {
269 blankSpace: SharedValue<number>;487 blankSpace: SharedValue<number>;
270 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */488 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
271 extraContentPadding: SharedValue<number>;489 extraContentPadding: SharedValue<number>;
272+ /** Safe-area extra beyond raw contentInset. Offset math only. */490+ /** Safe-area extra beyond raw contentInset. Offset math only. */
273+ adjustedInsetCompensation: number;491+ adjustedInsetCompensation: number;
492+ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */
493+ adjustedStartInsetCompensation: number;
274 };494 };
275 type UseChatKeyboardReturn = {495 type UseChatKeyboardReturn = {
276 /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */496 /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */
277diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts497diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
278index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644498index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..45b387446587fd3e611fa02a01534bb5d9fa6bbd 100644
279--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts499--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
280+++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts500+++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
281@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = {501@@ -8,6 +8,10 @@ type UseExtraContentPaddingOptions = {
282 keyboardPadding: SharedValue<number>;502 keyboardPadding: SharedValue<number>;
283 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */503 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
284 blankSpace: SharedValue<number>;504 blankSpace: SharedValue<number>;
285+ /** Safe-area extra beyond raw contentInset. Offset math only. */505+ /** Safe-area extra beyond raw contentInset. Offset math only. */
286+ adjustedInsetCompensation: number;506+ adjustedInsetCompensation: number;
507+ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */
508+ adjustedStartInsetCompensation: number;
287 /** Current vertical scroll offset. */509 /** Current vertical scroll offset. */
288 scroll: SharedValue<number>;510 scroll: SharedValue<number>;
289 /** Visible viewport dimensions. */511 /** Visible viewport dimensions. */
290diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx512diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx
291index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644513index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..277a1ecada428ffbae517d6edc720d029c8ecc10 100644
292--- a/src/components/KeyboardChatScrollView/index.tsx514--- a/src/components/KeyboardChatScrollView/index.tsx
293+++ b/src/components/KeyboardChatScrollView/index.tsx515+++ b/src/components/KeyboardChatScrollView/index.tsx
294@@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react";516@@ -1,7 +1,9 @@
295 import { StyleSheet } from "react-native";517 import React, { forwardRef, useCallback, useMemo } from "react";
518-import { StyleSheet } from "react-native";
519+import { Platform, StyleSheet } from "react-native";
296 import {520 import {
297 makeMutable,521 makeMutable,
298+ runOnJS,522+ runOnJS,
...@@ -300,11 +524,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d...@@ -300,11 +524,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
300 useAnimatedRef,524 useAnimatedRef,
301 useAnimatedStyle,525 useAnimatedStyle,
302 useDerivedValue,526 useDerivedValue,
303@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef<527@@ -35,9 +37,12 @@ const KeyboardChatScrollView = forwardRef<
304 offset = 0,528 offset = 0,
305 extraContentPadding = ZERO_CONTENT_PADDING,529 extraContentPadding = ZERO_CONTENT_PADDING,
306 blankSpace = ZERO_BLANK_SPACE,530 blankSpace = ZERO_BLANK_SPACE,
307+ adjustedInsetCompensation = 0,531+ adjustedInsetCompensation = 0,
532+ adjustedStartInsetCompensation = 0,
308 applyWorkaroundForContentInsetHitTestBug = false,533 applyWorkaroundForContentInsetHitTestBug = false,
309 onLayout: onLayoutProp,534 onLayout: onLayoutProp,
310 onContentSizeChange: onContentSizeChangeProp,535 onContentSizeChange: onContentSizeChangeProp,
...@@ -312,23 +537,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d...@@ -312,23 +537,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
312 onEndVisible,537 onEndVisible,
313 ...rest538 ...rest
314 },539 },
315@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef<540@@ -64,6 +69,8 @@ const KeyboardChatScrollView = forwardRef<
316 offset,541 offset,
317 blankSpace,542 blankSpace,
318 extraContentPadding,543 extraContentPadding,
319+ adjustedInsetCompensation,544+ adjustedInsetCompensation,
545+ adjustedStartInsetCompensation,
320 });546 });
321 547
322 useExtraContentPadding({548 useExtraContentPadding({
323@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef<549@@ -71,6 +78,8 @@ const KeyboardChatScrollView = forwardRef<
324 extraContentPadding,550 extraContentPadding,
325 keyboardPadding: padding,551 keyboardPadding: padding,
326 blankSpace,552 blankSpace,
327+ adjustedInsetCompensation,553+ adjustedInsetCompensation,
554+ adjustedStartInsetCompensation,
328 scroll,555 scroll,
329 layout,556 layout,
330 size,557 size,
331@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef<558@@ -102,13 +111,40 @@ const KeyboardChatScrollView = forwardRef<
332 ),559 ),
333 );560 );
334 561
...@@ -337,19 +564,34 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d...@@ -337,19 +564,34 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
337- // scrollIndicatorInsets adjustment at the application layer.564- // scrollIndicatorInsets adjustment at the application layer.
338- const indicatorPadding = useDerivedValue(565- const indicatorPadding = useDerivedValue(
339- () => padding.value + extraContentPadding.value,566- () => padding.value + extraContentPadding.value,
567+ // iOS applies the destination contentInset and contentOffset together at
568+ // keyboard-animation start. Keep that native target behavior, but report
569+ // the presentation height so virtualized-list layout follows the keyboard.
570+ const reportedPadding = useDerivedValue(() =>
571+ Platform.OS === "ios"
572+ ? Math.min(
573+ layout.value.height,
574+ Math.max(
575+ blankSpace.value,
576+ currentHeight.value + extraContentPadding.value,
577+ ),
578+ )
579+ : totalPadding.value,
580 );
581
340+ // Mirror the effective bottom padding (keyboard + composer + blank floor)582+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
341+ // to the consumer — a virtualized list needs it in its own scroll math or583+ // to the consumer — a virtualized list needs it in its own scroll math or
342+ // its end/maintain targets point at the under-the-keyboard resting offset.584+ // its end/maintain targets point at the under-the-keyboard resting offset.
343+ useAnimatedReaction(585+ useAnimatedReaction(
344+ () => totalPadding.value,586+ () => reportedPadding.value,
345+ (current, previous) => {587+ (current, previous) => {
346+ if (onContentInsetChange && current !== previous) {588+ if (onContentInsetChange && current !== previous) {
347+ runOnJS(onContentInsetChange)({ bottom: current });589+ runOnJS(onContentInsetChange)({ bottom: current });
348+ }590+ }
349+ },591+ },
350+ [onContentInsetChange],592+ [onContentInsetChange],
351 );593+ );
352 594+
353+ // Scroll indicator inset = keyboard only (excludes extraContentPadding and595+ // Scroll indicator inset = keyboard only (excludes extraContentPadding and
354+ // blankSpace): with a floating composer the indicator track should run the596+ // blankSpace): with a floating composer the indicator track should run the
355+ // full height of the scroll view, behind the composer, like iOS Messages.597+ // full height of the scroll view, behind the composer, like iOS Messages.
...@@ -360,10 +602,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d...@@ -360,10 +602,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
360 (e: LayoutChangeEvent) => {602 (e: LayoutChangeEvent) => {
361 onLayoutInternal(e);603 onLayoutInternal(e);
362diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts604diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts
363index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644605index dd222b57bc7a71729524670bad3812f30920bd73..7be71b5460eb143ddf460ce5c87b843c66bfb2cd 100644
364--- a/src/components/KeyboardChatScrollView/types.ts606--- a/src/components/KeyboardChatScrollView/types.ts
365+++ b/src/components/KeyboardChatScrollView/types.ts607+++ b/src/components/KeyboardChatScrollView/types.ts
366@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = {608@@ -90,6 +90,17 @@ export type KeyboardChatScrollViewProps = {
367 * Default is `undefined` (equivalent to `0` — no minimum floor).609 * Default is `undefined` (equivalent to `0` — no minimum floor).
368 */610 */
369 blankSpace?: SharedValue<number>;611 blankSpace?: SharedValue<number>;
...@@ -376,22 +618,65 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb...@@ -376,22 +618,65 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb
376+ * Default is `0`.618+ * Default is `0`.
377+ */619+ */
378+ adjustedInsetCompensation?: number;620+ adjustedInsetCompensation?: number;
621+ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */
622+ adjustedStartInsetCompensation?: number;
379 /**623 /**
380 * Fires whenever the effective content inset changes — the static `contentInset`624 * Fires whenever the effective content inset changes — the static `contentInset`
381 * prop combined with the dynamic keyboard-driven padding.625 * prop combined with the dynamic keyboard-driven padding.
626diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
627index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb670b097b 100644
628--- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
629+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
630@@ -245,6 +245,7 @@ export const computeIOSContentOffset = (
631 layoutHeight: number,
632 inverted: boolean,
633 totalPaddingForMaxScroll?: number,
634+ startInsetCompensation: number = 0,
635 ): number => {
636 "worklet";
637
638@@ -262,7 +263,14 @@ export const computeIOSContentOffset = (
639 );
640 }
641
642- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
643+ const minScroll = -startInsetCompensation;
644+ const maxScroll = Math.max(
645+ contentHeight - layoutHeight + paddingForMax,
646+ minScroll,
647+ );
648
649- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
650+ return Math.min(
651+ Math.max(keyboardHeight + relativeScroll, minScroll),
652+ maxScroll,
653+ );
654 };
382diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts655diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
383index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644656index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..c375a8c4cc889c2fc2c0c1034788c605633192ed 100644
384--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts657--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
385+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts658+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
386@@ -43,6 +43,7 @@ function useChatKeyboard(659@@ -43,6 +43,8 @@ function useChatKeyboard(
387 offset,660 offset,
388 blankSpace,661 blankSpace,
389 extraContentPadding,662 extraContentPadding,
390+ adjustedInsetCompensation,663+ adjustedInsetCompensation,
664+ adjustedStartInsetCompensation,
391 } = options;665 } = options;
392 666
393 const padding = useSharedValue(0);667 const padding = useSharedValue(0);
394@@ -104,10 +105,12 @@ function useChatKeyboard(668@@ -79,6 +81,10 @@ function useChatKeyboard(
669 offset,
670 );
671
672+ if (e.duration === 0) {
673+ currentHeight.value = effective;
674+ }
675+
676 const atEnd = isScrollAtEnd(
677 scroll.value,
678 layout.value.height,
679@@ -104,10 +110,12 @@ function useChatKeyboard(
395 effective,680 effective,
396 minimumPaddingAbsorbed,681 minimumPaddingAbsorbed,
397 );682 );
...@@ -408,50 +693,131 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670...@@ -408,50 +693,131 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670
408 693
409 // persistent mode: when keyboard shrinks, clamp to valid range694 // persistent mode: when keyboard shrinks, clamp to valid range
410 if (695 if (
411@@ -242,7 +245,7 @@ function useChatKeyboard(696@@ -130,11 +138,11 @@ function useChatKeyboard(
697 } else {
698 const maxScroll = Math.max(
699 size.value.height - layout.value.height + actualTotalPadding,
700- 0,
701+ -adjustedStartInsetCompensation,
702 );
703
704 contentOffsetY.value = Math.max(
705- 0,
706+ -adjustedStartInsetCompensation,
707 Math.min(scroll.value, maxScroll),
708 );
709 }
710@@ -165,11 +173,11 @@ function useChatKeyboard(
711 } else {
712 const maxScroll = Math.max(
713 size.value.height - layout.value.height + actualTotalPadding,
714- 0,
715+ -adjustedStartInsetCompensation,
716 );
717
718 contentOffsetY.value = Math.max(
719- 0,
720+ -adjustedStartInsetCompensation,
721 Math.min(scroll.value, maxScroll),
722 );
723 }
724@@ -219,12 +227,34 @@ function useChatKeyboard(
725 layout.value.height,
726 inverted,
727 actualTotalPadding,
728+ adjustedStartInsetCompensation,
729 );
730 },
731- onMove: () => {
732+ onMove: (e) => {
733 "worklet";
734
735- // iOS doesn't need per-frame updates (contentOffset handles it)
736+ if (freeze.value) {
737+ return;
738+ }
739+
740+ currentHeight.value = getEffectiveHeight(
741+ e.height,
742+ targetKeyboardHeight.value,
743+ offset,
744+ );
745+ },
746+ onInteractive: (e) => {
747+ "worklet";
748+
749+ if (freeze.value) {
750+ return;
751+ }
752+
753+ currentHeight.value = getEffectiveHeight(
754+ e.height,
755+ targetKeyboardHeight.value,
756+ offset,
757+ );
758 },
759 onEnd: (e) => {
760 "worklet";
761@@ -239,10 +269,18 @@ function useChatKeyboard(
762 offset,
763 );
764
765+ currentHeight.value = effective;
412 padding.value = effective;766 padding.value = effective;
413 },767 },
414 },768 },
415- [inverted, keyboardLiftBehavior, offset, extraContentPadding],769- [inverted, keyboardLiftBehavior, offset, extraContentPadding],
416+ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation],770+ [
771+ inverted,
772+ keyboardLiftBehavior,
773+ offset,
774+ extraContentPadding,
775+ adjustedInsetCompensation,
776+ adjustedStartInsetCompensation,
777+ ],
417 );778 );
418 779
419 return {780 return {
420diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts781diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
421index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644782index 02abf5cd9490900826678175462b234e07e30e92..4ee0ddd65e0b105568ab43b73997af4be8adae00 100644
422--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts783--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
423+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts784+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
424@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = {785@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = {
425 blankSpace: SharedValue<number>;786 blankSpace: SharedValue<number>;
426 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */787 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
427 extraContentPadding: SharedValue<number>;788 extraContentPadding: SharedValue<number>;
428+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */789+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
429+ adjustedInsetCompensation: number;790+ adjustedInsetCompensation: number;
791+ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */
792+ adjustedStartInsetCompensation: number;
430 };793 };
431 794
432 type UseChatKeyboardReturn = {795 type UseChatKeyboardReturn = {
433diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts796diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
434index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644797index 833acbe78f1b1245251ddd3431d6546decdd0ade..8f7c10cb6ab392f10df4ca43549b89c25844f0b8 100644
435--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts798--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
436+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts799+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
437@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = {800@@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = {
438 keyboardPadding: SharedValue<number>;801 keyboardPadding: SharedValue<number>;
439 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */802 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
440 blankSpace: SharedValue<number>;803 blankSpace: SharedValue<number>;
441+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */804+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
442+ adjustedInsetCompensation: number;805+ adjustedInsetCompensation: number;
806+ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */
807+ adjustedStartInsetCompensation: number;
443 /** Current vertical scroll offset. */808 /** Current vertical scroll offset. */
444 scroll: SharedValue<number>;809 scroll: SharedValue<number>;
445 /** Visible viewport dimensions. */810 /** Visible viewport dimensions. */
446@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {811@@ -49,6 +53,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
447 extraContentPadding,812 extraContentPadding,
448 keyboardPadding,813 keyboardPadding,
449 blankSpace,814 blankSpace,
450+ adjustedInsetCompensation,815+ adjustedInsetCompensation,
816+ adjustedStartInsetCompensation,
451 scroll,817 scroll,
452 layout,818 layout,
453 size,819 size,
454@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {820@@ -97,14 +103,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
455 }821 }
456 822
457 // Compute effective delta considering blankSpace floor823 // Compute effective delta considering blankSpace floor
...@@ -472,12 +838,32 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15...@@ -472,12 +838,32 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15
472 const effectiveDelta = currentTotal - previousTotal;838 const effectiveDelta = currentTotal - previousTotal;
473 839
474 if (effectiveDelta === 0) {840 if (effectiveDelta === 0) {
475@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {841@@ -137,16 +141,25 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
842
843 scrollToTarget(target);
844 } else {
845+ const minScroll = -adjustedStartInsetCompensation;
846 const maxScroll = Math.max(
847 size.value.height - layout.value.height + currentTotal,
848- 0,
849+ minScroll,
850+ );
851+ const target = Math.max(
852+ minScroll,
853+ Math.min(scroll.value + effectiveDelta, maxScroll),
854 );
855- const target = Math.min(scroll.value + effectiveDelta, maxScroll);
856
476 scrollToTarget(target);857 scrollToTarget(target);
477 }858 }
478 },859 },
479- [inverted, keyboardLiftBehavior],860- [inverted, keyboardLiftBehavior],
480+ [inverted, keyboardLiftBehavior, adjustedInsetCompensation],861+ [
862+ inverted,
863+ keyboardLiftBehavior,
864+ adjustedInsetCompensation,
865+ adjustedStartInsetCompensation,
866+ ],
481 );867 );
482 }868 }
483 869
pnpm-lock.yaml+8-8
...@@ -86,13 +86,13 @@ patchedDependencies:...@@ -86,13 +86,13 @@ patchedDependencies:
86 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b86 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
87 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc287 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
88 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca36888 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
89 '@legendapp/list@3.3.5': 064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a7470689 '@legendapp/list@3.3.5': f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b
90 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa90 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
91 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd91 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
92 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e229555292 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
93 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb693 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
94 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d394 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
95 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae00895 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
96 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b608842267596 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
97 react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac8400697 react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006
98 uniwind@1.11.0: 329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e43719698 uniwind@1.11.0: 329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196
...@@ -228,7 +228,7 @@ importers:...@@ -228,7 +228,7 @@ importers:
228 version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)228 version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
229 '@legendapp/list':229 '@legendapp/list':
230 specifier: 'catalog:'230 specifier: 'catalog:'
231 version: 3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)231 version: 3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
232 '@noble/curves':232 '@noble/curves':
233 specifier: 'catalog:'233 specifier: 'catalog:'
234 version: 1.9.1234 version: 1.9.1
...@@ -402,7 +402,7 @@ importers:...@@ -402,7 +402,7 @@ importers:
402 version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)402 version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
403 react-native-keyboard-controller:403 react-native-keyboard-controller:
404 specifier: 1.21.13404 specifier: 1.21.13
405 version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)405 version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
406 react-native-nitro-markdown:406 react-native-nitro-markdown:
407 specifier: ^0.5.0407 specifier: ^0.5.0
408 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)408 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
...@@ -560,7 +560,7 @@ importers:...@@ -560,7 +560,7 @@ importers:
560 version: 0.9.0560 version: 0.9.0
561 '@legendapp/list':561 '@legendapp/list':
562 specifier: 'catalog:'562 specifier: 'catalog:'
563 version: 3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)563 version: 3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
564 '@lexical/react':564 '@lexical/react':
565 specifier: ^0.41.0565 specifier: ^0.41.0
566 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)566 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
...@@ -12828,7 +12828,7 @@ snapshots:...@@ -12828,7 +12828,7 @@ snapshots:
12828 dependencies:12828 dependencies:
12829 jsbi: 4.3.212829 jsbi: 4.3.2
1283012830
12831 '@legendapp/list@3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':12831 '@legendapp/list@3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12832 dependencies:12832 dependencies:
12833 react: 19.2.312833 react: 19.2.3
12834 use-sync-external-store: 1.6.0(react@19.2.3)12834 use-sync-external-store: 1.6.0(react@19.2.3)
...@@ -12836,7 +12836,7 @@ snapshots:...@@ -12836,7 +12836,7 @@ snapshots:
12836 react-dom: 19.2.3(react@19.2.3)12836 react-dom: 19.2.3(react@19.2.3)
12837 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)12837 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1283812838
12839 '@legendapp/list@3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':12839 '@legendapp/list@3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12840 dependencies:12840 dependencies:
12841 react: 19.2.612841 react: 19.2.6
12842 use-sync-external-store: 1.6.0(react@19.2.6)12842 use-sync-external-store: 1.6.0(react@19.2.6)
...@@ -19356,7 +19356,7 @@ snapshots:...@@ -19356,7 +19356,7 @@ snapshots:
19356 react: 19.2.319356 react: 19.2.3
19357 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)19357 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1935819358
19359 react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):19359 react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
19360 dependencies:19360 dependencies:
19361 react: 19.2.319361 react: 19.2.3
19362 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)19362 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)