| author | |
| committer | |
| log | f15680bd3c08a154bacb9b4d163cc8608a310970 |
| tree | 680e35715f2ea10d0d24cca736b1004e0d85c1c8 |
| parent | 2daff8c25adf701fddd062ae93b94cc57d420ec2 |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 2978 insertions(+), 894 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+178-119| ... | ... | @@ -9,7 +9,16 @@ import type { |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 11 | 11 | import type { ReactNode } from "react"; |
| 12 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | |
| 12 | import { | |
| 13 | memo, | |
| 14 | useCallback, | |
| 15 | useEffect, | |
| 16 | useLayoutEffect, | |
| 17 | useMemo, | |
| 18 | useRef, | |
| 19 | useState, | |
| 20 | type RefObject, | |
| 21 | } from "react"; | |
| 13 | 22 | import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native"; |
| 14 | 23 | import ImageViewing from "react-native-image-viewing"; |
| 15 | 24 | import Animated, { |
| ... | ... | @@ -18,6 +27,9 @@ import Animated, { |
| 18 | 27 | FadeOut, |
| 19 | 28 | FadeOutDown, |
| 20 | 29 | LinearTransition, |
| 30 | useAnimatedStyle, | |
| 31 | useSharedValue, | |
| 32 | withTiming, | |
| 21 | 33 | } from "react-native-reanimated"; |
| 22 | 34 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 23 | 35 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| ... | ... | @@ -31,7 +43,6 @@ import { |
| 31 | 43 | ComposerInlineControl, |
| 32 | 44 | ComposerToolbarButton, |
| 33 | 45 | ComposerToolbarRow, |
| 34 | ComposerToolbarScroller, | |
| 35 | 46 | } from "../../components/ComposerToolbar"; |
| 36 | 47 | import { ControlPill } from "../../components/ControlPill"; |
| 37 | 48 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| ... | ... | @@ -104,14 +115,17 @@ export interface ThreadComposerProps { |
| 104 | 115 | * iOS 26+ devices and keeps the existing opaque fallback elsewhere. |
| 105 | 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, | |
| 108 | // toolbar, and siblings move together instead of popping between layouts. | |
| 118 | // The bottom-anchored dock position and clipped surface height use the same | |
| 119 | // transition so the card grows upward without exposing its final-size content. | |
| 109 | 120 | // Android gets NO layout transition: the composer rides the keyboard via |
| 110 | 121 | // KeyboardStickyView (frame-synced to the IME), and a time-based morph |
| 111 | 122 | // running alongside that translate reads as jitter. Snapping the layout and |
| 112 | 123 | // letting the keyboard-synced slide be the only motion looks native there. |
| 113 | const COMPOSER_LAYOUT_TRANSITION = | |
| 114 | Platform.OS === "android" ? undefined : LinearTransition.duration(220); | |
| 124 | export const COMPOSER_TRANSITION_DURATION_MS = 220; | |
| 125 | export const COMPOSER_LAYOUT_TRANSITION = | |
| 126 | Platform.OS === "android" | |
| 127 | ? undefined | |
| 128 | : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS); | |
| 115 | 129 | |
| 116 | 130 | export function ComposerSurface(props: { |
| 117 | 131 | readonly children: ReactNode; |
| ... | ... | @@ -119,29 +133,53 @@ export function ComposerSurface(props: { |
| 119 | 133 | /** Existing thread composers morph between pill and card layouts. */ |
| 120 | 134 | readonly animateLayout?: boolean; |
| 121 | 135 | }) { |
| 122 | // A box shadow follows the rounded surface even when the glass is transparent. | |
| 123 | // Keep it outside the clipped content so the shadow can extend past the edge. | |
| 136 | const targetBorderRadius = | |
| 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 | 153 | return ( |
| 125 | 154 | <Animated.View |
| 126 | 155 | className="shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" |
| 127 | 156 | layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION} |
| 128 | style={{ | |
| 129 | borderRadius: props.style.borderRadius, | |
| 130 | // Android versions before 9 do not support outset box shadows. | |
| 131 | elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, | |
| 132 | }} | |
| 157 | style={[ | |
| 158 | animatedShapeStyle, | |
| 159 | { | |
| 160 | overflow: "hidden", | |
| 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 | 166 | <GlassSurface |
| 135 | 167 | chrome="none" |
| 136 | 168 | fallbackClassName="border border-border bg-card-translucent" |
| 137 | 169 | glassEffectStyle="regular" |
| 138 | 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 | 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 | 181 | </GlassSurface> |
| 182 | <View style={props.style}>{props.children}</View> | |
| 145 | 183 | </Animated.View> |
| 146 | 184 | ); |
| 147 | 185 | } |
| ... | ... | @@ -254,9 +292,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 254 | 292 | const isExpanded = isFocused || settingsSheetPresentation.isActive; |
| 255 | 293 | const canSend = hasContent; |
| 256 | 294 | |
| 257 | // Notify the parent from the derived value, not focus events: the parent | |
| 258 | // sizes the feed inset from this, and blur-during-sheet would otherwise | |
| 259 | // report collapsed while the composer still renders expanded. | |
| 295 | // Keep the parent's feed inset synchronized while the settings sheet keeps | |
| 296 | // the composer expanded between editor focus events. | |
| 260 | 297 | useEffect(() => { |
| 261 | 298 | onExpandedChange?.(isExpanded); |
| 262 | 299 | }, [isExpanded, onExpandedChange]); |
| ... | ... | @@ -279,13 +316,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 279 | 316 | const onEditorFocusChange = props.onEditorFocusChange; |
| 280 | 317 | const handleFocus = useCallback(() => { |
| 281 | 318 | setIsFocused(true); |
| 319 | onExpandedChange?.(true); | |
| 282 | 320 | onEditorFocusChange?.(true); |
| 283 | }, [onEditorFocusChange]); | |
| 321 | }, [onEditorFocusChange, onExpandedChange]); | |
| 284 | 322 | |
| 285 | 323 | const handleBlur = useCallback(() => { |
| 286 | 324 | setIsFocused(false); |
| 325 | if (!settingsSheetPresentation.isActive) { | |
| 326 | onExpandedChange?.(false); | |
| 327 | } | |
| 287 | 328 | onEditorFocusChange?.(false); |
| 288 | }, [onEditorFocusChange]); | |
| 329 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); | |
| 289 | 330 | const showStopAction = |
| 290 | 331 | props.selectedThread.session?.status === "running" || |
| 291 | 332 | props.selectedThread.session?.status === "starting"; |
| ... | ... | @@ -445,7 +486,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 445 | 486 | return ( |
| 446 | 487 | <Animated.View |
| 447 | 488 | className="px-4" |
| 448 | layout={COMPOSER_LAYOUT_TRANSITION} | |
| 449 | 489 | style={{ |
| 450 | 490 | paddingTop: isExpanded ? 8 : 6, |
| 451 | 491 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), |
| ... | ... | @@ -460,7 +500,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 460 | 500 | /> |
| 461 | 501 | <Animated.View |
| 462 | 502 | className="relative w-full self-center" |
| 463 | layout={COMPOSER_LAYOUT_TRANSITION} | |
| 464 | 503 | style={{ maxWidth: props.contentMaxWidth }} |
| 465 | 504 | > |
| 466 | 505 | {composerMenu.trigger && composerMenu.items.length > 0 ? ( |
| ... | ... | @@ -493,12 +532,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 493 | 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 | 538 | overflow: "hidden" as const, |
| 498 | flexDirection: "row" as const, | |
| 499 | alignItems: "center" as const, | |
| 500 | paddingLeft: 18, | |
| 501 | paddingRight: 5, | |
| 539 | paddingHorizontal: 14, | |
| 502 | 540 | paddingVertical: 5, |
| 503 | 541 | } |
| 504 | 542 | } |
| ... | ... | @@ -518,95 +556,124 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 518 | 556 | </Animated.View> |
| 519 | 557 | ) : null} |
| 520 | 558 | |
| 521 | <View className={isExpanded ? undefined : "min-w-0 flex-1"}> | |
| 522 | <ComposerEditor | |
| 523 | ref={inputRef} | |
| 524 | multiline | |
| 525 | value={props.draftMessage} | |
| 526 | skills={selectedProviderStatus?.skills ?? []} | |
| 527 | selection={composerMenu.selection} | |
| 528 | onChangeText={props.onChangeDraftMessage} | |
| 529 | onSelectionChange={composerMenu.onSelectionChange} | |
| 530 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} | |
| 531 | placeholder={props.placeholder} | |
| 532 | onFocus={handleFocus} | |
| 533 | onBlur={handleBlur} | |
| 534 | onSubmit={handleSend} | |
| 535 | scrollEnabled={isExpanded} | |
| 536 | // Android: collapsed single line centers natively (gravity) in | |
| 537 | // a pill-height box matching the send button; iOS keeps insets. | |
| 538 | singleLineCentered={!isExpanded} | |
| 539 | contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6} | |
| 540 | style={ | |
| 541 | isExpanded | |
| 542 | ? { | |
| 543 | minHeight: 72, | |
| 544 | maxHeight: 160, | |
| 545 | paddingHorizontal: 4, | |
| 546 | paddingVertical: 4, | |
| 547 | } | |
| 548 | : { | |
| 549 | height: 36, | |
| 550 | } | |
| 551 | } | |
| 552 | textStyle={{ | |
| 553 | ...bodyText, | |
| 554 | color: foregroundColor, | |
| 555 | }} | |
| 556 | /> | |
| 557 | </View> | |
| 558 | {!isExpanded && props.draftAttachments.length > 0 ? ( | |
| 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} | |
| 559 | <View className={isExpanded ? undefined : "flex-row items-center"}> | |
| 560 | <View className={isExpanded ? undefined : "min-w-0 flex-1"}> | |
| 561 | <ComposerEditor | |
| 562 | ref={inputRef} | |
| 563 | multiline | |
| 564 | value={props.draftMessage} | |
| 565 | skills={selectedProviderStatus?.skills ?? []} | |
| 566 | selection={composerMenu.selection} | |
| 567 | onChangeText={props.onChangeDraftMessage} | |
| 568 | onSelectionChange={composerMenu.onSelectionChange} | |
| 569 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} | |
| 570 | placeholder={props.placeholder} | |
| 571 | onFocus={handleFocus} | |
| 572 | onBlur={handleBlur} | |
| 573 | onSubmit={handleSend} | |
| 574 | scrollEnabled={isExpanded} | |
| 575 | // Android: collapsed single line centers natively (gravity) in | |
| 576 | // a pill-height box matching the send button; iOS keeps insets. | |
| 577 | singleLineCentered={!isExpanded} | |
| 578 | contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6} | |
| 579 | style={ | |
| 580 | isExpanded | |
| 581 | ? { | |
| 582 | minHeight: 72, | |
| 583 | maxHeight: 160, | |
| 584 | paddingHorizontal: 4, | |
| 585 | paddingVertical: 4, | |
| 586 | } | |
| 587 | : { | |
| 588 | height: 36, | |
| 589 | paddingHorizontal: 4, | |
| 590 | } | |
| 591 | } | |
| 592 | textStyle={{ | |
| 593 | ...bodyText, | |
| 594 | color: foregroundColor, | |
| 595 | }} | |
| 596 | /> | |
| 576 | 597 | </View> |
| 577 | ) : null} | |
| 578 | {!isExpanded ? ( | |
| 579 | <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}> | |
| 580 | {showStopAction ? ( | |
| 581 | <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} /> | |
| 582 | ) : ( | |
| 583 | <ControlPill | |
| 584 | icon="arrow.up" | |
| 585 | variant="primary" | |
| 586 | disabled={!canSend} | |
| 587 | onPress={handleSend} | |
| 588 | /> | |
| 589 | )} | |
| 590 | </Animated.View> | |
| 591 | ) : null} | |
| 592 | {isExpanded ? ( | |
| 598 | {!isExpanded && props.draftAttachments.length > 0 ? ( | |
| 599 | <View className="flex-row gap-1 pl-1"> | |
| 600 | {props.draftAttachments.slice(0, 3).map((image) => ( | |
| 601 | <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}> | |
| 602 | <Image | |
| 603 | source={{ uri: image.previewUri }} | |
| 604 | className="size-[30px] rounded-lg bg-subtle" | |
| 605 | resizeMode="cover" | |
| 606 | /> | |
| 607 | </Pressable> | |
| 608 | ))} | |
| 609 | {props.draftAttachments.length > 3 ? ( | |
| 610 | <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong"> | |
| 611 | <Text className="text-foreground-muted text-2xs font-t3-bold"> | |
| 612 | +{props.draftAttachments.length - 3} | |
| 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 | 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 | 650 | <ComposerToolbarButton |
| 596 | 651 | accessibilityLabel="Add attachment" |
| 597 | 652 | icon="plus" |
| 598 | 653 | onPress={() => void props.onPickDraftImages()} |
| 599 | 654 | showChevron={false} |
| 600 | 655 | /> |
| 601 | <ComposerInlineControl | |
| 602 | accessibilityLabel="Model and reasoning settings" | |
| 603 | emphasized | |
| 604 | iconNode={ | |
| 605 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 606 | } | |
| 607 | label={currentModelOption?.label ?? currentModelSelection.model} | |
| 608 | maxWidth={152} | |
| 609 | onPress={openSettings} | |
| 656 | <View className="min-w-0 flex-1" style={{ maxWidth: 152 }}> | |
| 657 | <ComposerInlineControl | |
| 658 | accessibilityLabel="Model and reasoning settings" | |
| 659 | emphasized | |
| 660 | iconNode={ | |
| 661 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 662 | } | |
| 663 | label={currentModelOption?.label ?? currentModelSelection.model} | |
| 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 | 678 | {showStopAction ? ( |
| 612 | 679 | <ComposerToolbarButton |
| ... | ... | @@ -617,17 +684,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 617 | 684 | showChevron={false} |
| 618 | 685 | /> |
| 619 | 686 | ) : null} |
| 620 | </ComposerToolbarScroller> | |
| 621 | <ComposerToolbarButton | |
| 622 | accessibilityLabel={sendLabel} | |
| 623 | icon="arrow.up" | |
| 624 | variant="primary" | |
| 625 | disabled={!canSend} | |
| 626 | onPress={handleSend} | |
| 627 | showChevron={false} | |
| 628 | /> | |
| 687 | </View> | |
| 629 | 688 | </ComposerToolbarRow> |
| 630 | ) : null} | |
| 689 | </View> | |
| 631 | 690 | </ComposerSurface> |
| 632 | 691 | |
| 633 | 692 | {/* Queue count */} |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+160-140| ... | ... | @@ -27,7 +27,6 @@ import { |
| 27 | 27 | useRef, |
| 28 | 28 | useState, |
| 29 | 29 | } from "react"; |
| 30 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | |
| 31 | 30 | import { |
| 32 | 31 | AppState, |
| 33 | 32 | Keyboard, |
| ... | ... | @@ -45,13 +44,13 @@ import Animated, { |
| 45 | 44 | Easing, |
| 46 | 45 | FadeInDown, |
| 47 | 46 | FadeOut, |
| 47 | ReduceMotion, | |
| 48 | 48 | useAnimatedReaction, |
| 49 | 49 | useSharedValue, |
| 50 | 50 | withTiming, |
| 51 | 51 | } from "react-native-reanimated"; |
| 52 | 52 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 53 | 53 | |
| 54 | import { ControlPill } from "../../components/ControlPill"; | |
| 55 | 54 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 56 | 55 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 57 | 56 | import type { StatusTone } from "../../components/StatusPill"; |
| ... | ... | @@ -67,6 +66,10 @@ import type { |
| 67 | 66 | } from "../../lib/threadActivity"; |
| 68 | 67 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 69 | 68 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 69 | import { | |
| 70 | FLOATING_WORKING_CONTROL_COVERAGE, | |
| 71 | FloatingWorkingControl, | |
| 72 | } from "./floating-working-control"; | |
| 70 | 73 | import { |
| 71 | 74 | derivePendingUserInputMaxHeight, |
| 72 | 75 | ESTIMATED_KEYBOARD_HEIGHT, |
| ... | ... | @@ -75,6 +78,8 @@ import { |
| 75 | 78 | import { |
| 76 | 79 | COMPOSER_COLLAPSED_CHROME, |
| 77 | 80 | COMPOSER_EXPANDED_CHROME, |
| 81 | COMPOSER_LAYOUT_TRANSITION, | |
| 82 | COMPOSER_TRANSITION_DURATION_MS, | |
| 78 | 83 | ThreadComposer, |
| 79 | 84 | } from "./ThreadComposer"; |
| 80 | 85 | import { ThreadFeed } from "./ThreadFeed"; |
| ... | ... | @@ -290,6 +295,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 290 | 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 | 306 | const selectedThreadFeed = props.selectedThreadFeed; |
| 294 | 307 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 295 | 308 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| ... | ... | @@ -343,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 343 | 356 | composerOverlayRef, |
| 344 | 357 | Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), |
| 345 | 358 | -nativeInsetOvercount, |
| 359 | Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0, | |
| 346 | 360 | ); |
| 347 | 361 | // The expanded questionnaire is an absolute overlay on iOS, so it never |
| 348 | 362 | // changes the measured overlay height (that constancy is what keeps the |
| ... | ... | @@ -356,6 +370,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 356 | 370 | const userInputCardProgress = useSharedValue(1); |
| 357 | 371 | const userInputInsetProgress = useSharedValue(1); |
| 358 | 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 | 382 | // Android renders the expanded card in-flow (it cannot hit-test the iOS |
| 360 | 383 | // overlay outside the bar's bounds), so its measured overlay height already |
| 361 | 384 | // includes the card — the coverage extra is iOS-only. |
| ... | ... | @@ -366,6 +389,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 366 | 389 | useAnimatedReaction( |
| 367 | 390 | () => |
| 368 | 391 | contentInsetEndAdjustment.value + |
| 392 | floatingControlCoverage.value + | |
| 369 | 393 | (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), |
| 370 | 394 | (value) => { |
| 371 | 395 | combinedContentInsetEndAdjustment.value = value; |
| ... | ... | @@ -375,20 +399,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 375 | 399 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); |
| 376 | 400 | const endFollowEnabledRef = useRef(true); |
| 377 | 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 | 407 | // The list's own corrections for these inset changes drift on short |
| 380 | 408 | // content (and the error compounds across toggles), so deterministically |
| 381 | 409 | // re-pin the end once a toggle settles: a no-op when the resting position |
| 382 | 410 | // is already right, corrective when it is not. Follow state is re-checked |
| 383 | 411 | // inside the callback — the user may grab the list during the settle |
| 384 | 412 | // window, and yanking them back would override a live gesture. |
| 385 | const scheduleUserInputRepin = useCallback( | |
| 413 | const scheduleOverlayRepin = useCallback( | |
| 386 | 414 | (delayMs: number) => { |
| 387 | if (userInputRepinTimerRef.current !== null) { | |
| 388 | clearTimeout(userInputRepinTimerRef.current); | |
| 415 | if (overlayRepinTimerRef.current !== null) { | |
| 416 | clearTimeout(overlayRepinTimerRef.current); | |
| 389 | 417 | } |
| 390 | userInputRepinTimerRef.current = setTimeout(() => { | |
| 391 | userInputRepinTimerRef.current = null; | |
| 418 | overlayRepinTimerRef.current = setTimeout(() => { | |
| 419 | overlayRepinTimerRef.current = null; | |
| 392 | 420 | if (!endFollowEnabledRef.current) { |
| 393 | 421 | return; |
| 394 | 422 | } |
| ... | ... | @@ -401,12 +429,29 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 401 | 429 | ); |
| 402 | 430 | useEffect( |
| 403 | 431 | () => () => { |
| 404 | if (userInputRepinTimerRef.current !== null) { | |
| 405 | clearTimeout(userInputRepinTimerRef.current); | |
| 432 | if (overlayRepinTimerRef.current !== null) { | |
| 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 | 455 | const handleToggleUserInputCollapsed = useCallback(() => { |
| 411 | 456 | if (activeUserInputRequestId === null) { |
| 412 | 457 | return; |
| ... | ... | @@ -416,7 +461,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 416 | 461 | userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); |
| 417 | 462 | userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); |
| 418 | 463 | setCollapsedUserInputRequestId(null); |
| 419 | scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); | |
| 464 | scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); | |
| 420 | 465 | } else { |
| 421 | 466 | // Collapsing hides the custom-answer inputs; release the keyboard with |
| 422 | 467 | // them instead of leaving it up over a dead responder. |
| ... | ... | @@ -427,11 +472,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 427 | 472 | // anchor. |
| 428 | 473 | userInputInsetProgress.value = 0; |
| 429 | 474 | setCollapsedUserInputRequestId(activeUserInputRequestId); |
| 430 | scheduleUserInputRepin(60); | |
| 475 | scheduleOverlayRepin(60); | |
| 431 | 476 | } |
| 432 | 477 | }, [ |
| 433 | 478 | activeUserInputRequestId, |
| 434 | scheduleUserInputRepin, | |
| 479 | scheduleOverlayRepin, | |
| 435 | 480 | userInputCardProgress, |
| 436 | 481 | userInputCollapsed, |
| 437 | 482 | userInputInsetProgress, |
| ... | ... | @@ -622,7 +667,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 622 | 667 | submittedMessageId={submittedMessageId} |
| 623 | 668 | contentInsetEndAdjustment={combinedContentInsetEndAdjustment} |
| 624 | 669 | contentTopInset={0} |
| 625 | contentBottomInset={estimatedOverlayHeight} | |
| 670 | contentBottomInset={ | |
| 671 | estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0) | |
| 672 | } | |
| 626 | 673 | contentMaxWidth={contentMaxWidth} |
| 627 | 674 | layoutVariant={layoutVariant} |
| 628 | 675 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} |
| ... | ... | @@ -639,137 +686,110 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 639 | 686 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 640 | 687 | {showContent ? ( |
| 641 | 688 | <KeyboardStickyView |
| 642 | // The animated keyboard height can remain stale after a dismissed | |
| 643 | // IME on both platforms. Visibility is the authoritative closed | |
| 644 | // state, so disable the translation rather than stranding the pill. | |
| 645 | enabled={isKeyboardVisible && !keyboardStateSuspect} | |
| 646 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 689 | // iOS emits a native animated height target on both will-show and | |
| 690 | // will-hide, so stay subscribed for the full transition. Android | |
| 691 | // retains its background/resume stale-state quarantine. | |
| 692 | enabled={Platform.OS === "ios" || (isKeyboardVisible && !keyboardStateSuspect)} | |
| 693 | pointerEvents="box-none" | |
| 694 | style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }} | |
| 647 | 695 | offset={{ closed: 0, opened: 0 }} |
| 648 | 696 | > |
| 649 | {/* No paddingTop here: the overlay's measured height becomes the | |
| 650 | list's bottom inset, so any padding above the pill/composer | |
| 651 | pushes the resting content floor up by the same amount. */} | |
| 652 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> | |
| 653 | {showScrollToEndButton ? ( | |
| 654 | <Animated.View | |
| 655 | pointerEvents="box-none" | |
| 656 | className="absolute -top-11 left-0 right-0 z-20 items-center" | |
| 657 | entering={FadeInDown.duration(160)} | |
| 658 | exiting={FadeOut.duration(100)} | |
| 659 | > | |
| 660 | {isLiquidGlassSupported ? ( | |
| 661 | <LiquidGlassView | |
| 662 | colorScheme={isDarkMode ? "dark" : "light"} | |
| 663 | effect="regular" | |
| 664 | interactive | |
| 665 | // Interactive glass can render larger than the requested | |
| 666 | // box (minimum touch size), so center the pill instead of | |
| 667 | // relying on it filling the glass exactly. | |
| 668 | style={{ | |
| 669 | alignItems: "center", | |
| 670 | borderRadius: 18, | |
| 671 | height: 36, | |
| 672 | justifyContent: "center", | |
| 673 | overflow: "hidden", | |
| 674 | width: 36, | |
| 675 | }} | |
| 697 | {/* The fixed sticky host gives this bottom-anchored child a stable | |
| 698 | coordinate space. Its top and height can then animate together | |
| 699 | instead of the auto-sized host jumping to Yoga's destination. */} | |
| 700 | <Animated.View | |
| 701 | layout={COMPOSER_LAYOUT_TRANSITION} | |
| 702 | pointerEvents="box-none" | |
| 703 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 704 | > | |
| 705 | {/* No paddingTop here: the overlay's measured height becomes the | |
| 706 | list's bottom inset, so any padding above the pill/composer | |
| 707 | pushes the resting content floor up by the same amount. */} | |
| 708 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> | |
| 709 | <FloatingWorkingControl | |
| 710 | colorScheme={isDarkMode ? "dark" : "light"} | |
| 711 | startedAt={floatingWorkingStartedAt} | |
| 712 | showScrollToEnd={showScrollToEndButton} | |
| 713 | onScrollToEnd={handleScrollToEnd} | |
| 714 | /> | |
| 715 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> | |
| 716 | {props.activePendingApproval || props.activePendingUserInput ? ( | |
| 717 | <Animated.View | |
| 718 | className="shrink-0 gap-3 px-4 pb-3" | |
| 719 | // The questionnaire replaces the composer, so it must pad | |
| 720 | // the home indicator the composer normally covers. | |
| 721 | style={ | |
| 722 | activeUserInputRequestId !== null | |
| 723 | ? { paddingBottom: composerBottomInset } | |
| 724 | : undefined | |
| 725 | } | |
| 726 | entering={FadeInDown.duration(220)} | |
| 727 | exiting={FadeOut.duration(140)} | |
| 676 | 728 | > |
| 677 | <ControlPill | |
| 678 | accessibilityLabel="Scroll to end" | |
| 679 | activateOnPressIn | |
| 680 | className="h-9 w-9 bg-transparent" | |
| 681 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} | |
| 682 | onPress={handleScrollToEnd} | |
| 683 | /> | |
| 684 | </LiquidGlassView> | |
| 685 | ) : ( | |
| 686 | <ControlPill | |
| 687 | accessibilityLabel="Scroll to end" | |
| 688 | activateOnPressIn | |
| 689 | className="h-9 w-9 border border-border bg-card shadow-md shadow-black/10" | |
| 690 | icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} | |
| 691 | onPress={handleScrollToEnd} | |
| 692 | /> | |
| 693 | )} | |
| 694 | </Animated.View> | |
| 695 | ) : null} | |
| 696 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> | |
| 697 | {props.activePendingApproval || props.activePendingUserInput ? ( | |
| 698 | <Animated.View | |
| 699 | className="shrink-0 gap-3 px-4 pb-3" | |
| 700 | // The questionnaire replaces the composer, so it must pad | |
| 701 | // the home indicator the composer normally covers. | |
| 702 | style={ | |
| 703 | activeUserInputRequestId !== null | |
| 704 | ? { paddingBottom: composerBottomInset } | |
| 705 | : undefined | |
| 706 | } | |
| 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 | |
| 729 | {props.activePendingApproval ? ( | |
| 730 | <PendingApprovalCard | |
| 731 | approval={props.activePendingApproval} | |
| 732 | respondingApprovalId={props.respondingApprovalId} | |
| 733 | onRespond={props.onRespondToApproval} | |
| 734 | /> | |
| 735 | ) : null} | |
| 736 | {props.activePendingUserInput ? ( | |
| 737 | <PendingUserInputCard | |
| 738 | pendingUserInput={props.activePendingUserInput} | |
| 739 | maxHeight={pendingUserInputMaxHeight} | |
| 740 | collapsed={userInputCollapsed} | |
| 741 | onToggleCollapsed={handleToggleUserInputCollapsed} | |
| 742 | onStopThread={props.onStopThread} | |
| 743 | cardProgress={userInputCardProgress} | |
| 744 | cardCoverage={userInputCardCoverage} | |
| 745 | onInputFocusChange={handleOwnedInputFocusChange} | |
| 746 | drafts={props.activePendingUserInputDrafts} | |
| 747 | answers={props.activePendingUserInputAnswers} | |
| 748 | respondingUserInputId={props.respondingUserInputId} | |
| 749 | onSelectOption={props.onSelectUserInputOption} | |
| 750 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | |
| 751 | onSubmit={props.onSubmitUserInput} | |
| 752 | /> | |
| 753 | ) : null} | |
| 754 | </Animated.View> | |
| 755 | ) : null} | |
| 756 | </View> | |
| 757 | ||
| 758 | {/* Hidden (not unmounted) while a user-input request owns the | |
| 740 | 759 | composer slot, so composer drafts and editor state survive. */} |
| 741 | <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}> | |
| 742 | <ThreadComposer | |
| 743 | editorRef={composerEditorRef} | |
| 744 | draftMessage={props.draftMessage} | |
| 745 | draftAttachments={props.draftAttachments} | |
| 746 | placeholder="Ask the repo agent, or run a command…" | |
| 747 | contentMaxWidth={contentMaxWidth} | |
| 748 | connectionState={props.connectionStateLabel} | |
| 749 | connectionError={props.connectionError} | |
| 750 | environmentLabel={props.environmentLabel} | |
| 751 | threadSyncPhase={threadSyncPhase} | |
| 752 | selectedThread={props.selectedThread} | |
| 753 | serverConfig={props.serverConfig} | |
| 754 | queueCount={props.selectedThreadQueueCount} | |
| 755 | environmentId={props.environmentId} | |
| 756 | projectCwd={props.projectWorkspaceRoot} | |
| 757 | bottomInset={composerBottomInset} | |
| 758 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 759 | onPickDraftImages={props.onPickDraftImages} | |
| 760 | onNativePasteImages={props.onNativePasteImages} | |
| 761 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 762 | onStopThread={props.onStopThread} | |
| 763 | onSendMessage={handleSendMessage} | |
| 764 | onReconnectEnvironment={props.onReconnectEnvironment} | |
| 765 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 766 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 767 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 768 | onExpandedChange={setComposerExpanded} | |
| 769 | onEditorFocusChange={handleOwnedInputFocusChange} | |
| 770 | /> | |
| 760 | <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}> | |
| 761 | <ThreadComposer | |
| 762 | editorRef={composerEditorRef} | |
| 763 | draftMessage={props.draftMessage} | |
| 764 | draftAttachments={props.draftAttachments} | |
| 765 | placeholder="Ask the repo agent, or run a command…" | |
| 766 | contentMaxWidth={contentMaxWidth} | |
| 767 | connectionState={props.connectionStateLabel} | |
| 768 | connectionError={props.connectionError} | |
| 769 | environmentLabel={props.environmentLabel} | |
| 770 | threadSyncPhase={threadSyncPhase} | |
| 771 | selectedThread={props.selectedThread} | |
| 772 | serverConfig={props.serverConfig} | |
| 773 | queueCount={props.selectedThreadQueueCount} | |
| 774 | environmentId={props.environmentId} | |
| 775 | projectCwd={props.projectWorkspaceRoot} | |
| 776 | bottomInset={composerBottomInset} | |
| 777 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 778 | onPickDraftImages={props.onPickDraftImages} | |
| 779 | onNativePasteImages={props.onNativePasteImages} | |
| 780 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 781 | onStopThread={props.onStopThread} | |
| 782 | onSendMessage={handleSendMessage} | |
| 783 | onReconnectEnvironment={props.onReconnectEnvironment} | |
| 784 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 785 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 786 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 787 | onExpandedChange={setComposerExpanded} | |
| 788 | onEditorFocusChange={handleOwnedInputFocusChange} | |
| 789 | /> | |
| 790 | </View> | |
| 771 | 791 | </View> |
| 772 | </View> | |
| 792 | </Animated.View> | |
| 773 | 793 | </KeyboardStickyView> |
| 774 | 794 | ) : null} |
| 775 | 795 | </View> |
apps/mobile/src/features/threads/ThreadFeed.tsx+122-95| ... | ... | @@ -4,7 +4,6 @@ import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | 5 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| 6 | 6 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 7 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | |
| 8 | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | 8 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 10 | 9 | import { useNavigation } from "@react-navigation/native"; |
| ... | ... | @@ -45,7 +44,13 @@ import { |
| 45 | 44 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 46 | 45 | import ImageViewing from "react-native-image-viewing"; |
| 47 | 46 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 48 | import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; | |
| 47 | import Animated, { | |
| 48 | FadeIn, | |
| 49 | FadeInUp, | |
| 50 | FadeOut, | |
| 51 | LinearTransition, | |
| 52 | type SharedValue, | |
| 53 | } from "react-native-reanimated"; | |
| 49 | 54 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 50 | 55 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 51 | 56 | import { useFontFamily } from "../../lib/useFontFamily"; |
| ... | ... | @@ -84,9 +89,7 @@ import { |
| 84 | 89 | import { |
| 85 | 90 | resolveMarkdownFontSizes, |
| 86 | 91 | resolveNativeMarkdownTypography, |
| 87 | scaledTypographyLineHeight, | |
| 88 | 92 | } from "../../lib/appearancePreferences"; |
| 89 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 90 | 93 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 91 | 94 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 92 | 95 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| ... | ... | @@ -105,6 +108,7 @@ import { |
| 105 | 108 | collapsedWorkLogHeight, |
| 106 | 109 | ThreadWorkGroupToggle, |
| 107 | 110 | ThreadWorkLog, |
| 111 | THREAD_DISCLOSURE_TRANSITION_MS, | |
| 108 | 112 | WORK_GROUP_TOGGLE_HEIGHT, |
| 109 | 113 | } from "./thread-work-log"; |
| 110 | 114 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| ... | ... | @@ -134,9 +138,10 @@ function formatMessageTime(input: string): string { |
| 134 | 138 | // so its height is a constant; a drifted value costs one correction on |
| 135 | 139 | // measure, not a persistent offset. |
| 136 | 140 | const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12) |
| 137 | // The working row has no min-height clamp — its height follows the scaled | |
| 138 | // text-xs line height (see workingRowHeight in ThreadFeed). | |
| 139 | const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) | |
| 141 | const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); | |
| 142 | const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140); | |
| 143 | const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120); | |
| 144 | const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set(); | |
| 140 | 145 | |
| 141 | 146 | // Entering animations must only play for rows born just now — LegendList |
| 142 | 147 | // remounts rows when they scroll back into view, and replaying an entrance for |
| ... | ... | @@ -985,10 +990,6 @@ function renderFeedEntry( |
| 985 | 990 | const entry = info.item; |
| 986 | 991 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; |
| 987 | 992 | |
| 988 | if (entry.type === "working") { | |
| 989 | return <WorkingTimelineRow startedAt={entry.createdAt} />; | |
| 990 | } | |
| 991 | ||
| 992 | 993 | if (entry.type === "turn-fold") { |
| 993 | 994 | return ( |
| 994 | 995 | <Pressable |
| ... | ... | @@ -1017,7 +1018,10 @@ function renderFeedEntry( |
| 1017 | 1018 | expanded={entry.expanded} |
| 1018 | 1019 | hiddenCount={entry.hiddenCount} |
| 1019 | 1020 | iconSubtleColor={iconSubtleColor} |
| 1020 | onlyToolActivities={entry.onlyToolActivities} | |
| 1021 | summary={entry.summary} | |
| 1022 | summaryKind={entry.summaryKind} | |
| 1023 | hasFailure={entry.hasFailure} | |
| 1024 | shimmer={entry.shimmer} | |
| 1021 | 1025 | onToggle={() => props.onToggleWorkGroup(entry.groupId)} |
| 1022 | 1026 | /> |
| 1023 | 1027 | ); |
| ... | ... | @@ -1180,32 +1184,6 @@ function renderFeedEntry( |
| 1180 | 1184 | ); |
| 1181 | 1185 | } |
| 1182 | 1186 | |
| 1183 | const 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 | ||
| 1209 | 1187 | function UserMessageContent(props: { |
| 1210 | 1188 | readonly text: string; |
| 1211 | 1189 | readonly markdownStyles: MarkdownStyleSet; |
| ... | ... | @@ -1479,14 +1457,14 @@ function ThreadFeedPlaceholder(props: { |
| 1479 | 1457 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1480 | 1458 | const navigation = useNavigation(); |
| 1481 | 1459 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1482 | const foldSettleFrameRef = useRef<number | null>(null); | |
| 1483 | const foldSettleSecondFrameRef = useRef<number | null>(null); | |
| 1460 | const disclosureSettleFrameRef = useRef<number | null>(null); | |
| 1461 | const disclosureSettleSecondFrameRef = useRef<number | null>(null); | |
| 1484 | 1462 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 1463 | const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null); | |
| 1485 | 1464 | const headerMaterialVisibleRef = useRef(false); |
| 1486 | 1465 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1487 | 1466 | const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1488 | 1467 | const { width: windowWidth } = useWindowDimensions(); |
| 1489 | const { appearance } = useAppearancePreferences(); | |
| 1490 | 1468 | const [viewportWidth, setViewportWidth] = useState(() => |
| 1491 | 1469 | props.layoutVariant === "split" ? 0 : windowWidth, |
| 1492 | 1470 | ); |
| ... | ... | @@ -1791,14 +1769,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1791 | 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]); | |
| 1794 | 1798 | |
| 1795 | // The empty↔filled key below remounts the list, which resets its imperative | |
| 1796 | // content-inset override — and useKeyboardChatComposerInset (mounted above | |
| 1797 | // the remount boundary) deduplicates by height, so it never re-reports the | |
| 1798 | // composer inset to the fresh instance. Re-report the measured overlay height | |
| 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. | |
| 1799 | // The empty↔filled key below remounts the list and resets its imperative | |
| 1800 | // content-inset override. Seed the fresh instance synchronously with the | |
| 1801 | // current overlay height before the scroll integration's next reaction; | |
| 1802 | // on Android the declarative contentInset floor covers this same window. | |
| 1802 | 1803 | const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; |
| 1803 | 1804 | useLayoutEffect(() => { |
| 1804 | 1805 | const bottom = props.contentInsetEndAdjustment.value; |
| ... | ... | @@ -1863,34 +1864,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1863 | 1864 | if (copyFeedbackTimeoutRef.current) { |
| 1864 | 1865 | clearTimeout(copyFeedbackTimeoutRef.current); |
| 1865 | 1866 | } |
| 1866 | if (foldSettleFrameRef.current !== null) { | |
| 1867 | cancelAnimationFrame(foldSettleFrameRef.current); | |
| 1867 | if (disclosureSettleFrameRef.current !== null) { | |
| 1868 | cancelAnimationFrame(disclosureSettleFrameRef.current); | |
| 1868 | 1869 | } |
| 1869 | if (foldSettleSecondFrameRef.current !== null) { | |
| 1870 | cancelAnimationFrame(foldSettleSecondFrameRef.current); | |
| 1870 | if (disclosureSettleSecondFrameRef.current !== null) { | |
| 1871 | cancelAnimationFrame(disclosureSettleSecondFrameRef.current); | |
| 1871 | 1872 | } |
| 1872 | 1873 | }; |
| 1873 | 1874 | }, []); |
| 1874 | 1875 | |
| 1875 | const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { | |
| 1876 | disclosureAnchorKeyRef.current = anchorKey; | |
| 1877 | setDisclosureToggleSettling(true); | |
| 1878 | if (foldSettleFrameRef.current !== null) { | |
| 1879 | cancelAnimationFrame(foldSettleFrameRef.current); | |
| 1876 | const settleDisclosureAfterLayout = useCallback(() => { | |
| 1877 | if (disclosureSettleFrameRef.current !== null) { | |
| 1878 | cancelAnimationFrame(disclosureSettleFrameRef.current); | |
| 1880 | 1879 | } |
| 1881 | if (foldSettleSecondFrameRef.current !== null) { | |
| 1882 | cancelAnimationFrame(foldSettleSecondFrameRef.current); | |
| 1880 | if (disclosureSettleSecondFrameRef.current !== null) { | |
| 1881 | cancelAnimationFrame(disclosureSettleSecondFrameRef.current); | |
| 1883 | 1882 | } |
| 1884 | foldSettleFrameRef.current = requestAnimationFrame(() => { | |
| 1885 | foldSettleSecondFrameRef.current = requestAnimationFrame(() => { | |
| 1883 | disclosureSettleFrameRef.current = requestAnimationFrame(() => { | |
| 1884 | disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { | |
| 1886 | 1885 | disclosureAnchorKeyRef.current = null; |
| 1887 | 1886 | setDisclosureToggleSettling(false); |
| 1888 | foldSettleFrameRef.current = null; | |
| 1889 | foldSettleSecondFrameRef.current = null; | |
| 1887 | disclosureSettleFrameRef.current = null; | |
| 1888 | disclosureSettleSecondFrameRef.current = null; | |
| 1890 | 1889 | }); |
| 1891 | 1890 | }); |
| 1892 | 1891 | }, []); |
| 1893 | 1892 | |
| 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 | 1913 | const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => { |
| 1895 | 1914 | const disclosureAnchorKey = disclosureAnchorKeyRef.current; |
| 1896 | 1915 | return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; |
| ... | ... | @@ -1975,11 +1994,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1975 | 1994 | // scrolling up through unmeasured content corrects each row's height as it |
| 1976 | 1995 | // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows |
| 1977 | 1996 | // exact; message rows stay undefined and use LegendList's per-type running |
| 1978 | // average once one of their type has been measured. Text-driven heights | |
| 1979 | // follow the configurable base font size via scaledTypographyLineHeight. | |
| 1980 | const workingRowHeight = | |
| 1981 | WORKING_ROW_VERTICAL_EXTRAS + | |
| 1982 | scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize); | |
| 1997 | // average once one of their type has been measured. | |
| 1983 | 1998 | const getFixedItemSize = useCallback( |
| 1984 | 1999 | (entry: ThreadFeedEntry) => { |
| 1985 | 2000 | switch (entry.type) { |
| ... | ... | @@ -1987,46 +2002,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1987 | 2002 | return TURN_FOLD_HEIGHT; |
| 1988 | 2003 | case "work-toggle": |
| 1989 | 2004 | return WORK_GROUP_TOGGLE_HEIGHT; |
| 1990 | case "working": | |
| 1991 | return workingRowHeight; | |
| 1992 | 2005 | case "activity-group": |
| 1993 | 2006 | // Expanded rows append a variable detail block — fall back to |
| 1994 | 2007 | // measurement for those groups. |
| 1995 | 2008 | return entry.activities.some((activity) => expandedWorkRows[activity.id]) |
| 1996 | 2009 | ? undefined |
| 1997 | : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize); | |
| 2010 | : collapsedWorkLogHeight(entry.activities); | |
| 1998 | 2011 | default: |
| 1999 | 2012 | return undefined; |
| 2000 | 2013 | } |
| 2001 | 2014 | }, |
| 2002 | [expandedWorkRows, workingRowHeight, appearance.baseFontSize], | |
| 2015 | [expandedWorkRows], | |
| 2003 | 2016 | ); |
| 2004 | 2017 | |
| 2005 | 2018 | const renderItem = useCallback( |
| 2006 | (info: { item: ThreadFeedEntry; index: number }) => | |
| 2007 | renderFeedEntry(info, { | |
| 2008 | environmentId: props.environmentId, | |
| 2009 | copiedRowId, | |
| 2010 | expandedWorkRows, | |
| 2011 | terminalAssistantMessageIds, | |
| 2012 | unsettledTurnId, | |
| 2013 | onCopyWorkRow, | |
| 2014 | onToggleWorkGroup, | |
| 2015 | onToggleWorkRow, | |
| 2016 | onToggleTurnFold, | |
| 2017 | onPressImage, | |
| 2018 | onMarkdownLinkPress, | |
| 2019 | renderMarkdownImage, | |
| 2020 | iconSubtleColor, | |
| 2021 | userBubbleColor, | |
| 2022 | markdownStyles, | |
| 2023 | reviewCommentColors, | |
| 2024 | reviewCommentBubbleWidth, | |
| 2025 | userBubbleMaxWidth, | |
| 2026 | skills: props.skills, | |
| 2027 | }), | |
| 2019 | (info: { item: ThreadFeedEntry; index: number }) => ( | |
| 2020 | <Animated.View | |
| 2021 | key={info.item.id} | |
| 2022 | entering={ | |
| 2023 | disclosureEnteringEntryIds.has(info.item.id) | |
| 2024 | ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION | |
| 2025 | : undefined | |
| 2026 | } | |
| 2027 | exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION} | |
| 2028 | > | |
| 2029 | {renderFeedEntry(info, { | |
| 2030 | environmentId: props.environmentId, | |
| 2031 | copiedRowId, | |
| 2032 | expandedWorkRows, | |
| 2033 | terminalAssistantMessageIds, | |
| 2034 | unsettledTurnId, | |
| 2035 | onCopyWorkRow, | |
| 2036 | onToggleWorkGroup, | |
| 2037 | onToggleWorkRow, | |
| 2038 | onToggleTurnFold, | |
| 2039 | onPressImage, | |
| 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 | 2053 | copiedRowId, |
| 2054 | disclosureEnteringEntryIds, | |
| 2030 | 2055 | expandedWorkRows, |
| 2031 | 2056 | terminalAssistantMessageIds, |
| 2032 | 2057 | unsettledTurnId, |
| ... | ... | @@ -2093,7 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2093 | 2118 | } |
| 2094 | 2119 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 2095 | 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 | 2122 | // lets its scroll math clamp programmatic scrolls to -headerInset |
| 2098 | 2123 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short |
| 2099 | 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 | 2173 | entry.type === "message" ? `message:${entry.message.role}` : entry.type |
| 2149 | 2174 | } |
| 2150 | 2175 | getFixedItemSize={getFixedItemSize} |
| 2176 | itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION} | |
| 2177 | onItemSizeChanged={handleItemSizeChanged} | |
| 2151 | 2178 | // Measure rows well before they scroll into view so estimate→actual |
| 2152 | 2179 | // corrections land offscreen instead of under the user's finger. |
| 2153 | 2180 | drawDistance={500} |
apps/mobile/src/features/threads/floating-working-control.tsx created+205| ... | ... | @@ -0,0 +1,205 @@ |
| 1 | import { | |
| 2 | isLiquidGlassSupported, | |
| 3 | LiquidGlassContainerView, | |
| 4 | LiquidGlassView, | |
| 5 | } from "@callstack/liquid-glass"; | |
| 6 | import { useEffect, useState } from "react"; | |
| 7 | import { Text as SystemText, View } from "react-native"; | |
| 8 | import Animated, { | |
| 9 | Easing, | |
| 10 | FadeIn, | |
| 11 | FadeOut, | |
| 12 | ReduceMotion, | |
| 13 | useAnimatedStyle, | |
| 14 | useSharedValue, | |
| 15 | withTiming, | |
| 16 | } from "react-native-reanimated"; | |
| 17 | import { withUniwind } from "uniwind"; | |
| 18 | ||
| 19 | import { AppText as Text } from "../../components/AppText"; | |
| 20 | import { ControlPill } from "../../components/ControlPill"; | |
| 21 | ||
| 22 | const CONTROL_HEIGHT = 44; | |
| 23 | const CONTROL_COMPOSER_GAP = 8; | |
| 24 | const GLASS_MERGE_SPACING = 12; | |
| 25 | const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); | |
| 26 | const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); | |
| 27 | const CONTROL_TIMING = { | |
| 28 | duration: 240, | |
| 29 | easing: Easing.out(Easing.cubic), | |
| 30 | reduceMotion: ReduceMotion.System, | |
| 31 | } as const; | |
| 32 | const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; | |
| 33 | ||
| 34 | const UniwindLiquidGlassView = withUniwind(LiquidGlassView); | |
| 35 | const UniwindLiquidGlassContainerView = withUniwind(LiquidGlassContainerView); | |
| 36 | const AnimatedLiquidGlassView = Animated.createAnimatedComponent(UniwindLiquidGlassView); | |
| 37 | ||
| 38 | export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; | |
| 39 | ||
| 40 | export 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 | ||
| 153 | function 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 | ||
| 178 | function 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 | ||
| 194 | function 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 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| 3 | import { LayoutAnimation, Pressable, ScrollView, View } from "react-native"; | |
| 3 | import { MaskedView } from "@expo/ui/community/masked-view"; | |
| 4 | import { useIsFocused } from "@react-navigation/native"; | |
| 5 | import { useEffect, useId, useState, type ComponentProps } from "react"; | |
| 6 | import { | |
| 7 | AccessibilityInfo, | |
| 8 | AppState, | |
| 9 | type ColorValue, | |
| 10 | Pressable, | |
| 11 | ScrollView, | |
| 12 | View, | |
| 13 | } from "react-native"; | |
| 14 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | |
| 4 | 15 | |
| 5 | 16 | import { AppText as Text } from "../../components/AppText"; |
| 6 | import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; | |
| 7 | 17 | import { cn } from "../../lib/cn"; |
| 8 | 18 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 9 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | |
| 10 | import Animated, { FadeIn } from "react-native-reanimated"; | |
| 19 | import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation"; | |
| 20 | import 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"; | |
| 11 | 34 | |
| 12 | const WORK_LOG_LAYOUT_ANIMATION = { | |
| 13 | duration: 180, | |
| 14 | create: { | |
| 15 | type: LayoutAnimation.Types.easeInEaseOut, | |
| 16 | property: LayoutAnimation.Properties.opacity, | |
| 17 | }, | |
| 18 | update: { type: LayoutAnimation.Types.easeInEaseOut }, | |
| 19 | delete: { | |
| 20 | type: LayoutAnimation.Types.easeInEaseOut, | |
| 21 | property: LayoutAnimation.Properties.opacity, | |
| 22 | }, | |
| 23 | } as const; | |
| 35 | const SHIMMER_WIDTH = 72; | |
| 36 | const SHIMMER_SWEEP_MS = 1_350; | |
| 37 | const SHIMMER_PAUSE_MS = 1_450; | |
| 38 | const SHIMMER_ICON_AND_GAP_WIDTH = 30; | |
| 39 | export const THREAD_DISCLOSURE_TRANSITION_MS = 180; | |
| 40 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); | |
| 41 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); | |
| 42 | const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); | |
| 24 | 43 | |
| 25 | function triggerDisclosureFeedback() { | |
| 26 | LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); | |
| 27 | void Haptics.selectionAsync(); | |
| 44 | function ShimmerWorkContent(props: { | |
| 45 | readonly highlighted: boolean; | |
| 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 | ||
| 81 | function 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 | } |
| 29 | 200 | |
| 30 | 201 | function stripShellWrapper(value: string): string { |
| ... | ... | @@ -79,44 +250,23 @@ function isFreshRow(createdAt: string): boolean { |
| 79 | 250 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; |
| 80 | 251 | } |
| 81 | 252 | |
| 82 | // Tool-like activities with a neutral status carry no signal worth a row. | |
| 83 | export function visibleWorkLogActivities( | |
| 84 | activities: ReadonlyArray<ThreadFeedActivity>, | |
| 85 | ): ReadonlyArray<ThreadFeedActivity> { | |
| 86 | return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral")); | |
| 87 | } | |
| 88 | ||
| 89 | 253 | // Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log |
| 90 | 254 | // 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 reaches | |
| 92 | // 23px at the 22pt maximum, under the 32px min-h-8), so row height is | |
| 93 | // deterministic. The "work log" label has no such clamp — its height follows | |
| 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. | |
| 255 | // taller than text-sm at every supported base font size, so row height is | |
| 256 | // deterministic. Values mirror the classNames below. A mismatch only costs a | |
| 257 | // one-time correction on measure. | |
| 96 | 258 | const WORK_ROW_HEIGHT = 32; // min-h-8 |
| 97 | 259 | const WORK_ROW_GAP = 1; // gap-px |
| 98 | const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label | |
| 99 | 260 | const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 |
| 100 | 261 | |
| 101 | 262 | export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) |
| 102 | 263 | |
| 103 | export function collapsedWorkLogHeight( | |
| 104 | activities: ReadonlyArray<ThreadFeedActivity>, | |
| 105 | baseFontSize: number, | |
| 106 | ): number { | |
| 107 | const rows = visibleWorkLogActivities(activities); | |
| 264 | export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number { | |
| 265 | const rows = activities; | |
| 108 | 266 | if (rows.length === 0) { |
| 109 | 267 | return 0; |
| 110 | 268 | } |
| 111 | const onlyToolRows = rows.every((row) => row.toolLike); | |
| 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 | ); | |
| 269 | return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP; | |
| 120 | 270 | } |
| 121 | 271 | |
| 122 | 272 | export function ThreadWorkLog(props: { |
| ... | ... | @@ -127,7 +277,7 @@ export function ThreadWorkLog(props: { |
| 127 | 277 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 128 | 278 | readonly onToggleRow: (rowId: string) => void; |
| 129 | 279 | }) { |
| 130 | const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ | |
| 280 | const rows = props.activities.map((activity) => ({ | |
| 131 | 281 | ...activity, |
| 132 | 282 | detail: compactActivityDetail(activity.detail), |
| 133 | 283 | })); |
| ... | ... | @@ -136,32 +286,28 @@ export function ThreadWorkLog(props: { |
| 136 | 286 | return null; |
| 137 | 287 | } |
| 138 | 288 | |
| 139 | const onlyToolRows = rows.every((row) => row.toolLike); | |
| 140 | ||
| 141 | 289 | return ( |
| 142 | 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 | 291 | <View className="gap-px"> |
| 150 | 292 | {rows.map((row) => { |
| 151 | 293 | const expanded = props.expandedRows[row.id] ?? false; |
| 152 | 294 | const canExpand = row.canExpand; |
| 153 | 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 | 297 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 298 | const failed = row.status === "failure"; | |
| 299 | const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; | |
| 156 | 300 | |
| 157 | 301 | return ( |
| 158 | 302 | <Animated.View |
| 159 | 303 | key={row.id} |
| 304 | layout={WORK_LOG_LAYOUT_TRANSITION} | |
| 305 | className="overflow-hidden" | |
| 160 | 306 | {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})} |
| 161 | 307 | > |
| 162 | 308 | <Pressable |
| 163 | 309 | accessibilityRole={canExpand ? "button" : undefined} |
| 164 | accessibilityLabel={displayText} | |
| 310 | accessibilityLabel={failed ? `${displayText}, tool call failed` : displayText} | |
| 165 | 311 | accessibilityHint={ |
| 166 | 312 | canExpand |
| 167 | 313 | ? "Double tap to show full details. Long press to copy." |
| ... | ... | @@ -171,7 +317,7 @@ export function ThreadWorkLog(props: { |
| 171 | 317 | hitSlop={4} |
| 172 | 318 | onPress={() => { |
| 173 | 319 | if (canExpand) { |
| 174 | triggerDisclosureFeedback(); | |
| 320 | void Haptics.selectionAsync(); | |
| 175 | 321 | props.onToggleRow(row.id); |
| 176 | 322 | } |
| 177 | 323 | }} |
| ... | ... | @@ -179,29 +325,41 @@ export function ThreadWorkLog(props: { |
| 179 | 325 | className="rounded-md px-0.5 py-0 active:bg-subtle" |
| 180 | 326 | > |
| 181 | 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"> | |
| 183 | <SymbolView | |
| 184 | name={workRowSymbolName(row.icon)} | |
| 185 | size={13} | |
| 186 | weight="medium" | |
| 187 | tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} | |
| 188 | type="monochrome" | |
| 328 | {row.live ? ( | |
| 329 | <ShimmeringWorkContent | |
| 330 | icon={workRowSymbolName(row.icon)} | |
| 331 | iconSubtleColor={props.iconSubtleColor} | |
| 332 | label={displayText} | |
| 333 | showIcon={showIcon} | |
| 189 | 334 | /> |
| 190 | </View> | |
| 191 | ||
| 192 | <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}> | |
| 193 | <Text | |
| 194 | className={cn( | |
| 195 | "font-t3-medium text-foreground", | |
| 196 | iconIsDestructive && "text-adaptive-rose-600-400", | |
| 197 | )} | |
| 198 | > | |
| 199 | {row.summary} | |
| 200 | </Text> | |
| 201 | {row.detail ? ( | |
| 202 | <Text className="text-foreground-muted opacity-60"> {row.detail}</Text> | |
| 203 | ) : null} | |
| 204 | </Text> | |
| 335 | ) : ( | |
| 336 | <> | |
| 337 | <View className="h-6 w-6 shrink-0 items-center justify-center"> | |
| 338 | {showIcon ? ( | |
| 339 | <SymbolView | |
| 340 | name={ | |
| 341 | failed | |
| 342 | ? { ios: "xmark", android: "close" } | |
| 343 | : workRowSymbolName(row.icon) | |
| 344 | } | |
| 345 | size={14} | |
| 346 | weight="medium" | |
| 347 | tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} | |
| 348 | type="monochrome" | |
| 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 | )} | |
| 205 | 363 | |
| 206 | 364 | <View className="shrink-0 flex-row items-center gap-px"> |
| 207 | 365 | {props.copiedRowId === row.id ? ( |
| ... | ... | @@ -223,28 +381,17 @@ export function ThreadWorkLog(props: { |
| 223 | 381 | /> |
| 224 | 382 | ) : null} |
| 225 | 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 | 384 | </View> |
| 243 | 385 | </View> |
| 244 | 386 | </Pressable> |
| 245 | 387 | |
| 246 | 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 | 395 | <ScrollView |
| 249 | 396 | nestedScrollEnabled |
| 250 | 397 | directionalLockEnabled |
| ... | ... | @@ -259,7 +406,7 @@ export function ThreadWorkLog(props: { |
| 259 | 406 | {fullDetail} |
| 260 | 407 | </Text> |
| 261 | 408 | </ScrollView> |
| 262 | </View> | |
| 409 | </Animated.View> | |
| 263 | 410 | ) : null} |
| 264 | 411 | </Animated.View> |
| 265 | 412 | ); |
| ... | ... | @@ -273,27 +420,24 @@ export function ThreadWorkGroupToggle(props: { |
| 273 | 420 | readonly expanded: boolean; |
| 274 | 421 | readonly hiddenCount: number; |
| 275 | 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 | 427 | readonly onToggle: () => void; |
| 278 | 428 | }) { |
| 279 | const noun = props.onlyToolActivities | |
| 280 | ? props.hiddenCount === 1 | |
| 281 | ? "tool call" | |
| 282 | : "tool calls" | |
| 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"; | |
| 429 | const accessibilityLabel = props.hasFailure | |
| 430 | ? `${props.summary}, tool call failed` | |
| 431 | : props.summary; | |
| 432 | const icon = toolGroupSummarySymbolName(props.summaryKind); | |
| 290 | 433 | |
| 291 | 434 | return ( |
| 292 | 435 | <View className="-mx-1 mb-1 px-1 py-0"> |
| 293 | 436 | <Pressable |
| 294 | 437 | accessibilityRole="button" |
| 295 | 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 | 441 | hitSlop={4} |
| 298 | 442 | onPress={() => { |
| 299 | 443 | void Haptics.selectionAsync(); |
| ... | ... | @@ -301,22 +445,64 @@ export function ThreadWorkGroupToggle(props: { |
| 301 | 445 | }} |
| 302 | 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"> | |
| 305 | <SymbolView | |
| 306 | name={ | |
| 307 | props.expanded | |
| 308 | ? { ios: "chevron.up", android: "keyboard_arrow_up" } | |
| 309 | : { ios: "chevron.down", android: "keyboard_arrow_down" } | |
| 310 | } | |
| 311 | size={12} | |
| 312 | tintColor={props.iconSubtleColor} | |
| 313 | type="monochrome" | |
| 448 | {props.shimmer ? ( | |
| 449 | <ShimmeringWorkContent | |
| 450 | icon={icon} | |
| 451 | iconSubtleColor={props.iconSubtleColor} | |
| 452 | label={props.summary} | |
| 453 | showIcon | |
| 314 | 454 | /> |
| 315 | </View> | |
| 316 | <Text className="font-t3-medium text-xs text-foreground opacity-80"> | |
| 317 | {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`} | |
| 318 | </Text> | |
| 455 | ) : ( | |
| 456 | <> | |
| 457 | <View className="h-6 w-6 items-center justify-center"> | |
| 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 | 480 | </Pressable> |
| 320 | 481 | </View> |
| 321 | 482 | ); |
| 322 | 483 | } |
| 484 | ||
| 485 | function 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 | 411 | |
| 412 | 412 | expect(group.activities).toHaveLength(1); |
| 413 | 413 | expect(group.activities[0]).toMatchObject({ |
| 414 | id: "tool-completed", | |
| 415 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 414 | id: "tool-updated", | |
| 415 | createdAt: "2026-04-01T00:00:01.000Z", | |
| 416 | 416 | turnId: "turn-1", |
| 417 | 417 | summary: "Run tests", |
| 418 | 418 | detail: "bun run test", |
| ... | ... | @@ -592,7 +592,7 @@ describe("buildThreadFeed", () => { |
| 592 | 592 | expect(expanded.map((entry) => entry.id)).toEqual([ |
| 593 | 593 | "assistant-first", |
| 594 | 594 | "turn-fold:turn-1", |
| 595 | "tool-completed", | |
| 595 | "work-toggle:work-group:tool-completed", | |
| 596 | 596 | "assistant-final", |
| 597 | 597 | ]); |
| 598 | 598 | }); |
| ... | ... | @@ -745,6 +745,20 @@ describe("buildThreadFeed", () => { |
| 745 | 745 | assistantMessageId: null, |
| 746 | 746 | }, |
| 747 | 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 | 762 | makeActivity({ |
| 749 | 763 | id: EventId.make("tool-failed"), |
| 750 | 764 | kind: "tool.completed", |
| ... | ... | @@ -763,25 +777,26 @@ describe("buildThreadFeed", () => { |
| 763 | 777 | }); |
| 764 | 778 | |
| 765 | 779 | const feed = buildThreadFeed(thread); |
| 766 | expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); | |
| 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([ | |
| 780 | expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toMatchObject([ | |
| 778 | 781 | { |
| 779 | type: "working", | |
| 780 | id: "working-indicator-row", | |
| 781 | createdAt: startedAt, | |
| 782 | type: "work-toggle", | |
| 783 | summary: "Ran 2 commands", | |
| 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 | }); |
| 786 | 801 | |
| 787 | 802 | it("models work-log overflow as list rows", () => { |
| ... | ... | @@ -801,6 +816,14 @@ describe("buildThreadFeed", () => { |
| 801 | 816 | icon: "command", |
| 802 | 817 | toolLike: true, |
| 803 | 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 | 828 | const feed: ThreadFeedEntry[] = [ |
| 806 | 829 | { |
| ... | ... | @@ -818,26 +841,216 @@ describe("buildThreadFeed", () => { |
| 818 | 841 | ]; |
| 819 | 842 | |
| 820 | 843 | const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); |
| 821 | expect(collapsed.map((entry) => entry.id)).toEqual(["activity-3", "work-toggle:work-group-1"]); | |
| 822 | expect(collapsed[1]).toMatchObject({ | |
| 844 | expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-1"]); | |
| 845 | expect(collapsed[0]).toMatchObject({ | |
| 823 | 846 | type: "work-toggle", |
| 824 | groupId: "work-group-1", | |
| 825 | hiddenCount: 2, | |
| 847 | groupId: "work-group:activity-1", | |
| 848 | hiddenCount: 3, | |
| 826 | 849 | expanded: false, |
| 850 | summary: "Ran 3 commands", | |
| 827 | 851 | }); |
| 828 | 852 | |
| 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 | 859 | expect(expanded.map((entry) => entry.id)).toEqual([ |
| 860 | "work-toggle:work-group:activity-1", | |
| 831 | 861 | "activity-1", |
| 832 | 862 | "activity-2", |
| 833 | 863 | "activity-3", |
| 834 | "work-toggle:work-group-1", | |
| 835 | 864 | ]); |
| 836 | expect(expanded.at(-1)).toMatchObject({ | |
| 865 | expect(expanded[0]).toMatchObject({ | |
| 837 | 866 | type: "work-toggle", |
| 838 | 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 | }); |
| 842 | 1055 | |
| 843 | 1056 | describe("quiet timeline: nested agents", () => { |
| ... | ... | @@ -873,5 +1086,8 @@ describe("quiet timeline: nested agents", () => { |
| 873 | 1086 | ); |
| 874 | 1087 | expect(ids).toContain("nested-done"); |
| 875 | 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 | 13 | UserInputQuestion, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 16 | import { | |
| 17 | normalizeCompactToolLabel, | |
| 18 | omitSupersededLifecycleMarkers, | |
| 19 | summarizeToolGroup, | |
| 20 | toolGroupSummaryKind, | |
| 21 | type ToolGroupSummaryKind, | |
| 22 | } from "@t3tools/client-runtime/work-log/presentation"; | |
| 23 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | |
| 16 | 24 | |
| 17 | 25 | import * as Arr from "effect/Array"; |
| 18 | 26 | import * as Order from "effect/Order"; |
| ... | ... | @@ -65,13 +73,15 @@ export interface ThreadFeedActivity { |
| 65 | 73 | | "zap"; |
| 66 | 74 | readonly toolLike: boolean; |
| 67 | 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 | } |
| 69 | 81 | |
| 70 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | |
| 71 | ||
| 72 | 82 | type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; |
| 73 | 83 | |
| 74 | interface WorkLogEntry { | |
| 84 | export interface WorkLogEntry { | |
| 75 | 85 | id: string; |
| 76 | 86 | createdAt: string; |
| 77 | 87 | turnId: TurnId | null; |
| ... | ... | @@ -85,11 +95,14 @@ interface WorkLogEntry { |
| 85 | 95 | itemType?: ToolLifecycleItemType; |
| 86 | 96 | requestKind?: PendingApproval["requestKind"]; |
| 87 | 97 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; |
| 98 | sourceActivityKind?: OrchestrationThreadActivity["kind"]; | |
| 99 | toolCallId?: string; | |
| 100 | agentSpawn?: boolean; | |
| 88 | 101 | toolData?: unknown; |
| 89 | 102 | } |
| 90 | 103 | |
| 91 | 104 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| 92 | activityKind: OrchestrationThreadActivity["kind"]; | |
| 105 | sourceActivityKind: OrchestrationThreadActivity["kind"]; | |
| 93 | 106 | collapseKey?: string; |
| 94 | 107 | /** Grouping key for subagent lifecycle rows (one row per agent). */ |
| 95 | 108 | taskId?: string; |
| ... | ... | @@ -112,11 +125,6 @@ type RawThreadFeedEntry = |
| 112 | 125 | |
| 113 | 126 | export type ThreadFeedEntry = |
| 114 | 127 | | Extract<RawThreadFeedEntry, { type: "message" }> |
| 115 | | { | |
| 116 | readonly type: "working"; | |
| 117 | readonly id: string; | |
| 118 | readonly createdAt: string; | |
| 119 | } | |
| 120 | 128 | | { |
| 121 | 129 | readonly type: "activity-group"; |
| 122 | 130 | readonly id: string; |
| ... | ... | @@ -132,7 +140,11 @@ export type ThreadFeedEntry = |
| 132 | 140 | readonly groupId: string; |
| 133 | 141 | readonly hiddenCount: number; |
| 134 | 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 | 150 | readonly type: "turn-fold"; |
| ... | ... | @@ -412,8 +424,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 412 | 424 | : activity.tone === "approval" |
| 413 | 425 | ? "info" |
| 414 | 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 | 437 | const itemType = extractWorkLogItemType(payload); |
| 418 | 438 | const requestKind = extractWorkLogRequestKind(payload); |
| 419 | 439 | if ( |
| ... | ... | @@ -472,12 +492,13 @@ function collapseDerivedWorkLogEntries( |
| 472 | 492 | // Subagent rows collapse by identity, not adjacency (quiet-timeline |
| 473 | 493 | // guarantee; mirrors web's session-logic). |
| 474 | 494 | const taskRowIndex = new Map<string, number>(); |
| 495 | const toolLifecycleRowIndex = new Map<string, number>(); | |
| 475 | 496 | for (const entry of entries) { |
| 476 | 497 | const isTaskRow = |
| 477 | 498 | entry.taskId !== undefined && |
| 478 | (entry.activityKind === "task.progress" || | |
| 479 | entry.activityKind === "task.completed" || | |
| 480 | entry.activityKind === "task.updated"); | |
| 499 | (entry.sourceActivityKind === "task.progress" || | |
| 500 | entry.sourceActivityKind === "task.completed" || | |
| 501 | entry.sourceActivityKind === "task.updated"); | |
| 481 | 502 | if (isTaskRow && entry.taskId !== undefined) { |
| 482 | 503 | const existingIndex = taskRowIndex.get(entry.taskId); |
| 483 | 504 | if (existingIndex !== undefined) { |
| ... | ... | @@ -488,30 +509,78 @@ function collapseDerivedWorkLogEntries( |
| 488 | 509 | collapsed.push(entry); |
| 489 | 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 | 526 | const previous = collapsed.at(-1); |
| 492 | 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 | 535 | continue; |
| 495 | 536 | } |
| 496 | 537 | collapsed.push(entry); |
| 538 | if (lifecycleKey !== undefined) { | |
| 539 | toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); | |
| 540 | } | |
| 497 | 541 | } |
| 498 | 542 | return collapsed; |
| 499 | 543 | } |
| 500 | 544 | |
| 545 | function 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 | ||
| 501 | 555 | function shouldCollapseToolLifecycleEntries( |
| 502 | 556 | previous: DerivedWorkLogEntry, |
| 503 | 557 | next: DerivedWorkLogEntry, |
| 504 | 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 | 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 | 566 | return false; |
| 510 | 567 | } |
| 511 | if (previous.activityKind === "tool.completed") { | |
| 568 | if (previous.turnId !== next.turnId) { | |
| 512 | 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 | } |
| 516 | 585 | |
| 517 | 586 | function mergeDerivedWorkLogEntries( |
| ... | ... | @@ -527,10 +596,13 @@ function mergeDerivedWorkLogEntries( |
| 527 | 596 | const requestKind = next.requestKind ?? previous.requestKind; |
| 528 | 597 | const collapseKey = next.collapseKey ?? previous.collapseKey; |
| 529 | 598 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; |
| 599 | const toolCallId = next.toolCallId ?? previous.toolCallId; | |
| 530 | 600 | const toolData = next.toolData ?? previous.toolData; |
| 531 | 601 | return { |
| 532 | 602 | ...previous, |
| 533 | 603 | ...next, |
| 604 | id: previous.id, | |
| 605 | createdAt: previous.createdAt, | |
| 534 | 606 | ...(detail ? { detail } : {}), |
| 535 | 607 | ...(command ? { command } : {}), |
| 536 | 608 | ...(rawCommand ? { rawCommand } : {}), |
| ... | ... | @@ -540,6 +612,7 @@ function mergeDerivedWorkLogEntries( |
| 540 | 612 | ...(requestKind ? { requestKind } : {}), |
| 541 | 613 | ...(collapseKey ? { collapseKey } : {}), |
| 542 | 614 | ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), |
| 615 | ...(toolCallId ? { toolCallId } : {}), | |
| 543 | 616 | ...(toolData !== undefined ? { toolData } : {}), |
| 544 | 617 | }; |
| 545 | 618 | } |
| ... | ... | @@ -556,9 +629,15 @@ function mergeChangedFiles( |
| 556 | 629 | } |
| 557 | 630 | |
| 558 | 631 | function 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 | 636 | return undefined; |
| 561 | 637 | } |
| 638 | if (entry.toolCallId) { | |
| 639 | return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; | |
| 640 | } | |
| 562 | 641 | const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); |
| 563 | 642 | const detail = entry.detail?.trim() ?? ""; |
| 564 | 643 | const itemType = entry.itemType ?? ""; |
| ... | ... | @@ -568,10 +647,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un |
| 568 | 647 | return [itemType, normalizedLabel, detail].join("\u001f"); |
| 569 | 648 | } |
| 570 | 649 | |
| 571 | function normalizeCompactToolLabel(value: string): string { | |
| 572 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 573 | } | |
| 574 | ||
| 575 | 650 | function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { |
| 576 | 651 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { |
| 577 | 652 | return true; |
| ... | ... | @@ -648,12 +723,12 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { |
| 648 | 723 | |
| 649 | 724 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 650 | 725 | if ( |
| 651 | entry.activityKind === "user-input.requested" || | |
| 652 | entry.activityKind === "user-input.resolved" | |
| 726 | entry.sourceActivityKind === "user-input.requested" || | |
| 727 | entry.sourceActivityKind === "user-input.resolved" | |
| 653 | 728 | ) { |
| 654 | 729 | return "message"; |
| 655 | 730 | } |
| 656 | if (entry.activityKind === "runtime.warning") return "warning"; | |
| 731 | if (entry.sourceActivityKind === "runtime.warning") return "warning"; | |
| 657 | 732 | if (entry.requestKind === "command") return "command"; |
| 658 | 733 | if (entry.requestKind === "file-read") return "eye"; |
| 659 | 734 | if (entry.requestKind === "file-change") return "edit"; |
| ... | ... | @@ -1287,10 +1362,14 @@ export function deriveThreadFeedPresentation( |
| 1287 | 1362 | activeWorkStartedAt: string | null = null, |
| 1288 | 1363 | ): ThreadFeedEntry[] { |
| 1289 | 1364 | const sourceFeed = feed.filter( |
| 1290 | (entry) => | |
| 1291 | entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", | |
| 1365 | (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", | |
| 1366 | ); | |
| 1367 | const activeTailGroup = sourceFeed.findLast( | |
| 1368 | (entry) => entry.type !== "message" || !isEmptyMessage(entry), | |
| 1292 | 1369 | ); |
| 1293 | 1370 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); |
| 1371 | const unsettledTurnId = deriveUnsettledTurnId(latestTurn); | |
| 1372 | const isWorking = activeWorkStartedAt !== null; | |
| 1294 | 1373 | const collapsedEntryIds = new Set<string>(); |
| 1295 | 1374 | for (const fold of foldsByAnchorId.values()) { |
| 1296 | 1375 | if (!expandedTurnIds.has(fold.turnId)) { |
| ... | ... | @@ -1302,6 +1381,13 @@ export function deriveThreadFeedPresentation( |
| 1302 | 1381 | |
| 1303 | 1382 | const result: ThreadFeedEntry[] = []; |
| 1304 | 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 | 1391 | const fold = foldsByAnchorId.get(entry.id); |
| 1306 | 1392 | if (fold) { |
| 1307 | 1393 | result.push({ |
| ... | ... | @@ -1314,49 +1400,65 @@ export function deriveThreadFeedPresentation( |
| 1314 | 1400 | }); |
| 1315 | 1401 | } |
| 1316 | 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 | 1413 | return result; |
| 1328 | 1414 | } |
| 1329 | 1415 | |
| 1330 | 1416 | function appendPresentedFeedEntry( |
| 1331 | 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 | 1419 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1420 | unsettledTurnId: TurnId | null, | |
| 1421 | isWorking: boolean, | |
| 1422 | activeTail: boolean, | |
| 1334 | 1423 | ): void { |
| 1335 | 1424 | if (entry.type !== "activity-group") { |
| 1336 | 1425 | result.push(entry); |
| 1337 | 1426 | return; |
| 1338 | 1427 | } |
| 1339 | 1428 | |
| 1340 | const activities = entry.activities.filter( | |
| 1341 | (activity) => !(activity.toolLike && activity.status === "neutral"), | |
| 1429 | const activities = omitSupersededLifecycleMarkers( | |
| 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 | 1439 | if (activities.length === 0) { |
| 1344 | 1440 | return; |
| 1345 | 1441 | } |
| 1346 | if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { | |
| 1347 | result.push({ | |
| 1348 | ...entry, | |
| 1349 | activities, | |
| 1350 | }); | |
| 1351 | return; | |
| 1352 | } | |
| 1353 | ||
| 1354 | const groupId = entry.id; | |
| 1355 | const expanded = expandedWorkGroupIds.has(groupId); | |
| 1356 | const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 1357 | const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES); | |
| 1358 | ||
| 1359 | for (const activity of visibleActivities) { | |
| 1442 | let groupableRun: ThreadFeedActivity[] = []; | |
| 1443 | const flushGroupableRun = (isTrailingRun: boolean) => { | |
| 1444 | if (groupableRun.length === 0) return; | |
| 1445 | appendToolGroupRows( | |
| 1446 | result, | |
| 1447 | entry, | |
| 1448 | groupableRun, | |
| 1449 | expandedWorkGroupIds, | |
| 1450 | unsettledTurnId, | |
| 1451 | isWorking, | |
| 1452 | activeTail && isTrailingRun, | |
| 1453 | ); | |
| 1454 | groupableRun = []; | |
| 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 | 1462 | result.push({ |
| 1361 | 1463 | type: "activity-group", |
| 1362 | 1464 | id: activity.id, |
| ... | ... | @@ -1365,16 +1467,87 @@ function appendPresentedFeedEntry( |
| 1365 | 1467 | activities: [activity], |
| 1366 | 1468 | }); |
| 1367 | 1469 | } |
| 1470 | flushGroupableRun(true); | |
| 1471 | } | |
| 1472 | ||
| 1473 | function 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 | 1501 | result.push({ |
| 1369 | 1502 | type: "work-toggle", |
| 1370 | id: `work-toggle:${groupId}`, | |
| 1371 | createdAt: entry.createdAt, | |
| 1372 | turnId: entry.turnId, | |
| 1503 | id: `${live ? "work-live" : "work-toggle"}:${groupId}`, | |
| 1504 | createdAt: sourceGroup.createdAt, | |
| 1505 | turnId: sourceGroup.turnId, | |
| 1373 | 1506 | groupId, |
| 1374 | hiddenCount, | |
| 1507 | hiddenCount: activities.length, | |
| 1375 | 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 | ||
| 1544 | function 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 | } |
| 1379 | 1552 | |
| 1380 | 1553 | /** |
| ... | ... | @@ -1609,6 +1782,8 @@ export function buildThreadFeed( |
| 1609 | 1782 | icon: workEntryIcon(entry), |
| 1610 | 1783 | toolLike: workLogEntryIsToolLike(entry), |
| 1611 | 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 | 7 | type TurnId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | |
| 10 | 11 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 11 | 12 | import { |
| 12 | 13 | emptyAgentPanelModel, |
| ... | ... | @@ -2289,176 +2290,6 @@ function workEntryRawCommand( |
| 2289 | 2290 | return rawCommand === workEntry.command.trim() ? null : rawCommand; |
| 2290 | 2291 | } |
| 2291 | 2292 | |
| 2292 | type CommandWrapper = "env" | "sudo"; | |
| 2293 | ||
| 2294 | const 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 | ||
| 2299 | const 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 | ||
| 2304 | function 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 | ||
| 2390 | function 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 | ||
| 2462 | 2293 | function liveWorkEntryLabel( |
| 2463 | 2294 | workEntry: TimelineWorkEntry, |
| 2464 | 2295 | workspaceRoot: string | undefined, |
packages/client-runtime/package.json+8| ... | ... | @@ -158,6 +158,14 @@ |
| 158 | 158 | "./state/vcs": { |
| 159 | 159 | "types": "./src/state/vcs.ts", |
| 160 | 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 | 171 | "scripts": { |
packages/client-runtime/src/work-log/commandLabel.ts created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | type CommandWrapper = "env" | "sudo"; | |
| 2 | ||
| 3 | const 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 | ||
| 8 | const 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 | ||
| 13 | function 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 | ||
| 99 | export 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 @@ |
| 1 | import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export 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 | ||
| 18 | export type ToolGroupAction = | |
| 19 | | "read" | |
| 20 | | "edit" | |
| 21 | | "command" | |
| 22 | | "code-search" | |
| 23 | | "search" | |
| 24 | | "other" | |
| 25 | | "update"; | |
| 26 | ||
| 27 | export type ToolGroupSummaryKind = | |
| 28 | | ToolGroupAction | |
| 29 | | "dynamic-tool" | |
| 30 | | "agent-tool" | |
| 31 | | "tone-tool" | |
| 32 | | "mixed"; | |
| 33 | ||
| 34 | export function normalizeCompactToolLabel(value: string): string { | |
| 35 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 36 | } | |
| 37 | ||
| 38 | function 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 | ||
| 45 | export 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 | ||
| 52 | export 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 | ||
| 75 | function 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 | ||
| 93 | function 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 | ||
| 112 | export 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 | ||
| 132 | export 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 | ||
| 168 | export 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 | 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts |
| 2 | index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644 | |
| 2 | index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644 | |
| 3 | 3 | --- a/keyboard.d.ts |
| 4 | 4 | +++ b/keyboard.d.ts |
| 5 | 5 | @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { |
| ... | ... | @@ -7,7 +7,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776 |
| 7 | 7 | current: Pick<View, "measure"> | null; |
| 8 | 8 | }; |
| 9 | 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 | 11 | contentInsetEndAdjustment: SharedValue<number>; |
| 12 | 12 | onComposerLayout: (event: LayoutChangeEvent) => void; |
| 13 | 13 | }; |
| ... | ... | @@ -23,15 +23,15 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776 |
| 23 | 23 | } & React.RefAttributes<LegendListRef>) => React.ReactElement | null; |
| 24 | 24 | |
| 25 | 25 | diff --git a/keyboard.js b/keyboard.js |
| 26 | index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644 | |
| 26 | index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644 | |
| 27 | 27 | --- a/keyboard.js |
| 28 | 28 | +++ 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 | 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 | 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 | 35 | const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); |
| 36 | 36 | const lastHeightRef = React.useRef(void 0); |
| 37 | 37 | const reportHeight = React.useCallback( |
| ... | ... | @@ -40,17 +40,21 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc |
| 40 | 40 | + const height = Math.max(0, rawHeight + heightAdjustment); |
| 41 | 41 | var _a; |
| 42 | 42 | if (Number.isFinite(height) && height !== lastHeightRef.current) { |
| 43 | + const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0; | |
| 43 | 44 | lastHeightRef.current = height; |
| 44 | contentInsetEndAdjustment.value = height; | |
| 45 | - contentInsetEndAdjustment.value = height; | |
| 45 | 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 | 52 | - [contentInsetEndAdjustment, listRef] |
| 49 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 53 | + [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 50 | 54 | ); |
| 51 | 55 | React.useLayoutEffect(() => { |
| 52 | 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 | 59 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { |
| 56 | 60 | const { |
| ... | ... | @@ -62,7 +66,15 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc |
| 62 | 66 | freeze, |
| 63 | 67 | keyboardLiftBehavior, |
| 64 | 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 | 78 | ...anchoredEndSpace, |
| 67 | 79 | onSizeChanged: (size) => { |
| 68 | 80 | var _a; |
| ... | ... | @@ -80,23 +92,25 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc |
| 80 | 92 | const onContentInsetChange = React.useCallback((insets) => { |
| 81 | 93 | var _a; |
| 82 | 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 | 96 | reactNativeKeyboardController.KeyboardChatScrollView, |
| 85 | 97 | { |
| 86 | 98 | ...scrollProps, |
| 87 | 99 | + adjustedInsetCompensation, |
| 100 | + adjustedStartInsetCompensation, | |
| 88 | 101 | applyWorkaroundForContentInsetHitTestBug, |
| 89 | 102 | blankSpace, |
| 90 | 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 | 108 | + adjustedInsetCompensation, |
| 109 | + adjustedStartInsetCompensation, | |
| 96 | 110 | applyWorkaroundForContentInsetHitTestBug, |
| 97 | 111 | blankSpace, |
| 98 | 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 | 115 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, |
| 102 | 116 | anchoredEndSpaceOwnerInternal: "scroll", |
| ... | ... | @@ -105,15 +119,24 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc |
| 105 | 119 | renderScrollComponent: memoList, |
| 106 | 120 | ...rest |
| 107 | 121 | diff --git a/keyboard.mjs b/keyboard.mjs |
| 108 | index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644 | |
| 122 | index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644 | |
| 109 | 123 | --- a/keyboard.mjs |
| 110 | 124 | +++ 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 | 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 | 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 | 140 | const contentInsetEndAdjustment = useSharedValue(initialHeight); |
| 118 | 141 | const lastHeightRef = useRef(void 0); |
| 119 | 142 | const reportHeight = useCallback( |
| ... | ... | @@ -122,17 +145,21 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af |
| 122 | 145 | + const height = Math.max(0, rawHeight + heightAdjustment); |
| 123 | 146 | var _a; |
| 124 | 147 | if (Number.isFinite(height) && height !== lastHeightRef.current) { |
| 148 | + const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0; | |
| 125 | 149 | lastHeightRef.current = height; |
| 126 | contentInsetEndAdjustment.value = height; | |
| 150 | - contentInsetEndAdjustment.value = height; | |
| 127 | 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 | 157 | - [contentInsetEndAdjustment, listRef] |
| 131 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 158 | + [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 132 | 159 | ); |
| 133 | 160 | useLayoutEffect(() => { |
| 134 | 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 | 164 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { |
| 138 | 165 | const { |
| ... | ... | @@ -144,7 +171,15 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af |
| 144 | 171 | freeze, |
| 145 | 172 | keyboardLiftBehavior, |
| 146 | 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 | 183 | ...anchoredEndSpace, |
| 149 | 184 | onSizeChanged: (size) => { |
| 150 | 185 | var _a; |
| ... | ... | @@ -162,23 +197,25 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af |
| 162 | 197 | const onContentInsetChange = useCallback((insets) => { |
| 163 | 198 | var _a; |
| 164 | 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 | 201 | KeyboardChatScrollView, |
| 167 | 202 | { |
| 168 | 203 | ...scrollProps, |
| 169 | 204 | + adjustedInsetCompensation, |
| 205 | + adjustedStartInsetCompensation, | |
| 170 | 206 | applyWorkaroundForContentInsetHitTestBug, |
| 171 | 207 | blankSpace, |
| 172 | 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 | 213 | + adjustedInsetCompensation, |
| 214 | + adjustedStartInsetCompensation, | |
| 178 | 215 | applyWorkaroundForContentInsetHitTestBug, |
| 179 | 216 | blankSpace, |
| 180 | 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 | 220 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, |
| 184 | 221 | anchoredEndSpaceOwnerInternal: "scroll", |
| ... | ... | @@ -204,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a |
| 204 | 241 | * Number of columns to render items in. |
| 205 | 242 | * @default 1 |
| 206 | 243 | diff --git a/react-native.js b/react-native.js |
| 207 | index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4a5493efb 100644 | |
| 244 | index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644 | |
| 208 | 245 | --- a/react-native.js |
| 209 | 246 | +++ b/react-native.js |
| 210 | 247 | @@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { |
| ... | ... | @@ -216,7 +253,27 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 216 | 253 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 217 | 254 | set$(ctx, "readyToRender", true); |
| 218 | 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 | 279 | // src/core/clampScrollOffset.ts |
| ... | ... | @@ -242,7 +299,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 242 | 299 | return clampedOffset; |
| 243 | 300 | } |
| 244 | 301 | |
| 245 | @@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 302 | @@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 246 | 303 | finishScrollTo(ctx); |
| 247 | 304 | } |
| 248 | 305 | } |
| ... | ... | @@ -255,7 +312,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 255 | 312 | x: ctx.state.props.horizontal ? offset : 0, |
| 256 | 313 | 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 | 317 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 261 | 318 | } else if (shouldRetryUnalignedEndScroll) { |
| ... | ... | @@ -267,7 +324,17 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 267 | 324 | scheduleFallbackCheck(100); |
| 268 | 325 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 269 | 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 | 339 | if (shouldMaintainScrollAtEnd) { |
| 273 | 340 | state.pendingMaintainScrollAtEnd = false; |
| ... | ... | @@ -287,7 +354,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 287 | 354 | } |
| 288 | 355 | if (!state.maintainingScrollAtEnd) { |
| 289 | 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 | 358 | y: 0 |
| 292 | 359 | }); |
| 293 | 360 | } else { |
| ... | ... | @@ -309,7 +376,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 309 | 376 | } |
| 310 | 377 | setTimeout( |
| 311 | 378 | () => { |
| 312 | @@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 379 | @@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 313 | 380 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 314 | 381 | const state = ctx.state; |
| 315 | 382 | if (Math.abs(positionDiff) > 0.1) { |
| ... | ... | @@ -320,7 +387,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 320 | 387 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 321 | 388 | const doit = () => { |
| 322 | 389 | if (needsScrollWorkaround) { |
| 323 | @@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 390 | @@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 324 | 391 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 325 | 392 | return 0; |
| 326 | 393 | } |
| ... | ... | @@ -331,7 +398,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 331 | 398 | const clampDelta = maxScroll - state.scroll; |
| 332 | 399 | if (unresolvedAmount < 0) { |
| 333 | 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 | 402 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 336 | 403 | return true; |
| 337 | 404 | } |
| ... | ... | @@ -340,7 +407,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 340 | 407 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 341 | 408 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 342 | 409 | if (isAtExpectedNativeClamp) { |
| 343 | @@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 410 | @@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 344 | 411 | if (diff > 0) { |
| 345 | 412 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 346 | 413 | } else { |
| ... | ... | @@ -349,7 +416,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 349 | 416 | state.scroll = maxScroll; |
| 350 | 417 | state.scrollPending = maxScroll; |
| 351 | 418 | diff = 0; |
| 352 | @@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, { | |
| 419 | @@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, { | |
| 353 | 420 | } |
| 354 | 421 | |
| 355 | 422 | // src/core/initialScroll.ts |
| ... | ... | @@ -471,7 +538,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 471 | 538 | const requestedIndex = target.index; |
| 472 | 539 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 473 | 540 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 474 | @@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 541 | @@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 475 | 542 | return; |
| 476 | 543 | } |
| 477 | 544 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| ... | ... | @@ -482,7 +549,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 482 | 549 | if (!shouldKeepEndTargetAlive) { |
| 483 | 550 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 484 | 551 | clearPendingInitialScrollFooterLayout(ctx, { |
| 485 | @@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 552 | @@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 486 | 553 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 487 | 554 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 488 | 555 | isReady = !hasUnknownTailSize; |
| ... | ... | @@ -492,7 +559,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 492 | 559 | } else if (anchorIndex >= 0) { |
| 493 | 560 | isReady = false; |
| 494 | 561 | } |
| 495 | @@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 562 | @@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 496 | 563 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 497 | 564 | } |
| 498 | 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 | 572 | } |
| 506 | 573 | return nextSize; |
| 507 | 574 | } |
| 508 | @@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 575 | @@ -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 | 653 | dataVersion, |
| 510 | 654 | drawDistance = 250, |
| 511 | 655 | contentInsetEndAdjustment, |
| ... | ... | @@ -513,7 +657,20 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 513 | 657 | estimatedItemSize = 100, |
| 514 | 658 | estimatedListSize, |
| 515 | 659 | extraData, |
| 516 | @@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 660 | @@ -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 | 674 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 518 | 675 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 519 | 676 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| ... | ... | @@ -522,7 +679,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 522 | 679 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); |
| 523 | 680 | const alwaysRenderIndices = React2.useMemo(() => { |
| 524 | 681 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); |
| 525 | @@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 682 | @@ -7341,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 526 | 683 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 527 | 684 | contentInset, |
| 528 | 685 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| ... | ... | @@ -530,7 +687,15 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 530 | 687 | data: dataProp, |
| 531 | 688 | dataKey, |
| 532 | 689 | dataVersion, |
| 533 | @@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 690 | @@ -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 | 699 | return void 0; |
| 535 | 700 | } |
| 536 | 701 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| ... | ... | @@ -544,15 +709,43 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 544 | 709 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 545 | 710 | }, [usesBootstrapInitialScroll]); |
| 546 | 711 | React2.useLayoutEffect(() => { |
| 547 | @@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 712 | @@ -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 | 729 | onScroll: (event) => onScroll(ctx, event), |
| 549 | 730 | onScrollBeginDrag: (event) => { |
| 550 | 731 | var _a4, _b2; |
| 551 | 732 | + ctx.state.didUserDrag = true; |
| 733 | + ctx.state.isUserDragging = true; | |
| 552 | 734 | prepareReachedEdgeForNextUserScroll(ctx); |
| 553 | 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, forwarded | |
| 737 | @@ -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 | 749 | onLayout, |
| 557 | 750 | onLayoutFooter, |
| 558 | 751 | onMomentumScrollEnd: fns.onMomentumScrollEnd, |
| ... | ... | @@ -561,7 +754,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 |
| 561 | 754 | recycleItems, |
| 562 | 755 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { |
| 563 | 756 | diff --git a/react-native.mjs b/react-native.mjs |
| 564 | index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644 | |
| 757 | index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644 | |
| 565 | 758 | --- a/react-native.mjs |
| 566 | 759 | +++ b/react-native.mjs |
| 567 | 760 | @@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { |
| ... | ... | @@ -573,7 +766,27 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 573 | 766 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 574 | 767 | set$(ctx, "readyToRender", true); |
| 575 | 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 | 792 | // src/core/clampScrollOffset.ts |
| ... | ... | @@ -599,7 +812,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 599 | 812 | return clampedOffset; |
| 600 | 813 | } |
| 601 | 814 | |
| 602 | @@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 815 | @@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 603 | 816 | finishScrollTo(ctx); |
| 604 | 817 | } |
| 605 | 818 | } |
| ... | ... | @@ -612,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 612 | 825 | x: ctx.state.props.horizontal ? offset : 0, |
| 613 | 826 | 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 | 830 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 618 | 831 | } else if (shouldRetryUnalignedEndScroll) { |
| ... | ... | @@ -624,7 +837,17 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 624 | 837 | scheduleFallbackCheck(100); |
| 625 | 838 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 626 | 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 | 852 | if (shouldMaintainScrollAtEnd) { |
| 630 | 853 | state.pendingMaintainScrollAtEnd = false; |
| ... | ... | @@ -644,7 +867,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 644 | 867 | } |
| 645 | 868 | if (!state.maintainingScrollAtEnd) { |
| 646 | 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 | 871 | y: 0 |
| 649 | 872 | }); |
| 650 | 873 | } else { |
| ... | ... | @@ -666,7 +889,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 666 | 889 | } |
| 667 | 890 | setTimeout( |
| 668 | 891 | () => { |
| 669 | @@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 892 | @@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 670 | 893 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 671 | 894 | const state = ctx.state; |
| 672 | 895 | if (Math.abs(positionDiff) > 0.1) { |
| ... | ... | @@ -677,7 +900,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 677 | 900 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 678 | 901 | const doit = () => { |
| 679 | 902 | if (needsScrollWorkaround) { |
| 680 | @@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 903 | @@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 681 | 904 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 682 | 905 | return 0; |
| 683 | 906 | } |
| ... | ... | @@ -688,7 +911,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 688 | 911 | const clampDelta = maxScroll - state.scroll; |
| 689 | 912 | if (unresolvedAmount < 0) { |
| 690 | 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 | 915 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 693 | 916 | return true; |
| 694 | 917 | } |
| ... | ... | @@ -697,7 +920,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 697 | 920 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 698 | 921 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 699 | 922 | if (isAtExpectedNativeClamp) { |
| 700 | @@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 923 | @@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 701 | 924 | if (diff > 0) { |
| 702 | 925 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 703 | 926 | } else { |
| ... | ... | @@ -706,7 +929,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 706 | 929 | state.scroll = maxScroll; |
| 707 | 930 | state.scrollPending = maxScroll; |
| 708 | 931 | diff = 0; |
| 709 | @@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, { | |
| 932 | @@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, { | |
| 710 | 933 | } |
| 711 | 934 | |
| 712 | 935 | // src/core/initialScroll.ts |
| ... | ... | @@ -828,7 +1051,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 828 | 1051 | const requestedIndex = target.index; |
| 829 | 1052 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 830 | 1053 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 831 | @@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 1054 | @@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 832 | 1055 | return; |
| 833 | 1056 | } |
| 834 | 1057 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| ... | ... | @@ -839,7 +1062,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 839 | 1062 | if (!shouldKeepEndTargetAlive) { |
| 840 | 1063 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 841 | 1064 | clearPendingInitialScrollFooterLayout(ctx, { |
| 842 | @@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 1065 | @@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 843 | 1066 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 844 | 1067 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 845 | 1068 | isReady = !hasUnknownTailSize; |
| ... | ... | @@ -849,7 +1072,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 849 | 1072 | } else if (anchorIndex >= 0) { |
| 850 | 1073 | isReady = false; |
| 851 | 1074 | } |
| 852 | @@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 1075 | @@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 853 | 1076 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 854 | 1077 | } |
| 855 | 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 | 1085 | } |
| 863 | 1086 | return nextSize; |
| 864 | 1087 | } |
| 865 | @@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1088 | @@ -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 | 1166 | dataVersion, |
| 867 | 1167 | drawDistance = 250, |
| 868 | 1168 | contentInsetEndAdjustment, |
| ... | ... | @@ -870,7 +1170,20 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 870 | 1170 | estimatedItemSize = 100, |
| 871 | 1171 | estimatedListSize, |
| 872 | 1172 | extraData, |
| 873 | @@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1173 | @@ -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 | 1187 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 875 | 1188 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 876 | 1189 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| ... | ... | @@ -879,7 +1192,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 879 | 1192 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); |
| 880 | 1193 | const alwaysRenderIndices = useMemo(() => { |
| 881 | 1194 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); |
| 882 | @@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1195 | @@ -7320,6 +7513,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 883 | 1196 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 884 | 1197 | contentInset, |
| 885 | 1198 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| ... | ... | @@ -887,7 +1200,15 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 887 | 1200 | data: dataProp, |
| 888 | 1201 | dataKey, |
| 889 | 1202 | dataVersion, |
| 890 | @@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1203 | @@ -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 | 1212 | return void 0; |
| 892 | 1213 | } |
| 893 | 1214 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| ... | ... | @@ -901,15 +1222,43 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 901 | 1222 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 902 | 1223 | }, [usesBootstrapInitialScroll]); |
| 903 | 1224 | useLayoutEffect(() => { |
| 904 | @@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1225 | @@ -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 | 1242 | onScroll: (event) => onScroll(ctx, event), |
| 906 | 1243 | onScrollBeginDrag: (event) => { |
| 907 | 1244 | var _a4, _b2; |
| 908 | 1245 | + ctx.state.didUserDrag = true; |
| 1246 | + ctx.state.isUserDragging = true; | |
| 909 | 1247 | prepareReachedEdgeForNextUserScroll(ctx); |
| 910 | 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, forwarded | |
| 1250 | @@ -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 | 1262 | onLayout, |
| 914 | 1263 | onLayoutFooter, |
| 915 | 1264 | onMomentumScrollEnd: fns.onMomentumScrollEnd, |
| ... | ... | @@ -918,7 +1267,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c |
| 918 | 1267 | recycleItems, |
| 919 | 1268 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { |
| 920 | 1269 | diff --git a/react.js b/react.js |
| 921 | index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9b774d49b 100644 | |
| 1270 | index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644 | |
| 922 | 1271 | --- a/react.js |
| 923 | 1272 | +++ b/react.js |
| 924 | 1273 | @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| ... | ... | @@ -931,8 +1280,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9 |
| 931 | 1280 | } else if (anchorIndex >= 0) { |
| 932 | 1281 | isReady = false; |
| 933 | 1282 | } |
| 934 | @@ -4721,7 +4722,13 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 935 | updateContentMetricsState(ctx); | |
| 1283 | @@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 936 | 1284 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 937 | 1285 | } |
| 938 | 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 | 1293 | } |
| 946 | 1294 | return nextSize; |
| 947 | 1295 | } |
| 948 | @@ -6448,8 +6455,8 @@ function ScrollAdjust() { | |
| 1296 | @@ -6446,8 +6453,8 @@ function ScrollAdjust() { | |
| 949 | 1297 | window.getComputedStyle(contentNode)[axis.paddingEndProp] |
| 950 | 1298 | ); |
| 951 | 1299 | const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; |
| ... | ... | @@ -956,7 +1304,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9 |
| 956 | 1304 | scrollBy(); |
| 957 | 1305 | if (resetPaddingRafRef.current !== void 0) { |
| 958 | 1306 | diff --git a/react.mjs b/react.mjs |
| 959 | index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e86820262da4 100644 | |
| 1307 | index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644 | |
| 960 | 1308 | --- a/react.mjs |
| 961 | 1309 | +++ b/react.mjs |
| 962 | 1310 | @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| ... | ... | @@ -969,8 +1317,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868 |
| 969 | 1317 | } else if (anchorIndex >= 0) { |
| 970 | 1318 | isReady = false; |
| 971 | 1319 | } |
| 972 | @@ -4700,7 +4701,13 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 973 | updateContentMetricsState(ctx); | |
| 1320 | @@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 974 | 1321 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 975 | 1322 | } |
| 976 | 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 | 1330 | } |
| 984 | 1331 | return nextSize; |
| 985 | 1332 | } |
| 986 | @@ -6427,8 +6434,8 @@ function ScrollAdjust() { | |
| 1333 | @@ -6425,8 +6432,8 @@ function ScrollAdjust() { | |
| 987 | 1334 | window.getComputedStyle(contentNode)[axis.paddingEndProp] |
| 988 | 1335 | ); |
| 989 | 1336 | const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; |
| ... | ... | @@ -1011,19 +1358,22 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 |
| 1011 | 1358 | * Number of columns to render items in. |
| 1012 | 1359 | * @default 1 |
| 1013 | 1360 | diff --git a/reanimated.js b/reanimated.js |
| 1014 | index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644 | |
| 1361 | index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644 | |
| 1015 | 1362 | --- a/reanimated.js |
| 1016 | 1363 | +++ 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 | 1366 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); |
| 1019 | 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 | 1370 | let shouldSkipTransitionForRecycleReuse = false; |
| 1021 | 1371 | - if (recycleItems && layoutTransition) { |
| 1022 | 1372 | + if (layoutTransition) { |
| 1023 | 1373 | const itemKeySignal = `containerItemKey${id}`; |
| 1024 | 1374 | const itemKey = peek$(ctx, itemKeySignal); |
| 1025 | 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 | 1377 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], |
| 1028 | 1378 | [horizontal, positionValue, style] |
| 1029 | 1379 | ); |
| ... | ... | @@ -1035,30 +1385,168 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274 |
| 1035 | 1385 | + // transition exists to smooth at-rest shifts (streaming text, work-log |
| 1036 | 1386 | + // folds), so gate it to at-rest moments. |
| 1037 | 1387 | + const now = Date.now(); |
| 1038 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 1039 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 1388 | + const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0; | |
| 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 | 1399 | return /* @__PURE__ */ React__namespace.createElement( |
| 1041 | 1400 | Reanimated__default.default.View, |
| 1042 | 1401 | { |
| 1043 | 1402 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, |
| 1044 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 1403 | + layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition, | |
| 1045 | 1404 | ref: refView, |
| 1046 | 1405 | style: viewStyle, |
| 1047 | 1406 | ...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 } : {} | |
| 1048 | 1524 | diff --git a/reanimated.mjs b/reanimated.mjs |
| 1049 | index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644 | |
| 1525 | index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644 | |
| 1050 | 1526 | --- a/reanimated.mjs |
| 1051 | 1527 | +++ 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 | 1539 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); |
| 1054 | 1540 | const prevItemKeyRef = React.useRef(void 0); |
| 1541 | + const previousPositionRef = React.useRef(positionValue); | |
| 1542 | + const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); | |
| 1055 | 1543 | let shouldSkipTransitionForRecycleReuse = false; |
| 1056 | 1544 | - if (recycleItems && layoutTransition) { |
| 1057 | 1545 | + if (layoutTransition) { |
| 1058 | 1546 | const itemKeySignal = `containerItemKey${id}`; |
| 1059 | 1547 | const itemKey = peek$(ctx, itemKeySignal); |
| 1060 | 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 | 1550 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], |
| 1063 | 1551 | [horizontal, positionValue, style] |
| 1064 | 1552 | ); |
| ... | ... | @@ -1070,13 +1558,139 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a |
| 1070 | 1558 | + // transition exists to smooth at-rest shifts (streaming text, work-log |
| 1071 | 1559 | + // folds), so gate it to at-rest moments. |
| 1072 | 1560 | + const now = Date.now(); |
| 1073 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 1074 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 1561 | + const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0; | |
| 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 | 1572 | return /* @__PURE__ */ React.createElement( |
| 1076 | 1573 | Reanimated.View, |
| 1077 | 1574 | { |
| 1078 | 1575 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, |
| 1079 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 1576 | + layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition, | |
| 1080 | 1577 | ref: refView, |
| 1081 | 1578 | style: viewStyle, |
| 1082 | 1579 | ...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 | 1 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js |
| 2 | index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 | |
| 2 | index db8cfb1d289f91563f13c4dd842c783c99facc32..64c7e38ad8d9e866b15a53e561509e6b0ad33cb9 100644 | |
| 3 | 3 | --- a/lib/commonjs/components/KeyboardChatScrollView/index.js |
| 4 | 4 | +++ 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 | 6 | offset = 0, |
| 7 | 7 | extraContentPadding = ZERO_CONTENT_PADDING, |
| 8 | 8 | blankSpace = ZERO_BLANK_SPACE, |
| 9 | 9 | + adjustedInsetCompensation = 0, |
| 10 | + adjustedStartInsetCompensation = 0, | |
| 10 | 11 | applyWorkaroundForContentInsetHitTestBug = false, |
| 11 | 12 | onLayout: onLayoutProp, |
| 12 | 13 | onContentSizeChange: onContentSizeChangeProp, |
| ... | ... | @@ -14,13 +15,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 |
| 14 | 15 | onEndVisible, |
| 15 | 16 | ...rest |
| 16 | 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 | 19 | freeze: freezeSV, |
| 19 | 20 | offset, |
| 20 | 21 | blankSpace, |
| 21 | 22 | - extraContentPadding |
| 22 | 23 | + extraContentPadding, |
| 23 | + adjustedInsetCompensation | |
| 24 | + adjustedInsetCompensation, | |
| 25 | + adjustedStartInsetCompensation | |
| 24 | 26 | }); |
| 25 | 27 | (0, _useExtraContentPadding.useExtraContentPadding)({ |
| 26 | 28 | scrollViewRef, |
| ... | ... | @@ -28,17 +30,23 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 |
| 28 | 30 | keyboardPadding: padding, |
| 29 | 31 | blankSpace, |
| 30 | 32 | + adjustedInsetCompensation, |
| 33 | + adjustedStartInsetCompensation, | |
| 31 | 34 | scroll, |
| 32 | 35 | layout, |
| 33 | 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 | 38 | // a bug for you, please open an issue. |
| 36 | 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 | 46 | + // Mirror the effective bottom padding (keyboard + composer + blank floor) |
| 39 | 47 | + // to the consumer - a virtualized list needs it in its own scroll math or |
| 40 | 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 | 50 | + if (onContentInsetChange && current !== previous) { |
| 43 | 51 | + (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ |
| 44 | 52 | + bottom: current |
| ... | ... | @@ -54,21 +62,57 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 |
| 54 | 62 | const onLayout = (0, _react.useCallback)(e => { |
| 55 | 63 | onLayoutInternal(e); |
| 56 | 64 | onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); |
| 65 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js | |
| 66 | index 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 | |
| 57 | 90 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js |
| 58 | index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 | |
| 91 | index 2073da84b8b2be291aa3181700c2b18a75d0fc56..7ae9bb220ce3cc23c788a79677afdc7d9547a27c 100644 | |
| 59 | 92 | --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js |
| 60 | 93 | +++ 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 | 95 | freeze, |
| 63 | 96 | offset, |
| 64 | 97 | blankSpace, |
| 65 | 98 | - extraContentPadding |
| 66 | 99 | + extraContentPadding, |
| 67 | + adjustedInsetCompensation | |
| 100 | + adjustedInsetCompensation, | |
| 101 | + adjustedStartInsetCompensation | |
| 68 | 102 | } = options; |
| 69 | 103 | const padding = (0, _reactNativeReanimated.useSharedValue)(0); |
| 70 | 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 | 116 | const visiblePadding = visibleFraction * blankSpace.value; |
| 73 | 117 | const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); |
| 74 | 118 | const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); |
| ... | ... | @@ -77,28 +121,81 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e |
| 77 | 121 | |
| 78 | 122 | // persistent mode: when keyboard shrinks, clamp to valid range |
| 79 | 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 | 176 | const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); |
| 177 | + currentHeight.value = effective; | |
| 82 | 178 | padding.value = effective; |
| 83 | 179 | } |
| 84 | 180 | - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); |
| 85 | + }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); | |
| 181 | + }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); | |
| 86 | 182 | return { |
| 87 | 183 | padding, |
| 88 | 184 | currentHeight, |
| 89 | 185 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js |
| 90 | index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644 | |
| 186 | index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..e0b73653fd1dd23f0f2e2c85835a3d107e3f666d 100644 | |
| 91 | 187 | --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js |
| 92 | 188 | +++ 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 | 190 | extraContentPadding, |
| 95 | 191 | keyboardPadding, |
| 96 | 192 | blankSpace, |
| 97 | 193 | + adjustedInsetCompensation, |
| 194 | + adjustedStartInsetCompensation, | |
| 98 | 195 | scroll, |
| 99 | 196 | layout, |
| 100 | 197 | size, |
| 101 | @@ -68,8 +69,8 @@ function useExtraContentPadding(options) { | |
| 198 | @@ -68,8 +70,8 @@ function useExtraContentPadding(options) { | |
| 102 | 199 | } |
| 103 | 200 | |
| 104 | 201 | // Compute effective delta considering blankSpace floor |
| ... | ... | @@ -109,33 +206,41 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da |
| 109 | 206 | const effectiveDelta = currentTotal - previousTotal; |
| 110 | 207 | if (effectiveDelta === 0) { |
| 111 | 208 | // blankSpace absorbed the change |
| 112 | @@ -92,6 +93,6 @@ function useExtraContentPadding(options) { | |
| 113 | const target = Math.min(scroll.value + effectiveDelta, maxScroll); | |
| 209 | @@ -88,10 +90,11 @@ function useExtraContentPadding(options) { | |
| 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 | 218 | scrollToTarget(target); |
| 115 | 219 | } |
| 116 | 220 | - }, [inverted, keyboardLiftBehavior]); |
| 117 | + }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); | |
| 221 | + }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]); | |
| 118 | 222 | } |
| 119 | 223 | //# sourceMappingURL=index.js.map |
| 120 | \ No newline at end of file | |
| 121 | 224 | diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js |
| 122 | index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644 | |
| 225 | index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..76b858a6590ad0592cc663864ea822db55eaeceb 100644 | |
| 123 | 226 | --- a/lib/module/components/KeyboardChatScrollView/index.js |
| 124 | 227 | +++ b/lib/module/components/KeyboardChatScrollView/index.js |
| 125 | 228 | @@ -1,7 +1,7 @@ |
| 126 | 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 | 230 | import React, { forwardRef, useCallback, useMemo } from "react"; |
| 128 | import { StyleSheet } from "react-native"; | |
| 231 | -import { StyleSheet } from "react-native"; | |
| 129 | 232 | -import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; |
| 233 | +import { Platform, StyleSheet } from "react-native"; | |
| 130 | 234 | +import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; |
| 131 | 235 | import Reanimated from "react-native-reanimated"; |
| 132 | 236 | import useCombinedRef from "../hooks/useCombinedRef"; |
| 133 | 237 | import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; |
| 134 | @@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | |
| 238 | @@ -19,9 +19,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | |
| 135 | 239 | offset = 0, |
| 136 | 240 | extraContentPadding = ZERO_CONTENT_PADDING, |
| 137 | 241 | blankSpace = ZERO_BLANK_SPACE, |
| 138 | 242 | + adjustedInsetCompensation = 0, |
| 243 | + adjustedStartInsetCompensation = 0, | |
| 139 | 244 | applyWorkaroundForContentInsetHitTestBug = false, |
| 140 | 245 | onLayout: onLayoutProp, |
| 141 | 246 | onContentSizeChange: onContentSizeChangeProp, |
| ... | ... | @@ -143,13 +248,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 |
| 143 | 248 | onEndVisible, |
| 144 | 249 | ...rest |
| 145 | 250 | }, ref) => { |
| 146 | @@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | |
| 251 | @@ -43,13 +46,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | |
| 147 | 252 | freeze: freezeSV, |
| 148 | 253 | offset, |
| 149 | 254 | blankSpace, |
| 150 | 255 | - extraContentPadding |
| 151 | 256 | + extraContentPadding, |
| 152 | + adjustedInsetCompensation | |
| 257 | + adjustedInsetCompensation, | |
| 258 | + adjustedStartInsetCompensation | |
| 153 | 259 | }); |
| 154 | 260 | useExtraContentPadding({ |
| 155 | 261 | scrollViewRef, |
| ... | ... | @@ -157,17 +263,23 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 |
| 157 | 263 | keyboardPadding: padding, |
| 158 | 264 | blankSpace, |
| 159 | 265 | + adjustedInsetCompensation, |
| 266 | + adjustedStartInsetCompensation, | |
| 160 | 267 | scroll, |
| 161 | 268 | layout, |
| 162 | 269 | size, |
| 163 | @@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | |
| 270 | @@ -75,10 +82,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | |
| 164 | 271 | // a bug for you, please open an issue. |
| 165 | 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 | 279 | + // Mirror the effective bottom padding (keyboard + composer + blank floor) |
| 168 | 280 | + // to the consumer - a virtualized list needs it in its own scroll math or |
| 169 | 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 | 283 | + if (onContentInsetChange && current !== previous) { |
| 172 | 284 | + runOnJS(onContentInsetChange)({ |
| 173 | 285 | + bottom: current |
| ... | ... | @@ -183,21 +295,56 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 |
| 183 | 295 | const onLayout = useCallback(e => { |
| 184 | 296 | onLayoutInternal(e); |
| 185 | 297 | onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); |
| 298 | diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js | |
| 299 | index 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 | |
| 186 | 322 | diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js |
| 187 | index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 | |
| 323 | index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..cc4bfae859191aeb848179028b7217098cba2340 100644 | |
| 188 | 324 | --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js |
| 189 | 325 | +++ 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 | 327 | freeze, |
| 192 | 328 | offset, |
| 193 | 329 | blankSpace, |
| 194 | 330 | - extraContentPadding |
| 195 | 331 | + extraContentPadding, |
| 196 | + adjustedInsetCompensation | |
| 332 | + adjustedInsetCompensation, | |
| 333 | + adjustedStartInsetCompensation | |
| 197 | 334 | } = options; |
| 198 | 335 | const padding = useSharedValue(0); |
| 199 | 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 | 348 | const visiblePadding = visibleFraction * blankSpace.value; |
| 202 | 349 | const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); |
| 203 | 350 | const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); |
| ... | ... | @@ -206,28 +353,81 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669 |
| 206 | 353 | |
| 207 | 354 | // persistent mode: when keyboard shrinks, clamp to valid range |
| 208 | 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 | 408 | const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); |
| 409 | + currentHeight.value = effective; | |
| 211 | 410 | padding.value = effective; |
| 212 | 411 | } |
| 213 | 412 | - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); |
| 214 | + }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); | |
| 413 | + }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); | |
| 215 | 414 | return { |
| 216 | 415 | padding, |
| 217 | 416 | currentHeight, |
| 218 | 417 | diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js |
| 219 | index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644 | |
| 418 | index 1afa50987a8d2a5fe3f36b20945efe804d48a873..7b05d0721c50e118cf78c07913b580a31293db77 100644 | |
| 220 | 419 | --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js |
| 221 | 420 | +++ 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 | 422 | extraContentPadding, |
| 224 | 423 | keyboardPadding, |
| 225 | 424 | blankSpace, |
| 226 | 425 | + adjustedInsetCompensation, |
| 426 | + adjustedStartInsetCompensation, | |
| 227 | 427 | scroll, |
| 228 | 428 | layout, |
| 229 | 429 | size, |
| 230 | @@ -62,8 +63,8 @@ function useExtraContentPadding(options) { | |
| 430 | @@ -62,8 +64,8 @@ function useExtraContentPadding(options) { | |
| 231 | 431 | } |
| 232 | 432 | |
| 233 | 433 | // Compute effective delta considering blankSpace floor |
| ... | ... | @@ -238,61 +438,85 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3 |
| 238 | 438 | const effectiveDelta = currentTotal - previousTotal; |
| 239 | 439 | if (effectiveDelta === 0) { |
| 240 | 440 | // blankSpace absorbed the change |
| 241 | @@ -86,7 +87,7 @@ function useExtraContentPadding(options) { | |
| 242 | const target = Math.min(scroll.value + effectiveDelta, maxScroll); | |
| 441 | @@ -82,11 +84,12 @@ function useExtraContentPadding(options) { | |
| 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 | 450 | scrollToTarget(target); |
| 244 | 451 | } |
| 245 | 452 | - }, [inverted, keyboardLiftBehavior]); |
| 246 | + }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); | |
| 453 | + }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]); | |
| 247 | 454 | } |
| 248 | 455 | export { useExtraContentPadding }; |
| 249 | 456 | //# sourceMappingURL=index.js.map |
| 250 | \ No newline at end of file | |
| 251 | 457 | diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts |
| 252 | index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644 | |
| 458 | index a036b431f03efb9d5379527c17db6fce62bfee09..5e56bf7edec9161698a745e8e6292bfccd68d3db 100644 | |
| 253 | 459 | --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts |
| 254 | 460 | +++ 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 | 462 | * Default is `undefined` (equivalent to `0` — no minimum floor). |
| 257 | 463 | */ |
| 258 | 464 | blankSpace?: SharedValue<number>; |
| 259 | 465 | + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ |
| 260 | 466 | + adjustedInsetCompensation?: number; |
| 467 | + /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ | |
| 468 | + adjustedStartInsetCompensation?: number; | |
| 261 | 469 | /** |
| 262 | 470 | * Fires whenever the effective content inset changes — the static `contentInset` |
| 263 | 471 | * prop combined with the dynamic keyboard-driven padding. |
| 472 | diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts | |
| 473 | index 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; | |
| 264 | 482 | diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts |
| 265 | index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 | |
| 483 | index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..fdd83953271123be6caefaa786c469f0d92cce2b 100644 | |
| 266 | 484 | --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts |
| 267 | 485 | +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts |
| 268 | @@ -9,6 +9,8 @@ type UseChatKeyboardOptions = { | |
| 486 | @@ -9,6 +9,10 @@ type UseChatKeyboardOptions = { | |
| 269 | 487 | blankSpace: SharedValue<number>; |
| 270 | 488 | /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ |
| 271 | 489 | extraContentPadding: SharedValue<number>; |
| 272 | 490 | + /** Safe-area extra beyond raw contentInset. Offset math only. */ |
| 273 | 491 | + adjustedInsetCompensation: number; |
| 492 | + /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ | |
| 493 | + adjustedStartInsetCompensation: number; | |
| 274 | 494 | }; |
| 275 | 495 | type UseChatKeyboardReturn = { |
| 276 | 496 | /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ |
| 277 | 497 | diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts |
| 278 | index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644 | |
| 498 | index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..45b387446587fd3e611fa02a01534bb5d9fa6bbd 100644 | |
| 279 | 499 | --- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts |
| 280 | 500 | +++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts |
| 281 | @@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = { | |
| 501 | @@ -8,6 +8,10 @@ type UseExtraContentPaddingOptions = { | |
| 282 | 502 | keyboardPadding: SharedValue<number>; |
| 283 | 503 | /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ |
| 284 | 504 | blankSpace: SharedValue<number>; |
| 285 | 505 | + /** Safe-area extra beyond raw contentInset. Offset math only. */ |
| 286 | 506 | + adjustedInsetCompensation: number; |
| 507 | + /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */ | |
| 508 | + adjustedStartInsetCompensation: number; | |
| 287 | 509 | /** Current vertical scroll offset. */ |
| 288 | 510 | scroll: SharedValue<number>; |
| 289 | 511 | /** Visible viewport dimensions. */ |
| 290 | 512 | diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx |
| 291 | index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644 | |
| 513 | index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..277a1ecada428ffbae517d6edc720d029c8ecc10 100644 | |
| 292 | 514 | --- a/src/components/KeyboardChatScrollView/index.tsx |
| 293 | 515 | +++ b/src/components/KeyboardChatScrollView/index.tsx |
| 294 | @@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; | |
| 295 | import { StyleSheet } from "react-native"; | |
| 516 | @@ -1,7 +1,9 @@ | |
| 517 | import React, { forwardRef, useCallback, useMemo } from "react"; | |
| 518 | -import { StyleSheet } from "react-native"; | |
| 519 | +import { Platform, StyleSheet } from "react-native"; | |
| 296 | 520 | import { |
| 297 | 521 | makeMutable, |
| 298 | 522 | + runOnJS, |
| ... | ... | @@ -300,11 +524,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d |
| 300 | 524 | useAnimatedRef, |
| 301 | 525 | useAnimatedStyle, |
| 302 | 526 | useDerivedValue, |
| 303 | @@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< | |
| 527 | @@ -35,9 +37,12 @@ const KeyboardChatScrollView = forwardRef< | |
| 304 | 528 | offset = 0, |
| 305 | 529 | extraContentPadding = ZERO_CONTENT_PADDING, |
| 306 | 530 | blankSpace = ZERO_BLANK_SPACE, |
| 307 | 531 | + adjustedInsetCompensation = 0, |
| 532 | + adjustedStartInsetCompensation = 0, | |
| 308 | 533 | applyWorkaroundForContentInsetHitTestBug = false, |
| 309 | 534 | onLayout: onLayoutProp, |
| 310 | 535 | onContentSizeChange: onContentSizeChangeProp, |
| ... | ... | @@ -312,23 +537,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d |
| 312 | 537 | onEndVisible, |
| 313 | 538 | ...rest |
| 314 | 539 | }, |
| 315 | @@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< | |
| 540 | @@ -64,6 +69,8 @@ const KeyboardChatScrollView = forwardRef< | |
| 316 | 541 | offset, |
| 317 | 542 | blankSpace, |
| 318 | 543 | extraContentPadding, |
| 319 | 544 | + adjustedInsetCompensation, |
| 545 | + adjustedStartInsetCompensation, | |
| 320 | 546 | }); |
| 321 | 547 | |
| 322 | 548 | useExtraContentPadding({ |
| 323 | @@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< | |
| 549 | @@ -71,6 +78,8 @@ const KeyboardChatScrollView = forwardRef< | |
| 324 | 550 | extraContentPadding, |
| 325 | 551 | keyboardPadding: padding, |
| 326 | 552 | blankSpace, |
| 327 | 553 | + adjustedInsetCompensation, |
| 554 | + adjustedStartInsetCompensation, | |
| 328 | 555 | scroll, |
| 329 | 556 | layout, |
| 330 | 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 | 564 | - // scrollIndicatorInsets adjustment at the application layer. |
| 338 | 565 | - const indicatorPadding = useDerivedValue( |
| 339 | 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 | 582 | + // Mirror the effective bottom padding (keyboard + composer + blank floor) |
| 341 | 583 | + // to the consumer — a virtualized list needs it in its own scroll math or |
| 342 | 584 | + // its end/maintain targets point at the under-the-keyboard resting offset. |
| 343 | 585 | + useAnimatedReaction( |
| 344 | + () => totalPadding.value, | |
| 586 | + () => reportedPadding.value, | |
| 345 | 587 | + (current, previous) => { |
| 346 | 588 | + if (onContentInsetChange && current !== previous) { |
| 347 | 589 | + runOnJS(onContentInsetChange)({ bottom: current }); |
| 348 | 590 | + } |
| 349 | 591 | + }, |
| 350 | 592 | + [onContentInsetChange], |
| 351 | ); | |
| 352 | ||
| 593 | + ); | |
| 594 | + | |
| 353 | 595 | + // Scroll indicator inset = keyboard only (excludes extraContentPadding and |
| 354 | 596 | + // blankSpace): with a floating composer the indicator track should run the |
| 355 | 597 | + // full height of the scroll view, behind the composer, like iOS Messages. |
| ... | ... | @@ -360,10 +602,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d |
| 360 | 602 | (e: LayoutChangeEvent) => { |
| 361 | 603 | onLayoutInternal(e); |
| 362 | 604 | diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts |
| 363 | index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 | |
| 605 | index dd222b57bc7a71729524670bad3812f30920bd73..7be71b5460eb143ddf460ce5c87b843c66bfb2cd 100644 | |
| 364 | 606 | --- a/src/components/KeyboardChatScrollView/types.ts |
| 365 | 607 | +++ b/src/components/KeyboardChatScrollView/types.ts |
| 366 | @@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { | |
| 608 | @@ -90,6 +90,17 @@ export type KeyboardChatScrollViewProps = { | |
| 367 | 609 | * Default is `undefined` (equivalent to `0` — no minimum floor). |
| 368 | 610 | */ |
| 369 | 611 | blankSpace?: SharedValue<number>; |
| ... | ... | @@ -376,22 +618,65 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb |
| 376 | 618 | + * Default is `0`. |
| 377 | 619 | + */ |
| 378 | 620 | + adjustedInsetCompensation?: number; |
| 621 | + /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ | |
| 622 | + adjustedStartInsetCompensation?: number; | |
| 379 | 623 | /** |
| 380 | 624 | * Fires whenever the effective content inset changes — the static `contentInset` |
| 381 | 625 | * prop combined with the dynamic keyboard-driven padding. |
| 626 | diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts | |
| 627 | index 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 | }; | |
| 382 | 655 | diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts |
| 383 | index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 | |
| 656 | index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..c375a8c4cc889c2fc2c0c1034788c605633192ed 100644 | |
| 384 | 657 | --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts |
| 385 | 658 | +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts |
| 386 | @@ -43,6 +43,7 @@ function useChatKeyboard( | |
| 659 | @@ -43,6 +43,8 @@ function useChatKeyboard( | |
| 387 | 660 | offset, |
| 388 | 661 | blankSpace, |
| 389 | 662 | extraContentPadding, |
| 390 | 663 | + adjustedInsetCompensation, |
| 664 | + adjustedStartInsetCompensation, | |
| 391 | 665 | } = options; |
| 392 | 666 | |
| 393 | 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 | 680 | effective, |
| 396 | 681 | minimumPaddingAbsorbed, |
| 397 | 682 | ); |
| ... | ... | @@ -408,50 +693,131 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670 |
| 408 | 693 | |
| 409 | 694 | // persistent mode: when keyboard shrinks, clamp to valid range |
| 410 | 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 | 766 | padding.value = effective; |
| 413 | 767 | }, |
| 414 | 768 | }, |
| 415 | 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 | 780 | return { |
| 420 | 781 | diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts |
| 421 | index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 | |
| 782 | index 02abf5cd9490900826678175462b234e07e30e92..4ee0ddd65e0b105568ab43b73997af4be8adae00 100644 | |
| 422 | 783 | --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts |
| 423 | 784 | +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts |
| 424 | @@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { | |
| 785 | @@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { | |
| 425 | 786 | blankSpace: SharedValue<number>; |
| 426 | 787 | /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ |
| 427 | 788 | extraContentPadding: SharedValue<number>; |
| 428 | 789 | + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ |
| 429 | 790 | + adjustedInsetCompensation: number; |
| 791 | + /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ | |
| 792 | + adjustedStartInsetCompensation: number; | |
| 430 | 793 | }; |
| 431 | 794 | |
| 432 | 795 | type UseChatKeyboardReturn = { |
| 433 | 796 | diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts |
| 434 | index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 | |
| 797 | index 833acbe78f1b1245251ddd3431d6546decdd0ade..8f7c10cb6ab392f10df4ca43549b89c25844f0b8 100644 | |
| 435 | 798 | --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts |
| 436 | 799 | +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts |
| 437 | @@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { | |
| 800 | @@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = { | |
| 438 | 801 | keyboardPadding: SharedValue<number>; |
| 439 | 802 | /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ |
| 440 | 803 | blankSpace: SharedValue<number>; |
| 441 | 804 | + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ |
| 442 | 805 | + adjustedInsetCompensation: number; |
| 806 | + /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */ | |
| 807 | + adjustedStartInsetCompensation: number; | |
| 443 | 808 | /** Current vertical scroll offset. */ |
| 444 | 809 | scroll: SharedValue<number>; |
| 445 | 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 | 812 | extraContentPadding, |
| 448 | 813 | keyboardPadding, |
| 449 | 814 | blankSpace, |
| 450 | 815 | + adjustedInsetCompensation, |
| 816 | + adjustedStartInsetCompensation, | |
| 451 | 817 | scroll, |
| 452 | 818 | layout, |
| 453 | 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 | 823 | // Compute effective delta considering blankSpace floor |
| ... | ... | @@ -472,12 +838,32 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15 |
| 472 | 838 | const effectiveDelta = currentTotal - previousTotal; |
| 473 | 839 | |
| 474 | 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 | 857 | scrollToTarget(target); |
| 477 | 858 | } |
| 478 | 859 | }, |
| 479 | 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 | 86 | '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b |
| 87 | 87 | '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 |
| 88 | 88 | '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 |
| 89 | '@legendapp/list@3.3.5': 064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706 | |
| 89 | '@legendapp/list@3.3.5': f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b | |
| 90 | 90 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa |
| 91 | 91 | '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd |
| 92 | 92 | '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 |
| 93 | 93 | effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 |
| 94 | 94 | react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 95 | react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 | |
| 95 | react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 | |
| 96 | 96 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 97 | 97 | react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006 |
| 98 | 98 | uniwind@1.11.0: 329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196 |
| ... | ... | @@ -228,7 +228,7 @@ importers: |
| 228 | 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 | 229 | '@legendapp/list': |
| 230 | 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 | 232 | '@noble/curves': |
| 233 | 233 | specifier: 'catalog:' |
| 234 | 234 | version: 1.9.1 |
| ... | ... | @@ -402,7 +402,7 @@ importers: |
| 402 | 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 | 403 | react-native-keyboard-controller: |
| 404 | 404 | 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 | 406 | react-native-nitro-markdown: |
| 407 | 407 | specifier: ^0.5.0 |
| 408 | 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 | 560 | version: 0.9.0 |
| 561 | 561 | '@legendapp/list': |
| 562 | 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 | 564 | '@lexical/react': |
| 565 | 565 | specifier: ^0.41.0 |
| 566 | 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 | 12828 | dependencies: |
| 12829 | 12829 | jsbi: 4.3.2 |
| 12830 | 12830 | |
| 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 | 12832 | dependencies: |
| 12833 | 12833 | react: 19.2.3 |
| 12834 | 12834 | use-sync-external-store: 1.6.0(react@19.2.3) |
| ... | ... | @@ -12836,7 +12836,7 @@ snapshots: |
| 12836 | 12836 | react-dom: 19.2.3(react@19.2.3) |
| 12837 | 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) |
| 12838 | 12838 | |
| 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 | 12840 | dependencies: |
| 12841 | 12841 | react: 19.2.6 |
| 12842 | 12842 | use-sync-external-store: 1.6.0(react@19.2.6) |
| ... | ... | @@ -19356,7 +19356,7 @@ snapshots: |
| 19356 | 19356 | react: 19.2.3 |
| 19357 | 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) |
| 19358 | 19358 | |
| 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 | 19360 | dependencies: |
| 19361 | 19361 | react: 19.2.3 |
| 19362 | 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) |