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

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


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

apps/mobile/src/features/threads/ThreadComposer.tsx+178-119
......@@ -9,7 +9,16 @@ import type {
99} from "@t3tools/contracts";
1010import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
1111import type { ReactNode } from "react";
12import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
12import {
13 memo,
14 useCallback,
15 useEffect,
16 useLayoutEffect,
17 useMemo,
18 useRef,
19 useState,
20 type RefObject,
21} from "react";
1322import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";
1423import ImageViewing from "react-native-image-viewing";
1524import Animated, {
......@@ -18,6 +27,9 @@ import Animated, {
1827 FadeOut,
1928 FadeOutDown,
2029 LinearTransition,
30 useAnimatedStyle,
31 useSharedValue,
32 withTiming,
2133} from "react-native-reanimated";
2234import { useUniwindTheme } from "../../lib/useUniwindTheme";
2335import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
......@@ -31,7 +43,6 @@ import {
3143 ComposerInlineControl,
3244 ComposerToolbarButton,
3345 ComposerToolbarRow,
34 ComposerToolbarScroller,
3546} from "../../components/ComposerToolbar";
3647import { ControlPill } from "../../components/ControlPill";
3748import { ProviderIcon } from "../../components/ProviderIcon";
......@@ -104,14 +115,17 @@ export interface ThreadComposerProps {
104115 * iOS 26+ devices and keeps the existing opaque fallback elsewhere.
105116 * Exported so NewTaskDraftScreen can render the same composer chrome.
106117 */
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.
109120// Android gets NO layout transition: the composer rides the keyboard via
110121// KeyboardStickyView (frame-synced to the IME), and a time-based morph
111122// running alongside that translate reads as jitter. Snapping the layout and
112123// letting the keyboard-synced slide be the only motion looks native there.
113const COMPOSER_LAYOUT_TRANSITION =
114 Platform.OS === "android" ? undefined : LinearTransition.duration(220);
124export const COMPOSER_TRANSITION_DURATION_MS = 220;
125export const COMPOSER_LAYOUT_TRANSITION =
126 Platform.OS === "android"
127 ? undefined
128 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS);
115129
116130export function ComposerSurface(props: {
117131 readonly children: ReactNode;
......@@ -119,29 +133,53 @@ export function ComposerSurface(props: {
119133 /** Existing thread composers morph between pill and card layouts. */
120134 readonly animateLayout?: boolean;
121135}) {
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.
124153 return (
125154 <Animated.View
126155 className="shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
127156 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 ]}
133165 >
134166 <GlassSurface
135167 chrome="none"
136168 fallbackClassName="border border-border bg-card-translucent"
137169 glassEffectStyle="regular"
138170 // 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"
140173 tintColor="transparent"
141 style={props.style}
174 style={{
175 position: "absolute",
176 inset: 0,
177 borderRadius: targetBorderRadius,
178 }}
142179 >
143 {props.children}
180 {null}
144181 </GlassSurface>
182 <View style={props.style}>{props.children}</View>
145183 </Animated.View>
146184 );
147185}
......@@ -254,9 +292,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
254292 const isExpanded = isFocused || settingsSheetPresentation.isActive;
255293 const canSend = hasContent;
256294
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.
260297 useEffect(() => {
261298 onExpandedChange?.(isExpanded);
262299 }, [isExpanded, onExpandedChange]);
......@@ -279,13 +316,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
279316 const onEditorFocusChange = props.onEditorFocusChange;
280317 const handleFocus = useCallback(() => {
281318 setIsFocused(true);
319 onExpandedChange?.(true);
282320 onEditorFocusChange?.(true);
283 }, [onEditorFocusChange]);
321 }, [onEditorFocusChange, onExpandedChange]);
284322
285323 const handleBlur = useCallback(() => {
286324 setIsFocused(false);
325 if (!settingsSheetPresentation.isActive) {
326 onExpandedChange?.(false);
327 }
287328 onEditorFocusChange?.(false);
288 }, [onEditorFocusChange]);
329 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);
289330 const showStopAction =
290331 props.selectedThread.session?.status === "running" ||
291332 props.selectedThread.session?.status === "starting";
......@@ -445,7 +486,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
445486 return (
446487 <Animated.View
447488 className="px-4"
448 layout={COMPOSER_LAYOUT_TRANSITION}
449489 style={{
450490 paddingTop: isExpanded ? 8 : 6,
451491 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
......@@ -460,7 +500,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
460500 />
461501 <Animated.View
462502 className="relative w-full self-center"
463 layout={COMPOSER_LAYOUT_TRANSITION}
464503 style={{ maxWidth: props.contentMaxWidth }}
465504 >
466505 {composerMenu.trigger && composerMenu.items.length > 0 ? (
......@@ -493,12 +532,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
493532 paddingTop: 14,
494533 }
495534 : {
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,
497538 overflow: "hidden" as const,
498 flexDirection: "row" as const,
499 alignItems: "center" as const,
500 paddingLeft: 18,
501 paddingRight: 5,
539 paddingHorizontal: 14,
502540 paddingVertical: 5,
503541 }
504542 }
......@@ -518,95 +556,124 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
518556 </Animated.View>
519557 ) : null}
520558
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 />
576597 </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 >
593648 <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">
595650 <ComposerToolbarButton
596651 accessibilityLabel="Add attachment"
597652 icon="plus"
598653 onPress={() => void props.onPickDraftImages()}
599654 showChevron={false}
600655 />
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}
610677 />
611678 {showStopAction ? (
612679 <ComposerToolbarButton
......@@ -617,17 +684,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
617684 showChevron={false}
618685 />
619686 ) : 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>
629688 </ComposerToolbarRow>
630 ) : null}
689 </View>
631690 </ComposerSurface>
632691
633692 {/* Queue count */}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+160-140
......@@ -27,7 +27,6 @@ import {
2727 useRef,
2828 useState,
2929} from "react";
30import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
3130import {
3231 AppState,
3332 Keyboard,
......@@ -45,13 +44,13 @@ import Animated, {
4544 Easing,
4645 FadeInDown,
4746 FadeOut,
47 ReduceMotion,
4848 useAnimatedReaction,
4949 useSharedValue,
5050 withTiming,
5151} from "react-native-reanimated";
5252import { useSafeAreaInsets } from "react-native-safe-area-context";
5353
54import { ControlPill } from "../../components/ControlPill";
5554import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5655import type { ComposerEditorHandle } from "../../components/ComposerEditor";
5756import type { StatusTone } from "../../components/StatusPill";
......@@ -67,6 +66,10 @@ import type {
6766} from "../../lib/threadActivity";
6867import { PendingApprovalCard } from "./PendingApprovalCard";
6968import { PendingUserInputCard } from "./PendingUserInputCard";
69import {
70 FLOATING_WORKING_CONTROL_COVERAGE,
71 FloatingWorkingControl,
72} from "./floating-working-control";
7073import {
7174 derivePendingUserInputMaxHeight,
7275 ESTIMATED_KEYBOARD_HEIGHT,
......@@ -75,6 +78,8 @@ import {
7578import {
7679 COMPOSER_COLLAPSED_CHROME,
7780 COMPOSER_EXPANDED_CHROME,
81 COMPOSER_LAYOUT_TRANSITION,
82 COMPOSER_TRANSITION_DURATION_MS,
7883 ThreadComposer,
7984} from "./ThreadComposer";
8085import { ThreadFeed } from "./ThreadFeed";
......@@ -290,6 +295,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
290295 return null;
291296 }
292297 })();
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;
293306 const selectedThreadFeed = props.selectedThreadFeed;
294307 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
295308 const composerOverlapHeight = composerChrome + composerBottomInset;
......@@ -343,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
343356 composerOverlayRef,
344357 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
345358 -nativeInsetOvercount,
359 Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0,
346360 );
347361 // The expanded questionnaire is an absolute overlay on iOS, so it never
348362 // changes the measured overlay height (that constancy is what keeps the
......@@ -356,6 +370,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
356370 const userInputCardProgress = useSharedValue(1);
357371 const userInputInsetProgress = useSharedValue(1);
358372 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]);
359382 // Android renders the expanded card in-flow (it cannot hit-test the iOS
360383 // overlay outside the bar's bounds), so its measured overlay height already
361384 // includes the card — the coverage extra is iOS-only.
......@@ -366,6 +389,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
366389 useAnimatedReaction(
367390 () =>
368391 contentInsetEndAdjustment.value +
392 floatingControlCoverage.value +
369393 (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0),
370394 (value) => {
371395 combinedContentInsetEndAdjustment.value = value;
......@@ -375,20 +399,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
375399 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
376400 const endFollowEnabledRef = useRef(true);
377401 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 });
379407 // The list's own corrections for these inset changes drift on short
380408 // content (and the error compounds across toggles), so deterministically
381409 // re-pin the end once a toggle settles: a no-op when the resting position
382410 // is already right, corrective when it is not. Follow state is re-checked
383411 // inside the callback — the user may grab the list during the settle
384412 // window, and yanking them back would override a live gesture.
385 const scheduleUserInputRepin = useCallback(
413 const scheduleOverlayRepin = useCallback(
386414 (delayMs: number) => {
387 if (userInputRepinTimerRef.current !== null) {
388 clearTimeout(userInputRepinTimerRef.current);
415 if (overlayRepinTimerRef.current !== null) {
416 clearTimeout(overlayRepinTimerRef.current);
389417 }
390 userInputRepinTimerRef.current = setTimeout(() => {
391 userInputRepinTimerRef.current = null;
418 overlayRepinTimerRef.current = setTimeout(() => {
419 overlayRepinTimerRef.current = null;
392420 if (!endFollowEnabledRef.current) {
393421 return;
394422 }
......@@ -401,12 +429,29 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
401429 );
402430 useEffect(
403431 () => () => {
404 if (userInputRepinTimerRef.current !== null) {
405 clearTimeout(userInputRepinTimerRef.current);
432 if (overlayRepinTimerRef.current !== null) {
433 clearTimeout(overlayRepinTimerRef.current);
406434 }
407435 },
408436 [],
409437 );
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]);
410455 const handleToggleUserInputCollapsed = useCallback(() => {
411456 if (activeUserInputRequestId === null) {
412457 return;
......@@ -416,7 +461,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
416461 userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
417462 userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
418463 setCollapsedUserInputRequestId(null);
419 scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);
464 scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);
420465 } else {
421466 // Collapsing hides the custom-answer inputs; release the keyboard with
422467 // them instead of leaving it up over a dead responder.
......@@ -427,11 +472,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
427472 // anchor.
428473 userInputInsetProgress.value = 0;
429474 setCollapsedUserInputRequestId(activeUserInputRequestId);
430 scheduleUserInputRepin(60);
475 scheduleOverlayRepin(60);
431476 }
432477 }, [
433478 activeUserInputRequestId,
434 scheduleUserInputRepin,
479 scheduleOverlayRepin,
435480 userInputCardProgress,
436481 userInputCollapsed,
437482 userInputInsetProgress,
......@@ -622,7 +667,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
622667 submittedMessageId={submittedMessageId}
623668 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
624669 contentTopInset={0}
625 contentBottomInset={estimatedOverlayHeight}
670 contentBottomInset={
671 estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
672 }
626673 contentMaxWidth={contentMaxWidth}
627674 layoutVariant={layoutVariant}
628675 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
......@@ -639,137 +686,110 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
639686 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
640687 {showContent ? (
641688 <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 }}
647695 offset={{ closed: 0, opened: 0 }}
648696 >
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)}
676728 >
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
740759 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>
771791 </View>
772 </View>
792 </Animated.View>
773793 </KeyboardStickyView>
774794 ) : null}
775795 </View>
apps/mobile/src/features/threads/ThreadFeed.tsx+122-95
......@@ -4,7 +4,6 @@ import { type LegendListRef } from "@legendapp/list/react-native";
44import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
55import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
66import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
7import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
87import { SymbolView } from "../../components/AppSymbol";
98import { HeaderHeightContext } from "@react-navigation/elements";
109import { useNavigation } from "@react-navigation/native";
......@@ -45,7 +44,13 @@ import {
4544import { TouchableOpacity } from "react-native-gesture-handler";
4645import ImageViewing from "react-native-image-viewing";
4746import { useSafeAreaInsets } from "react-native-safe-area-context";
48import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
47import Animated, {
48 FadeIn,
49 FadeInUp,
50 FadeOut,
51 LinearTransition,
52 type SharedValue,
53} from "react-native-reanimated";
4954import { useUniwindTheme } from "../../lib/useUniwindTheme";
5055import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
5156import { useFontFamily } from "../../lib/useFontFamily";
......@@ -84,9 +89,7 @@ import {
8489import {
8590 resolveMarkdownFontSizes,
8691 resolveNativeMarkdownTypography,
87 scaledTypographyLineHeight,
8892} from "../../lib/appearancePreferences";
89import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
9093import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
9194import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
9295import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
......@@ -105,6 +108,7 @@ import {
105108 collapsedWorkLogHeight,
106109 ThreadWorkGroupToggle,
107110 ThreadWorkLog,
111 THREAD_DISCLOSURE_TRANSITION_MS,
108112 WORK_GROUP_TOGGLE_HEIGHT,
109113} from "./thread-work-log";
110114import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
......@@ -134,9 +138,10 @@ function formatMessageTime(input: string): string {
134138// so its height is a constant; a drifted value costs one correction on
135139// measure, not a persistent offset.
136140const 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).
139const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16)
141const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
142const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
143const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
144const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
140145
141146// Entering animations must only play for rows born just now — LegendList
142147// remounts rows when they scroll back into view, and replaying an entrance for
......@@ -985,10 +990,6 @@ function renderFeedEntry(
985990 const entry = info.item;
986991 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
987992
988 if (entry.type === "working") {
989 return <WorkingTimelineRow startedAt={entry.createdAt} />;
990 }
991
992993 if (entry.type === "turn-fold") {
993994 return (
994995 <Pressable
......@@ -1017,7 +1018,10 @@ function renderFeedEntry(
10171018 expanded={entry.expanded}
10181019 hiddenCount={entry.hiddenCount}
10191020 iconSubtleColor={iconSubtleColor}
1020 onlyToolActivities={entry.onlyToolActivities}
1021 summary={entry.summary}
1022 summaryKind={entry.summaryKind}
1023 hasFailure={entry.hasFailure}
1024 shimmer={entry.shimmer}
10211025 onToggle={() => props.onToggleWorkGroup(entry.groupId)}
10221026 />
10231027 );
......@@ -1180,32 +1184,6 @@ function renderFeedEntry(
11801184 );
11811185}
11821186
1183const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
1184 const [nowMs, setNowMs] = useState(() => Date.now());
1185
1186 useEffect(() => {
1187 const intervalId = setInterval(() => {
1188 setNowMs(Date.now());
1189 }, 1_000);
1190 return () => clearInterval(intervalId);
1191 }, [props.startedAt]);
1192
1193 const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
1194
1195 return (
1196 <View className="mb-4 flex-row items-center gap-2 px-1.5 py-1">
1197 <View className="flex-row items-center gap-1">
1198 <View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-500" />
1199 <View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a80-500-a80" />
1200 <View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a60-500-a60" />
1201 </View>
1202 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
1203 Working for {durationLabel}
1204 </Text>
1205 </View>
1206 );
1207});
1208
12091187function UserMessageContent(props: {
12101188 readonly text: string;
12111189 readonly markdownStyles: MarkdownStyleSet;
......@@ -1479,14 +1457,14 @@ function ThreadFeedPlaceholder(props: {
14791457export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14801458 const navigation = useNavigation();
14811459 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);
14841462 const disclosureAnchorKeyRef = useRef<string | null>(null);
1463 const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
14851464 const headerMaterialVisibleRef = useRef(false);
14861465 const previousLatestTurnRef = useRef(props.latestTurn);
14871466 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14881467 const { width: windowWidth } = useWindowDimensions();
1489 const { appearance } = useAppearancePreferences();
14901468 const [viewportWidth, setViewportWidth] = useState(() =>
14911469 props.layoutVariant === "split" ? 0 : windowWidth,
14921470 );
......@@ -1791,14 +1769,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17911769 props.latestTurn,
17921770 ],
17931771 );
1772 const disclosureEnteringEntryIds = useMemo(() => {
1773 const anchorKey = disclosureAnchorKeyRef.current;
1774 const previousPresentedFeed = previousPresentedFeedRef.current;
1775 if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
1776 return EMPTY_DISCLOSURE_ENTRY_IDS;
1777 }
1778
1779 const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
1780 const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
1781 const enteringIds = new Set<string>();
1782 if (anchorIndex < 0) {
1783 return enteringIds;
1784 }
1785 for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
1786 const entryId = presentedFeed[index]!.id;
1787 if (previousIds.has(entryId)) {
1788 break;
1789 }
1790 enteringIds.add(entryId);
1791 }
1792 return enteringIds;
1793 }, [disclosureToggleSettling, presentedFeed]);
1794
1795 useLayoutEffect(() => {
1796 previousPresentedFeedRef.current = presentedFeed;
1797 }, [presentedFeed]);
17941798
1795 // The empty↔filled key below remounts the list, which resets its 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.
18021803 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
18031804 useLayoutEffect(() => {
18041805 const bottom = props.contentInsetEndAdjustment.value;
......@@ -1863,34 +1864,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18631864 if (copyFeedbackTimeoutRef.current) {
18641865 clearTimeout(copyFeedbackTimeoutRef.current);
18651866 }
1866 if (foldSettleFrameRef.current !== null) {
1867 cancelAnimationFrame(foldSettleFrameRef.current);
1867 if (disclosureSettleFrameRef.current !== null) {
1868 cancelAnimationFrame(disclosureSettleFrameRef.current);
18681869 }
1869 if (foldSettleSecondFrameRef.current !== null) {
1870 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1870 if (disclosureSettleSecondFrameRef.current !== null) {
1871 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
18711872 }
18721873 };
18731874 }, []);
18741875
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);
18801879 }
1881 if (foldSettleSecondFrameRef.current !== null) {
1882 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1880 if (disclosureSettleSecondFrameRef.current !== null) {
1881 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
18831882 }
1884 foldSettleFrameRef.current = requestAnimationFrame(() => {
1885 foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1883 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
1884 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
18861885 disclosureAnchorKeyRef.current = null;
18871886 setDisclosureToggleSettling(false);
1888 foldSettleFrameRef.current = null;
1889 foldSettleSecondFrameRef.current = null;
1887 disclosureSettleFrameRef.current = null;
1888 disclosureSettleSecondFrameRef.current = null;
18901889 });
18911890 });
18921891 }, []);
18931892
1893 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
1894 disclosureAnchorKeyRef.current = anchorKey;
1895 setDisclosureToggleSettling(true);
1896 }, []);
1897
1898 // Start the quiet-frame countdown after React has committed the disclosure.
1899 // Every measured item-size change restarts it, so end maintenance cannot
1900 // wake between the data mutation and LegendList's final layout correction.
1901 useLayoutEffect(() => {
1902 if (disclosureAnchorKeyRef.current !== null) {
1903 settleDisclosureAfterLayout();
1904 }
1905 }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
1906
1907 const handleItemSizeChanged = useCallback(() => {
1908 if (disclosureAnchorKeyRef.current !== null) {
1909 settleDisclosureAfterLayout();
1910 }
1911 }, [settleDisclosureAfterLayout]);
1912
18941913 const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
18951914 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
18961915 return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
......@@ -1975,11 +1994,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19751994 // scrolling up through unmeasured content corrects each row's height as it
19761995 // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
19771996 // 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.
19831998 const getFixedItemSize = useCallback(
19841999 (entry: ThreadFeedEntry) => {
19852000 switch (entry.type) {
......@@ -1987,46 +2002,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19872002 return TURN_FOLD_HEIGHT;
19882003 case "work-toggle":
19892004 return WORK_GROUP_TOGGLE_HEIGHT;
1990 case "working":
1991 return workingRowHeight;
19922005 case "activity-group":
19932006 // Expanded rows append a variable detail block — fall back to
19942007 // measurement for those groups.
19952008 return entry.activities.some((activity) => expandedWorkRows[activity.id])
19962009 ? undefined
1997 : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize);
2010 : collapsedWorkLogHeight(entry.activities);
19982011 default:
19992012 return undefined;
20002013 }
20012014 },
2002 [expandedWorkRows, workingRowHeight, appearance.baseFontSize],
2015 [expandedWorkRows],
20032016 );
20042017
20052018 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 ),
20282052 [
20292053 copiedRowId,
2054 disclosureEnteringEntryIds,
20302055 expandedWorkRows,
20312056 terminalAssistantMessageIds,
20322057 unsettledTurnId,
......@@ -2093,7 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20932118 }
20942119 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
20952120 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2096 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
2121 // Patched LegendList prop (patches/@legendapp__list@3.3.5.patch):
20972122 // lets its scroll math clamp programmatic scrolls to -headerInset
20982123 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
20992124 // content rest below the transparent header rather than at frame top.
......@@ -2148,6 +2173,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21482173 entry.type === "message" ? `message:${entry.message.role}` : entry.type
21492174 }
21502175 getFixedItemSize={getFixedItemSize}
2176 itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION}
2177 onItemSizeChanged={handleItemSizeChanged}
21512178 // Measure rows well before they scroll into view so estimate→actual
21522179 // corrections land offscreen instead of under the user's finger.
21532180 drawDistance={500}
apps/mobile/src/features/threads/floating-working-control.tsx created+205
......@@ -0,0 +1,205 @@
1import {
2 isLiquidGlassSupported,
3 LiquidGlassContainerView,
4 LiquidGlassView,
5} from "@callstack/liquid-glass";
6import { useEffect, useState } from "react";
7import { Text as SystemText, View } from "react-native";
8import Animated, {
9 Easing,
10 FadeIn,
11 FadeOut,
12 ReduceMotion,
13 useAnimatedStyle,
14 useSharedValue,
15 withTiming,
16} from "react-native-reanimated";
17import { withUniwind } from "uniwind";
18
19import { AppText as Text } from "../../components/AppText";
20import { ControlPill } from "../../components/ControlPill";
21
22const CONTROL_HEIGHT = 44;
23const CONTROL_COMPOSER_GAP = 8;
24const GLASS_MERGE_SPACING = 12;
25const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
26const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
27const CONTROL_TIMING = {
28 duration: 240,
29 easing: Easing.out(Easing.cubic),
30 reduceMotion: ReduceMotion.System,
31} as const;
32const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2;
33
34const UniwindLiquidGlassView = withUniwind(LiquidGlassView);
35const UniwindLiquidGlassContainerView = withUniwind(LiquidGlassContainerView);
36const AnimatedLiquidGlassView = Animated.createAnimatedComponent(UniwindLiquidGlassView);
37
38export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP;
39
40export function FloatingWorkingControl(props: {
41 readonly colorScheme: "light" | "dark";
42 readonly startedAt: string | null;
43 readonly showScrollToEnd: boolean;
44 readonly onScrollToEnd: () => void;
45}) {
46 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
47
48 useEffect(() => {
49 separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);
50 }, [props.showScrollToEnd, separationProgress]);
51
52 const timerStyle = useAnimatedStyle(() => ({
53 transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],
54 }));
55 const arrowTransformStyle = useAnimatedStyle(() => ({
56 transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],
57 }));
58 const arrowContentStyle = useAnimatedStyle(() => ({
59 opacity: separationProgress.value,
60 }));
61
62 if (props.startedAt === null && !props.showScrollToEnd) {
63 return null;
64 }
65
66 return (
67 <Animated.View
68 pointerEvents="box-none"
69 className="absolute left-0 right-0 z-20 items-center"
70 style={{ top: -FLOATING_WORKING_CONTROL_COVERAGE }}
71 entering={isLiquidGlassSupported ? undefined : CONTROL_ENTERING}
72 exiting={isLiquidGlassSupported ? undefined : CONTROL_EXITING}
73 >
74 {props.startedAt !== null && isLiquidGlassSupported ? (
75 <UniwindLiquidGlassContainerView
76 spacing={GLASS_MERGE_SPACING}
77 pointerEvents="box-none"
78 className="flex-row items-center gap-4"
79 >
80 <AnimatedLiquidGlassView
81 colorScheme={props.colorScheme}
82 effect="regular"
83 pointerEvents="none"
84 className="h-11 justify-center overflow-hidden rounded-full"
85 style={timerStyle}
86 >
87 <WorkingDuration startedAt={props.startedAt} />
88 </AnimatedLiquidGlassView>
89
90 <AnimatedLiquidGlassView
91 colorScheme={props.colorScheme}
92 effect="regular"
93 interactive
94 pointerEvents={props.showScrollToEnd ? "auto" : "none"}
95 accessibilityElementsHidden={!props.showScrollToEnd}
96 importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
97 className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
98 style={arrowTransformStyle}
99 >
100 <Animated.View style={arrowContentStyle}>
101 <ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} />
102 </Animated.View>
103 </AnimatedLiquidGlassView>
104 </UniwindLiquidGlassContainerView>
105 ) : props.startedAt !== null ? (
106 <View pointerEvents="box-none" className="flex-row items-center gap-4">
107 <Animated.View
108 pointerEvents="none"
109 className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"
110 style={timerStyle}
111 >
112 <WorkingDuration startedAt={props.startedAt} />
113 </Animated.View>
114
115 <Animated.View
116 pointerEvents={props.showScrollToEnd ? "auto" : "none"}
117 accessibilityElementsHidden={!props.showScrollToEnd}
118 importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
119 style={[arrowTransformStyle, arrowContentStyle]}
120 >
121 <ControlPill
122 accessibilityLabel="Scroll to end"
123 activateOnPressIn
124 className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"
125 disabled={!props.showScrollToEnd}
126 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
127 onPress={props.onScrollToEnd}
128 />
129 </Animated.View>
130 </View>
131 ) : isLiquidGlassSupported ? (
132 <UniwindLiquidGlassView
133 colorScheme={props.colorScheme}
134 effect="regular"
135 interactive
136 className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
137 >
138 <ScrollToEndButton onPress={props.onScrollToEnd} />
139 </UniwindLiquidGlassView>
140 ) : (
141 <ControlPill
142 accessibilityLabel="Scroll to end"
143 activateOnPressIn
144 className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"
145 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
146 onPress={props.onScrollToEnd}
147 />
148 )}
149 </Animated.View>
150 );
151}
152
153function WorkingDuration(props: { readonly startedAt: string }) {
154 const [nowMs, setNowMs] = useState(() => Date.now());
155
156 useEffect(() => {
157 setNowMs(Date.now());
158 const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
159 return () => clearInterval(intervalId);
160 }, [props.startedAt]);
161
162 const duration = formatWorkingDuration(props.startedAt, nowMs);
163 const label = `Working for ${duration}`;
164
165 return (
166 <View accessible accessibilityLabel={label} className="h-11 flex-row items-center px-4">
167 <Text className="font-t3-medium text-xs text-foreground">Working for </Text>
168 <SystemText
169 className="text-xs text-foreground"
170 style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
171 >
172 {duration}
173 </SystemText>
174 </View>
175 );
176}
177
178function formatWorkingDuration(startedAt: string, nowMs: number): string {
179 const startedAtMs = Date.parse(startedAt);
180 if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) {
181 return "0s";
182 }
183
184 const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000);
185 if (totalSeconds < 60) {
186 return `${totalSeconds}s`;
187 }
188
189 const minutes = Math.floor(totalSeconds / 60);
190 const seconds = String(totalSeconds % 60).padStart(2, "0");
191 return `${minutes}m ${seconds}s`;
192}
193
194function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) {
195 return (
196 <ControlPill
197 accessibilityLabel="Scroll to end"
198 activateOnPressIn
199 className="h-11 w-11 bg-transparent"
200 disabled={props.disabled}
201 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
202 onPress={props.onPress}
203 />
204 );
205}
apps/mobile/src/features/threads/thread-work-log.tsx+311-125
......@@ -1,30 +1,201 @@
11import * as Haptics from "expo-haptics";
22import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { LayoutAnimation, Pressable, ScrollView, View } from "react-native";
3import { MaskedView } from "@expo/ui/community/masked-view";
4import { useIsFocused } from "@react-navigation/native";
5import { useEffect, useId, useState, type ComponentProps } from "react";
6import {
7 AccessibilityInfo,
8 AppState,
9 type ColorValue,
10 Pressable,
11 ScrollView,
12 View,
13} from "react-native";
14import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
415
516import { AppText as Text } from "../../components/AppText";
6import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
717import { cn } from "../../lib/cn";
818import type { ThreadFeedActivity } from "../../lib/threadActivity";
9import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
10import Animated, { FadeIn } from "react-native-reanimated";
19import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation";
20import Animated, {
21 cancelAnimation,
22 Easing,
23 FadeIn,
24 FadeOut,
25 LinearTransition,
26 ReduceMotion,
27 useAnimatedStyle,
28 useSharedValue,
29 withDelay,
30 withRepeat,
31 withSequence,
32 withTiming,
33} from "react-native-reanimated";
1134
12const WORK_LOG_LAYOUT_ANIMATION = {
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;
35const SHIMMER_WIDTH = 72;
36const SHIMMER_SWEEP_MS = 1_350;
37const SHIMMER_PAUSE_MS = 1_450;
38const SHIMMER_ICON_AND_GAP_WIDTH = 30;
39export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
40const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
41const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
42const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
2443
25function triggerDisclosureFeedback() {
26 LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION);
27 void Haptics.selectionAsync();
44function 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
81function ShimmeringWorkContent(props: {
82 readonly icon: AppSymbolName;
83 readonly iconSubtleColor: ColorValue;
84 readonly label: string;
85 readonly showIcon: boolean;
86}) {
87 const [availableWidth, setAvailableWidth] = useState(0);
88 const [textWidth, setTextWidth] = useState(0);
89 const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active");
90 const [reducedMotion, setReducedMotion] = useState(true);
91 const screenIsFocused = useIsFocused();
92 const progress = useSharedValue(0);
93 const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`;
94 const contentWidth = Math.min(availableWidth, SHIMMER_ICON_AND_GAP_WIDTH + Math.ceil(textWidth));
95
96 useEffect(() => {
97 const subscription = AppState.addEventListener("change", (state) => {
98 setAppIsActive(state === "active");
99 });
100 return () => subscription.remove();
101 }, []);
102
103 useEffect(() => {
104 void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion);
105 const subscription = AccessibilityInfo.addEventListener(
106 "reduceMotionChanged",
107 setReducedMotion,
108 );
109 return () => subscription.remove();
110 }, []);
111
112 useEffect(() => {
113 cancelAnimation(progress);
114 progress.value = 0;
115 if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return;
116
117 progress.value = withRepeat(
118 withSequence(
119 withTiming(1, {
120 duration: SHIMMER_SWEEP_MS,
121 easing: Easing.linear,
122 reduceMotion: ReduceMotion.Never,
123 }),
124 withDelay(
125 SHIMMER_PAUSE_MS,
126 withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }),
127 ),
128 ),
129 -1,
130 false,
131 undefined,
132 ReduceMotion.Never,
133 );
134 return () => cancelAnimation(progress);
135 }, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]);
136
137 const sweepStyle = useAnimatedStyle(() => ({
138 transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }],
139 }));
140 const counterSweepStyle = useAnimatedStyle(() => ({
141 transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }],
142 }));
143
144 return (
145 <View
146 className="min-w-0 flex-1"
147 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
148 >
149 <ShimmerWorkContent
150 highlighted={false}
151 icon={props.icon}
152 iconSubtleColor={props.iconSubtleColor}
153 label={props.label}
154 showIcon={props.showIcon}
155 onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)}
156 />
157 {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? (
158 <Animated.View
159 className="absolute inset-y-0 left-0 overflow-hidden"
160 pointerEvents="none"
161 accessible={false}
162 accessibilityElementsHidden
163 importantForAccessibility="no-hide-descendants"
164 style={[{ width: SHIMMER_WIDTH }, sweepStyle]}
165 >
166 <MaskedView
167 className="absolute inset-0"
168 maskElement={
169 <Svg width="100%" height="100%">
170 <Defs>
171 <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
172 <Stop offset="0" stopColor="white" stopOpacity={0} />
173 <Stop offset="0.15" stopColor="white" stopOpacity={0.12} />
174 <Stop offset="0.35" stopColor="white" stopOpacity={0.55} />
175 <Stop offset="0.5" stopColor="white" stopOpacity={1} />
176 <Stop offset="0.65" stopColor="white" stopOpacity={0.55} />
177 <Stop offset="0.85" stopColor="white" stopOpacity={0.12} />
178 <Stop offset="1" stopColor="white" stopOpacity={0} />
179 </LinearGradient>
180 </Defs>
181 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
182 </Svg>
183 }
184 >
185 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
186 <ShimmerWorkContent
187 highlighted
188 icon={props.icon}
189 iconSubtleColor={props.iconSubtleColor}
190 label={props.label}
191 showIcon={props.showIcon}
192 />
193 </Animated.View>
194 </MaskedView>
195 </Animated.View>
196 ) : null}
197 </View>
198 );
28199}
29200
30201function stripShellWrapper(value: string): string {
......@@ -79,44 +250,23 @@ function isFreshRow(createdAt: string): boolean {
79250 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
80251}
81252
82// Tool-like activities with a neutral status carry no signal worth a row.
83export function visibleWorkLogActivities(
84 activities: ReadonlyArray<ThreadFeedActivity>,
85): ReadonlyArray<ThreadFeedActivity> {
86 return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral"));
87}
88
89253// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log
90254// 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.
96258const WORK_ROW_HEIGHT = 32; // min-h-8
97259const WORK_ROW_GAP = 1; // gap-px
98const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label
99260const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1
100261
101262export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
102263
103export function collapsedWorkLogHeight(
104 activities: ReadonlyArray<ThreadFeedActivity>,
105 baseFontSize: number,
106): number {
107 const rows = visibleWorkLogActivities(activities);
264export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
265 const rows = activities;
108266 if (rows.length === 0) {
109267 return 0;
110268 }
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;
120270}
121271
122272export function ThreadWorkLog(props: {
......@@ -127,7 +277,7 @@ export function ThreadWorkLog(props: {
127277 readonly onCopyRow: (rowId: string, value: string) => void;
128278 readonly onToggleRow: (rowId: string) => void;
129279}) {
130 const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
280 const rows = props.activities.map((activity) => ({
131281 ...activity,
132282 detail: compactActivityDetail(activity.detail),
133283 }));
......@@ -136,32 +286,28 @@ export function ThreadWorkLog(props: {
136286 return null;
137287 }
138288
139 const onlyToolRows = rows.every((row) => row.toolLike);
140
141289 return (
142290 <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
149291 <View className="gap-px">
150292 {rows.map((row) => {
151293 const expanded = props.expandedRows[row.id] ?? false;
152294 const canExpand = row.canExpand;
153295 const fullDetail = expanded ? row.getFullDetail() : null;
154 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
296 const displayText = row.detail ?? row.summary;
155297 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
298 const failed = row.status === "failure";
299 const showIcon = !row.groupedToolDetail || iconIsDestructive || failed;
156300
157301 return (
158302 <Animated.View
159303 key={row.id}
304 layout={WORK_LOG_LAYOUT_TRANSITION}
305 className="overflow-hidden"
160306 {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
161307 >
162308 <Pressable
163309 accessibilityRole={canExpand ? "button" : undefined}
164 accessibilityLabel={displayText}
310 accessibilityLabel={failed ? `${displayText}, tool call failed` : displayText}
165311 accessibilityHint={
166312 canExpand
167313 ? "Double tap to show full details. Long press to copy."
......@@ -171,7 +317,7 @@ export function ThreadWorkLog(props: {
171317 hitSlop={4}
172318 onPress={() => {
173319 if (canExpand) {
174 triggerDisclosureFeedback();
320 void Haptics.selectionAsync();
175321 props.onToggleRow(row.id);
176322 }
177323 }}
......@@ -179,29 +325,41 @@ export function ThreadWorkLog(props: {
179325 className="rounded-md px-0.5 py-0 active:bg-subtle"
180326 >
181327 <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}
189334 />
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 )}
205363
206364 <View className="shrink-0 flex-row items-center gap-px">
207365 {props.copiedRowId === row.id ? (
......@@ -223,28 +381,17 @@ export function ThreadWorkLog(props: {
223381 />
224382 ) : null}
225383 </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>
242384 </View>
243385 </View>
244386 </Pressable>
245387
246388 {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 >
248395 <ScrollView
249396 nestedScrollEnabled
250397 directionalLockEnabled
......@@ -259,7 +406,7 @@ export function ThreadWorkLog(props: {
259406 {fullDetail}
260407 </Text>
261408 </ScrollView>
262 </View>
409 </Animated.View>
263410 ) : null}
264411 </Animated.View>
265412 );
......@@ -273,27 +420,24 @@ export function ThreadWorkGroupToggle(props: {
273420 readonly expanded: boolean;
274421 readonly hiddenCount: number;
275422 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;
277427 readonly onToggle: () => void;
278428}) {
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);
290433
291434 return (
292435 <View className="-mx-1 mb-1 px-1 py-0">
293436 <Pressable
294437 accessibilityRole="button"
295438 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"}.`}
297441 hitSlop={4}
298442 onPress={() => {
299443 void Haptics.selectionAsync();
......@@ -301,22 +445,64 @@ export function ThreadWorkGroupToggle(props: {
301445 }}
302446 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
303447 >
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
314454 />
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 />
319480 </Pressable>
320481 </View>
321482 );
322483}
484
485function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
486 switch (kind) {
487 case "read":
488 return { ios: "eye", android: "visibility" };
489 case "edit":
490 return { ios: "square.and.pencil", android: "edit" };
491 case "command":
492 return { ios: "terminal", android: "terminal" };
493 case "search":
494 return { ios: "globe", android: "public" };
495 case "code-search":
496 return "magnifyingglass";
497 case "other":
498 return { ios: "wrench", android: "build" };
499 case "agent-tool":
500 return { ios: "sparkles", android: "auto_awesome" };
501 case "tone-tool":
502 return { ios: "bolt", android: "bolt" };
503 case "dynamic-tool":
504 case "update":
505 case "mixed":
506 return { ios: "hammer", android: "construction" };
507 }
508}
apps/mobile/src/lib/threadActivity.test.ts+242-26
......@@ -411,8 +411,8 @@ describe("buildThreadFeed", () => {
411411
412412 expect(group.activities).toHaveLength(1);
413413 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",
416416 turnId: "turn-1",
417417 summary: "Run tests",
418418 detail: "bun run test",
......@@ -592,7 +592,7 @@ describe("buildThreadFeed", () => {
592592 expect(expanded.map((entry) => entry.id)).toEqual([
593593 "assistant-first",
594594 "turn-fold:turn-1",
595 "tool-completed",
595 "work-toggle:work-group:tool-completed",
596596 "assistant-final",
597597 ]);
598598 });
......@@ -745,6 +745,20 @@ describe("buildThreadFeed", () => {
745745 assistantMessageId: null,
746746 },
747747 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 }),
748762 makeActivity({
749763 id: EventId.make("tool-failed"),
750764 kind: "tool.completed",
......@@ -763,25 +777,26 @@ describe("buildThreadFeed", () => {
763777 });
764778
765779 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([
778781 {
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,
782786 },
783787 ]);
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"]);
785800 });
786801
787802 it("models work-log overflow as list rows", () => {
......@@ -801,6 +816,14 @@ describe("buildThreadFeed", () => {
801816 icon: "command",
802817 toolLike: true,
803818 status,
819 workEntry: {
820 id,
821 createdAt,
822 turnId: null,
823 label: `Tool ${id}`,
824 command: `command ${id}`,
825 tone: "tool",
826 },
804827 });
805828 const feed: ThreadFeedEntry[] = [
806829 {
......@@ -818,26 +841,216 @@ describe("buildThreadFeed", () => {
818841 ];
819842
820843 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({
823846 type: "work-toggle",
824 groupId: "work-group-1",
825 hiddenCount: 2,
847 groupId: "work-group:activity-1",
848 hiddenCount: 3,
826849 expanded: false,
850 summary: "Ran 3 commands",
827851 });
828852
829 const expanded = deriveThreadFeedPresentation(feed, null, new Set(), new Set(["work-group-1"]));
853 const expanded = deriveThreadFeedPresentation(
854 feed,
855 null,
856 new Set(),
857 new Set(["work-group:activity-1"]),
858 );
830859 expect(expanded.map((entry) => entry.id)).toEqual([
860 "work-toggle:work-group:activity-1",
831861 "activity-1",
832862 "activity-2",
833863 "activity-3",
834 "work-toggle:work-group-1",
835864 ]);
836 expect(expanded.at(-1)).toMatchObject({
865 expect(expanded[0]).toMatchObject({
837866 type: "work-toggle",
838867 expanded: true,
839868 });
840869 });
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 });
8411054});
8421055
8431056describe("quiet timeline: nested agents", () => {
......@@ -873,5 +1086,8 @@ describe("quiet timeline: nested agents", () => {
8731086 );
8741087 expect(ids).toContain("nested-done");
8751088 expect(ids).not.toContain("shell-done");
1089 expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([
1090 { type: "activity-group", id: "nested-done" },
1091 ]);
8761092 });
8771093});
apps/mobile/src/lib/threadActivity.ts+234-59
......@@ -13,6 +13,14 @@ import type {
1313 UserInputQuestion,
1414} from "@t3tools/contracts";
1515import { formatDuration } from "@t3tools/shared/orchestrationTiming";
16import {
17 normalizeCompactToolLabel,
18 omitSupersededLifecycleMarkers,
19 summarizeToolGroup,
20 toolGroupSummaryKind,
21 type ToolGroupSummaryKind,
22} from "@t3tools/client-runtime/work-log/presentation";
23import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
1624
1725import * as Arr from "effect/Array";
1826import * as Order from "effect/Order";
......@@ -65,13 +73,15 @@ export interface ThreadFeedActivity {
6573 | "zap";
6674 readonly toolLike: boolean;
6775 readonly status: "success" | "failure" | "neutral" | null;
76 readonly lifecycleStatus?: WorkLogToolLifecycleStatus;
77 readonly workEntry: WorkLogEntry;
78 readonly groupedToolDetail?: boolean;
79 readonly live?: boolean;
6880}
6981
70const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
71
7282type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
7383
74interface WorkLogEntry {
84export interface WorkLogEntry {
7585 id: string;
7686 createdAt: string;
7787 turnId: TurnId | null;
......@@ -85,11 +95,14 @@ interface WorkLogEntry {
8595 itemType?: ToolLifecycleItemType;
8696 requestKind?: PendingApproval["requestKind"];
8797 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
98 sourceActivityKind?: OrchestrationThreadActivity["kind"];
99 toolCallId?: string;
100 agentSpawn?: boolean;
88101 toolData?: unknown;
89102}
90103
91104interface DerivedWorkLogEntry extends WorkLogEntry {
92 activityKind: OrchestrationThreadActivity["kind"];
105 sourceActivityKind: OrchestrationThreadActivity["kind"];
93106 collapseKey?: string;
94107 /** Grouping key for subagent lifecycle rows (one row per agent). */
95108 taskId?: string;
......@@ -112,11 +125,6 @@ type RawThreadFeedEntry =
112125
113126export type ThreadFeedEntry =
114127 | Extract<RawThreadFeedEntry, { type: "message" }>
115 | {
116 readonly type: "working";
117 readonly id: string;
118 readonly createdAt: string;
119 }
120128 | {
121129 readonly type: "activity-group";
122130 readonly id: string;
......@@ -132,7 +140,11 @@ export type ThreadFeedEntry =
132140 readonly groupId: string;
133141 readonly hiddenCount: number;
134142 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;
136148 }
137149 | {
138150 readonly type: "turn-fold";
......@@ -412,8 +424,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
412424 : activity.tone === "approval"
413425 ? "info"
414426 : activity.tone,
415 activityKind: activity.kind,
427 sourceActivityKind: activity.kind,
416428 };
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 }
417437 const itemType = extractWorkLogItemType(payload);
418438 const requestKind = extractWorkLogRequestKind(payload);
419439 if (
......@@ -472,12 +492,13 @@ function collapseDerivedWorkLogEntries(
472492 // Subagent rows collapse by identity, not adjacency (quiet-timeline
473493 // guarantee; mirrors web's session-logic).
474494 const taskRowIndex = new Map<string, number>();
495 const toolLifecycleRowIndex = new Map<string, number>();
475496 for (const entry of entries) {
476497 const isTaskRow =
477498 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");
481502 if (isTaskRow && entry.taskId !== undefined) {
482503 const existingIndex = taskRowIndex.get(entry.taskId);
483504 if (existingIndex !== undefined) {
......@@ -488,30 +509,78 @@ function collapseDerivedWorkLogEntries(
488509 collapsed.push(entry);
489510 continue;
490511 }
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 }
491526 const previous = collapsed.at(-1);
492527 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);
494535 continue;
495536 }
496537 collapsed.push(entry);
538 if (lifecycleKey !== undefined) {
539 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
540 }
497541 }
498542 return collapsed;
499543}
500544
545function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
546 if (
547 entry.sourceActivityKind !== "tool.updated" &&
548 entry.sourceActivityKind !== "tool.completed"
549 ) {
550 return undefined;
551 }
552 return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined;
553}
554
501555function shouldCollapseToolLifecycleEntries(
502556 previous: DerivedWorkLogEntry,
503557 next: DerivedWorkLogEntry,
504558): boolean {
505 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
559 if (
560 previous.sourceActivityKind !== "tool.updated" &&
561 previous.sourceActivityKind !== "tool.completed"
562 ) {
506563 return false;
507564 }
508 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
565 if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") {
509566 return false;
510567 }
511 if (previous.activityKind === "tool.completed") {
568 if (previous.turnId !== next.turnId) {
512569 return false;
513570 }
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 );
515584}
516585
517586function mergeDerivedWorkLogEntries(
......@@ -527,10 +596,13 @@ function mergeDerivedWorkLogEntries(
527596 const requestKind = next.requestKind ?? previous.requestKind;
528597 const collapseKey = next.collapseKey ?? previous.collapseKey;
529598 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
599 const toolCallId = next.toolCallId ?? previous.toolCallId;
530600 const toolData = next.toolData ?? previous.toolData;
531601 return {
532602 ...previous,
533603 ...next,
604 id: previous.id,
605 createdAt: previous.createdAt,
534606 ...(detail ? { detail } : {}),
535607 ...(command ? { command } : {}),
536608 ...(rawCommand ? { rawCommand } : {}),
......@@ -540,6 +612,7 @@ function mergeDerivedWorkLogEntries(
540612 ...(requestKind ? { requestKind } : {}),
541613 ...(collapseKey ? { collapseKey } : {}),
542614 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
615 ...(toolCallId ? { toolCallId } : {}),
543616 ...(toolData !== undefined ? { toolData } : {}),
544617 };
545618}
......@@ -556,9 +629,15 @@ function mergeChangedFiles(
556629}
557630
558631function 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 ) {
560636 return undefined;
561637 }
638 if (entry.toolCallId) {
639 return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
640 }
562641 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
563642 const detail = entry.detail?.trim() ?? "";
564643 const itemType = entry.itemType ?? "";
......@@ -568,10 +647,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
568647 return [itemType, normalizedLabel, detail].join("\u001f");
569648}
570649
571function normalizeCompactToolLabel(value: string): string {
572 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
573}
574
575650function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
576651 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
577652 return true;
......@@ -648,12 +723,12 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
648723
649724function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
650725 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"
653728 ) {
654729 return "message";
655730 }
656 if (entry.activityKind === "runtime.warning") return "warning";
731 if (entry.sourceActivityKind === "runtime.warning") return "warning";
657732 if (entry.requestKind === "command") return "command";
658733 if (entry.requestKind === "file-read") return "eye";
659734 if (entry.requestKind === "file-change") return "edit";
......@@ -1287,10 +1362,14 @@ export function deriveThreadFeedPresentation(
12871362 activeWorkStartedAt: string | null = null,
12881363): ThreadFeedEntry[] {
12891364 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),
12921369 );
12931370 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
1371 const unsettledTurnId = deriveUnsettledTurnId(latestTurn);
1372 const isWorking = activeWorkStartedAt !== null;
12941373 const collapsedEntryIds = new Set<string>();
12951374 for (const fold of foldsByAnchorId.values()) {
12961375 if (!expandedTurnIds.has(fold.turnId)) {
......@@ -1302,6 +1381,13 @@ export function deriveThreadFeedPresentation(
13021381
13031382 const result: ThreadFeedEntry[] = [];
13041383 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;
13051391 const fold = foldsByAnchorId.get(entry.id);
13061392 if (fold) {
13071393 result.push({
......@@ -1314,49 +1400,65 @@ export function deriveThreadFeedPresentation(
13141400 });
13151401 }
13161402 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 );
13181411 }
13191412 }
1320 if (activeWorkStartedAt !== null) {
1321 result.push({
1322 type: "working",
1323 id: "working-indicator-row",
1324 createdAt: activeWorkStartedAt,
1325 });
1326 }
13271413 return result;
13281414}
13291415
13301416function appendPresentedFeedEntry(
13311417 result: ThreadFeedEntry[],
1332 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>,
1418 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>,
13331419 expandedWorkGroupIds: ReadonlySet<string>,
1420 unsettledTurnId: TurnId | null,
1421 isWorking: boolean,
1422 activeTail: boolean,
13341423): void {
13351424 if (entry.type !== "activity-group") {
13361425 result.push(entry);
13371426 return;
13381427 }
13391428
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,
13421438 );
13431439 if (activities.length === 0) {
13441440 return;
13451441 }
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);
13601462 result.push({
13611463 type: "activity-group",
13621464 id: activity.id,
......@@ -1365,16 +1467,87 @@ function appendPresentedFeedEntry(
13651467 activities: [activity],
13661468 });
13671469 }
1470 flushGroupableRun(true);
1471}
1472
1473function appendToolGroupRows(
1474 result: ThreadFeedEntry[],
1475 sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
1476 activities: ReadonlyArray<ThreadFeedActivity>,
1477 expandedWorkGroupIds: ReadonlySet<string>,
1478 unsettledTurnId: TurnId | null,
1479 isWorking: boolean,
1480 activeTail: boolean,
1481): void {
1482 const firstEntry = activities[0]!.workEntry;
1483 const identity = firstEntry.toolCallId
1484 ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}`
1485 : activities[0]!.id;
1486 const groupId = `work-group:${identity}`;
1487 const expanded = expandedWorkGroupIds.has(groupId);
1488 const latestInProgressActivity = activities.findLast(
1489 (activity) =>
1490 isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId,
1491 );
1492 const live = activeTail || latestInProgressActivity !== undefined;
1493 const latestActivity = activeTail
1494 ? activities.at(-1)!
1495 : (latestInProgressActivity ?? activities.at(-1)!);
1496 const summary = live
1497 ? liveToolActivitySummary(latestActivity)
1498 : activities.length === 1 && !activities[0]!.toolLike
1499 ? activities[0]!.workEntry.label
1500 : summarizeToolGroup(activities.map((activity) => activity.workEntry));
13681501 result.push({
13691502 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,
13731506 groupId,
1374 hiddenCount,
1507 hiddenCount: activities.length,
13751508 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,
13771519 });
1520 if (!expanded) {
1521 return;
1522 }
1523 for (const activity of activities) {
1524 result.push({
1525 type: "activity-group",
1526 id: activity.id,
1527 createdAt: activity.createdAt,
1528 turnId: activity.turnId,
1529 activities: [
1530 {
1531 ...activity,
1532 groupedToolDetail: true,
1533 live:
1534 isWorking &&
1535 activity.id === latestActivity.id &&
1536 activity.lifecycleStatus === "inProgress" &&
1537 activity.turnId === unsettledTurnId,
1538 },
1539 ],
1540 });
1541 }
1542}
1543
1544function liveToolActivitySummary(activity: ThreadFeedActivity): string {
1545 const command = activity.workEntry.command?.trim();
1546 if (command) {
1547 const program = commandProgramName(command);
1548 return program ? `Running ${program}` : "Running command";
1549 }
1550 return activity.detail ?? activity.summary;
13781551}
13791552
13801553/**
......@@ -1609,6 +1782,8 @@ export function buildThreadFeed(
16091782 icon: workEntryIcon(entry),
16101783 toolLike: workLogEntryIsToolLike(entry),
16111784 status: workEntryStatus(entry),
1785 ...(entry.toolLifecycleStatus ? { lifecycleStatus: entry.toolLifecycleStatus } : {}),
1786 workEntry: entry,
16121787 },
16131788 };
16141789 }),
apps/web/src/components/chat/MessagesTimeline.tsx+1-170
......@@ -7,6 +7,7 @@ import {
77 type TurnId,
88} from "@t3tools/contracts";
99import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
10import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
1011import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
1112import {
1213 emptyAgentPanelModel,
......@@ -2289,176 +2290,6 @@ function workEntryRawCommand(
22892290 return rawCommand === workEntry.command.trim() ? null : rawCommand;
22902291}
22912292
2292type CommandWrapper = "env" | "sudo";
2293
2294const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
2295 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
2296 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
2297};
2298
2299const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
2300 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]),
2301 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
2302};
2303
2304function tokenizeShellCommand(command: string): string[] | null {
2305 const input = command.trim();
2306 const tokens: string[] = [];
2307 let current = "";
2308 let quote: '"' | "'" | null = null;
2309 let escaping = false;
2310 let substitutionDepth = 0;
2311 let tokenStarted = false;
2312
2313 for (let index = 0; index < input.length; index += 1) {
2314 const character = input[index]!;
2315 if (escaping) {
2316 current += character;
2317 escaping = false;
2318 tokenStarted = true;
2319 continue;
2320 }
2321 if (character === "\\" && quote !== "'") {
2322 const nextCharacter = input[index + 1];
2323 const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current);
2324 if (
2325 (quote === '"' || isWindowsDrivePath) &&
2326 nextCharacter !== undefined &&
2327 nextCharacter !== '"' &&
2328 nextCharacter !== "\\" &&
2329 nextCharacter !== "$" &&
2330 nextCharacter !== "`" &&
2331 nextCharacter !== "\n"
2332 ) {
2333 current += character;
2334 tokenStarted = true;
2335 continue;
2336 }
2337 escaping = true;
2338 tokenStarted = true;
2339 continue;
2340 }
2341 if (quote !== null) {
2342 if (character === quote) {
2343 quote = null;
2344 } else {
2345 current += character;
2346 }
2347 tokenStarted = true;
2348 continue;
2349 }
2350 if (character === "$" && input[index + 1] === "(") {
2351 current += "$(";
2352 substitutionDepth += 1;
2353 tokenStarted = true;
2354 index += 1;
2355 continue;
2356 }
2357 if (character === ")" && substitutionDepth > 0) {
2358 current += character;
2359 substitutionDepth -= 1;
2360 tokenStarted = true;
2361 continue;
2362 }
2363 if (character === '"' || character === "'") {
2364 quote = character;
2365 tokenStarted = true;
2366 continue;
2367 }
2368 if (/\s/u.test(character)) {
2369 if (substitutionDepth > 0) {
2370 current += character;
2371 tokenStarted = true;
2372 continue;
2373 }
2374 if (tokenStarted) {
2375 tokens.push(current);
2376 current = "";
2377 tokenStarted = false;
2378 }
2379 continue;
2380 }
2381 current += character;
2382 tokenStarted = true;
2383 }
2384
2385 if (quote !== null || escaping || substitutionDepth > 0) return null;
2386 if (tokenStarted) tokens.push(current);
2387 return tokens;
2388}
2389
2390function commandProgramName(command: string, depth = 0): string | null {
2391 if (depth >= 8) return null;
2392 const tokens = tokenizeShellCommand(command);
2393 if (tokens === null) return null;
2394 let index = 0;
2395 let wrapper: CommandWrapper | null = null;
2396
2397 while (index < tokens.length) {
2398 const token = tokens[index];
2399 if (!token) return null;
2400 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
2401 index += 1;
2402 continue;
2403 }
2404 const tokenProgram = token.split(/[\\/]/).at(-1);
2405 if (tokenProgram === "env" || tokenProgram === "sudo") {
2406 wrapper = tokenProgram;
2407 index += 1;
2408 continue;
2409 }
2410 if (wrapper !== null && token === "--") {
2411 wrapper = null;
2412 index += 1;
2413 continue;
2414 }
2415 if (wrapper !== null && token.startsWith("-")) {
2416 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
2417 const splitCommand = tokens[index + 1];
2418 return splitCommand ? commandProgramName(splitCommand, depth + 1) : null;
2419 }
2420 if (wrapper === "env" && token.startsWith("--split-string=")) {
2421 return commandProgramName(token.slice("--split-string=".length), depth + 1);
2422 }
2423 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
2424 if (tokens[index + 1] === undefined) return null;
2425 index += 2;
2426 continue;
2427 }
2428 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) {
2429 index += 1;
2430 continue;
2431 }
2432 const equalsIndex = token.indexOf("=");
2433 if (token.startsWith("--") && equalsIndex > 2) {
2434 if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) {
2435 return null;
2436 }
2437 index += 1;
2438 continue;
2439 }
2440 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
2441 let consumesNextToken = false;
2442 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
2443 const shortOption = `-${option}`;
2444 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
2445 consumesNextToken = optionIndex === token.length - 2;
2446 break;
2447 }
2448 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
2449 }
2450 if (consumesNextToken && tokens[index + 1] === undefined) return null;
2451 index += consumesNextToken ? 2 : 1;
2452 continue;
2453 }
2454 return null;
2455 }
2456 return token.split(/[\\/]/).at(-1) || null;
2457 }
2458
2459 return null;
2460}
2461
24622293function liveWorkEntryLabel(
24632294 workEntry: TimelineWorkEntry,
24642295 workspaceRoot: string | undefined,
packages/client-runtime/package.json+8
......@@ -158,6 +158,14 @@
158158 "./state/vcs": {
159159 "types": "./src/state/vcs.ts",
160160 "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"
161169 }
162170 },
163171 "scripts": {
packages/client-runtime/src/work-log/commandLabel.ts created+169
......@@ -0,0 +1,169 @@
1type CommandWrapper = "env" | "sudo";
2
3const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
4 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
5 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
6};
7
8const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
9 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]),
10 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
11};
12
13function tokenizeShellCommand(command: string): string[] | null {
14 const input = command.trim();
15 const tokens: string[] = [];
16 let current = "";
17 let quote: '"' | "'" | null = null;
18 let escaping = false;
19 let substitutionDepth = 0;
20 let tokenStarted = false;
21
22 for (let index = 0; index < input.length; index += 1) {
23 const character = input[index]!;
24 if (escaping) {
25 current += character;
26 escaping = false;
27 tokenStarted = true;
28 continue;
29 }
30 if (character === "\\" && quote !== "'") {
31 const nextCharacter = input[index + 1];
32 const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current);
33 if (
34 (quote === '"' || isWindowsDrivePath) &&
35 nextCharacter !== undefined &&
36 nextCharacter !== '"' &&
37 nextCharacter !== "\\" &&
38 nextCharacter !== "$" &&
39 nextCharacter !== "`" &&
40 nextCharacter !== "\n"
41 ) {
42 current += character;
43 tokenStarted = true;
44 continue;
45 }
46 escaping = true;
47 tokenStarted = true;
48 continue;
49 }
50 if (quote !== null) {
51 if (character === quote) {
52 quote = null;
53 } else {
54 current += character;
55 }
56 tokenStarted = true;
57 continue;
58 }
59 if (character === "$" && input[index + 1] === "(") {
60 current += "$(";
61 substitutionDepth += 1;
62 tokenStarted = true;
63 index += 1;
64 continue;
65 }
66 if (character === ")" && substitutionDepth > 0) {
67 current += character;
68 substitutionDepth -= 1;
69 tokenStarted = true;
70 continue;
71 }
72 if (character === '"' || character === "'") {
73 quote = character;
74 tokenStarted = true;
75 continue;
76 }
77 if (/\s/u.test(character)) {
78 if (substitutionDepth > 0) {
79 current += character;
80 tokenStarted = true;
81 continue;
82 }
83 if (tokenStarted) {
84 tokens.push(current);
85 current = "";
86 tokenStarted = false;
87 }
88 continue;
89 }
90 current += character;
91 tokenStarted = true;
92 }
93
94 if (quote !== null || escaping || substitutionDepth > 0) return null;
95 if (tokenStarted) tokens.push(current);
96 return tokens;
97}
98
99export function commandProgramName(command: string, depth = 0): string | null {
100 if (depth >= 8) return null;
101 const tokens = tokenizeShellCommand(command);
102 if (tokens === null) return null;
103 let index = 0;
104 let wrapper: CommandWrapper | null = null;
105
106 while (index < tokens.length) {
107 const token = tokens[index];
108 if (!token) return null;
109 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
110 index += 1;
111 continue;
112 }
113 const tokenProgram = token.split(/[\\/]/).at(-1);
114 if (tokenProgram === "env" || tokenProgram === "sudo") {
115 wrapper = tokenProgram;
116 index += 1;
117 continue;
118 }
119 if (wrapper !== null && token === "--") {
120 wrapper = null;
121 index += 1;
122 continue;
123 }
124 if (wrapper !== null && token.startsWith("-")) {
125 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
126 const splitCommand = tokens[index + 1];
127 return splitCommand ? commandProgramName(splitCommand, depth + 1) : null;
128 }
129 if (wrapper === "env" && token.startsWith("--split-string=")) {
130 return commandProgramName(token.slice("--split-string=".length), depth + 1);
131 }
132 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
133 if (tokens[index + 1] === undefined) return null;
134 index += 2;
135 continue;
136 }
137 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) {
138 index += 1;
139 continue;
140 }
141 const equalsIndex = token.indexOf("=");
142 if (token.startsWith("--") && equalsIndex > 2) {
143 if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) {
144 return null;
145 }
146 index += 1;
147 continue;
148 }
149 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
150 let consumesNextToken = false;
151 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
152 const shortOption = `-${option}`;
153 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
154 consumesNextToken = optionIndex === token.length - 2;
155 break;
156 }
157 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
158 }
159 if (consumesNextToken && tokens[index + 1] === undefined) return null;
160 index += consumesNextToken ? 2 : 1;
161 continue;
162 }
163 return null;
164 }
165 return token.split(/[\\/]/).at(-1) || null;
166 }
167
168 return null;
169}
packages/client-runtime/src/work-log/presentation.ts created+188
......@@ -0,0 +1,188 @@
1import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts";
2
3export interface WorkLogPresentationEntry {
4 readonly label: string;
5 readonly toolTitle?: string;
6 readonly tone: "thinking" | "tool" | "info" | "error";
7 readonly command?: string;
8 readonly changedFiles?: ReadonlyArray<string>;
9 readonly itemType?: ToolLifecycleItemType;
10 readonly requestKind?: string;
11 readonly turnId?: string | null;
12 readonly toolCallId?: string;
13 readonly toolLifecycleStatus?: string;
14 readonly sourceActivityKind?: string;
15 readonly taskId?: string;
16}
17
18export type ToolGroupAction =
19 | "read"
20 | "edit"
21 | "command"
22 | "code-search"
23 | "search"
24 | "other"
25 | "update";
26
27export type ToolGroupSummaryKind =
28 | ToolGroupAction
29 | "dynamic-tool"
30 | "agent-tool"
31 | "tone-tool"
32 | "mixed";
33
34export function normalizeCompactToolLabel(value: string): string {
35 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
36}
37
38function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
39 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
40 if (entry.command !== undefined && entry.command.trim().length > 0) return true;
41 if (entry.requestKind !== undefined) return true;
42 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
43}
44
45export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
46 return (
47 entry.itemType === "web_search" &&
48 /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
49 );
50}
51
52export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
53 if (
54 entry.requestKind === "file-read" ||
55 entry.itemType === "image_view" ||
56 (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
57 ) {
58 return "read";
59 }
60 if (
61 entry.requestKind === "file-change" ||
62 entry.itemType === "file_change" ||
63 (entry.changedFiles?.length ?? 0) > 0
64 ) {
65 return "edit";
66 }
67 if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
68 return "command";
69 }
70 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
71 if (entry.itemType === "web_search") return "search";
72 return workLogEntryIsToolLike(entry) ? "other" : "update";
73}
74
75function toolGroupActionCount(
76 action: ToolGroupAction,
77 entries: ReadonlyArray<WorkLogPresentationEntry>,
78): number {
79 if (action !== "edit") return entries.length;
80
81 const changedFiles = new Set<string>();
82 let editsWithoutFileDetails = 0;
83 for (const entry of entries) {
84 if (!entry.changedFiles || entry.changedFiles.length === 0) {
85 editsWithoutFileDetails += 1;
86 continue;
87 }
88 for (const file of entry.changedFiles) changedFiles.add(file);
89 }
90 return changedFiles.size + editsWithoutFileDetails;
91}
92
93function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
94 switch (action) {
95 case "read":
96 return `Read ${count} ${count === 1 ? "file" : "files"}`;
97 case "edit":
98 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
99 case "command":
100 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
101 case "search":
102 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
103 case "code-search":
104 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
105 case "other":
106 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
107 case "update":
108 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
109 }
110}
111
112export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): string {
113 const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry);
114 const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>();
115 for (const entry of summaryEntries) {
116 const action = toolGroupAction(entry);
117 const group = groupedEntries.get(action);
118 if (group) group.push(entry);
119 else groupedEntries.set(action, [entry]);
120 }
121 const labels = [...groupedEntries].map(([action, actionEntries]) =>
122 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
123 );
124 const sentenceLabels = labels.map((label, index) =>
125 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
126 );
127 if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
128 if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
129 return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
130}
131
132export function omitSupersededLifecycleMarkers<T>(
133 entries: readonly T[],
134 workEntryFor: (entry: T) => WorkLogPresentationEntry,
135): T[] {
136 const laterTerminalIdentities = new Set<string>();
137 const reversedEntries: T[] = [];
138
139 for (let index = entries.length - 1; index >= 0; index -= 1) {
140 const entry = entries[index]!;
141 const workEntry = workEntryFor(entry);
142 const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label);
143 const identity = [
144 workEntry.turnId ?? "no-turn",
145 workEntry.itemType ?? "",
146 normalizedLabel,
147 ].join("\u001f");
148 const activityKind = workEntry.sourceActivityKind;
149 const isStatuslessIdlessMarker =
150 workEntry.toolCallId === undefined &&
151 workEntry.toolLifecycleStatus === undefined &&
152 (activityKind === "tool.started" || activityKind === "tool.updated");
153 if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue;
154
155 reversedEntries.push(entry);
156 if (
157 activityKind === "tool.completed" ||
158 (workEntry.toolLifecycleStatus !== undefined &&
159 workEntry.toolLifecycleStatus !== "inProgress")
160 ) {
161 laterTerminalIdentities.add(identity);
162 }
163 }
164
165 return reversedEntries.toReversed();
166}
167
168export function toolGroupSummaryKind(
169 entries: ReadonlyArray<WorkLogPresentationEntry>,
170): ToolGroupSummaryKind {
171 const actions = new Set(entries.map(toolGroupAction));
172 if (actions.size !== 1) return "mixed";
173
174 const action = actions.values().next().value!;
175 if (action !== "other") return action;
176
177 const fallbackKinds = new Set(
178 entries.map((entry): ToolGroupSummaryKind => {
179 if (entry.itemType === "mcp_tool_call") return "other";
180 if (entry.itemType === "dynamic_tool_call") return "dynamic-tool";
181 if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool";
182 if (entry.tone === "thinking") return "agent-tool";
183 if (entry.tone === "tool") return "tone-tool";
184 return "other";
185 }),
186 );
187 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
188}
patches/@legendapp__list@3.3.5.patch+696-82
......@@ -1,5 +1,5 @@
11diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644
2index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644
33--- a/keyboard.d.ts
44+++ b/keyboard.d.ts
55@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
......@@ -7,7 +7,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776
77 current: Pick<View, "measure"> | null;
88 };
99-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): {
1111 contentInsetEndAdjustment: SharedValue<number>;
1212 onComposerLayout: (event: LayoutChangeEvent) => void;
1313 };
......@@ -23,15 +23,15 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776
2323 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
2424
2525diff --git a/keyboard.js b/keyboard.js
26index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644
26index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644
2727--- a/keyboard.js
2828+++ 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.
3030 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
3131 );
3232 }
3333-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) {
3535 const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
3636 const lastHeightRef = React.useRef(void 0);
3737 const reportHeight = React.useCallback(
......@@ -40,17 +40,21 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
4040+ const height = Math.max(0, rawHeight + heightAdjustment);
4141 var _a;
4242 if (Number.isFinite(height) && height !== lastHeightRef.current) {
43+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
4344 lastHeightRef.current = height;
44 contentInsetEndAdjustment.value = height;
45- contentInsetEndAdjustment.value = height;
4546- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
47+ contentInsetEndAdjustment.value = shouldAnimate ? reactNativeReanimated.withTiming(height, {
48+ duration: animationDuration
49+ }) : height;
4650 }
4751 },
4852- [contentInsetEndAdjustment, listRef]
49+ [contentInsetEndAdjustment, heightAdjustment, listRef]
53+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
5054 );
5155 React.useLayoutEffect(() => {
5256 var _a;
53@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
57@@ -84,9 +87,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
5458 }
5559 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
5660 const {
......@@ -62,7 +66,15 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
6266 freeze,
6367 keyboardLiftBehavior,
6468 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(
6678 ...anchoredEndSpace,
6779 onSizeChanged: (size) => {
6880 var _a;
......@@ -80,23 +92,25 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
8092 const onContentInsetChange = React.useCallback((insets) => {
8193 var _a;
8294 (_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(
8496 reactNativeKeyboardController.KeyboardChatScrollView,
8597 {
8698 ...scrollProps,
8799+ adjustedInsetCompensation,
100+ adjustedStartInsetCompensation,
88101 applyWorkaroundForContentInsetHitTestBug,
89102 blankSpace,
90103 extraContentPadding: contentInsetEndAdjustment,
91@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
104@@ -134,6 +146,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
92105 );
93106 },
94107 [
95108+ adjustedInsetCompensation,
109+ adjustedStartInsetCompensation,
96110 applyWorkaroundForContentInsetHitTestBug,
97111 blankSpace,
98112 contentInsetEndAdjustment,
99@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
113@@ -149,6 +163,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
100114 {
101115 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
102116 anchoredEndSpaceOwnerInternal: "scroll",
......@@ -105,15 +119,24 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc
105119 renderScrollComponent: memoList,
106120 ...rest
107121diff --git a/keyboard.mjs b/keyboard.mjs
108index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644
122index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644
109123--- a/keyboard.mjs
110124+++ 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) {
112135 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
113136 );
114137 }
115138-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) {
117140 const contentInsetEndAdjustment = useSharedValue(initialHeight);
118141 const lastHeightRef = useRef(void 0);
119142 const reportHeight = useCallback(
......@@ -122,17 +145,21 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af
122145+ const height = Math.max(0, rawHeight + heightAdjustment);
123146 var _a;
124147 if (Number.isFinite(height) && height !== lastHeightRef.current) {
148+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
125149 lastHeightRef.current = height;
126 contentInsetEndAdjustment.value = height;
150- contentInsetEndAdjustment.value = height;
127151- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
152+ contentInsetEndAdjustment.value = shouldAnimate ? withTiming(height, {
153+ duration: animationDuration
154+ }) : height;
128155 }
129156 },
130157- [contentInsetEndAdjustment, listRef]
131+ [contentInsetEndAdjustment, heightAdjustment, listRef]
158+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
132159 );
133160 useLayoutEffect(() => {
134161 var _a;
135@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
162@@ -63,9 +66,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
136163 }
137164 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
138165 const {
......@@ -144,7 +171,15 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af
144171 freeze,
145172 keyboardLiftBehavior,
146173 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(
148183 ...anchoredEndSpace,
149184 onSizeChanged: (size) => {
150185 var _a;
......@@ -162,23 +197,25 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af
162197 const onContentInsetChange = useCallback((insets) => {
163198 var _a;
164199 (_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(
166201 KeyboardChatScrollView,
167202 {
168203 ...scrollProps,
169204+ adjustedInsetCompensation,
205+ adjustedStartInsetCompensation,
170206 applyWorkaroundForContentInsetHitTestBug,
171207 blankSpace,
172208 extraContentPadding: contentInsetEndAdjustment,
173@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
209@@ -113,6 +125,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
174210 );
175211 },
176212 [
177213+ adjustedInsetCompensation,
214+ adjustedStartInsetCompensation,
178215 applyWorkaroundForContentInsetHitTestBug,
179216 blankSpace,
180217 contentInsetEndAdjustment,
181@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
218@@ -128,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
182219 {
183220 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
184221 anchoredEndSpaceOwnerInternal: "scroll",
......@@ -204,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a
204241 * Number of columns to render items in.
205242 * @default 1
206243diff --git a/react-native.js b/react-native.js
207index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4a5493efb 100644
244index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644
208245--- a/react-native.js
209246+++ b/react-native.js
210247@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, {
......@@ -216,7 +253,27 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
216253 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
217254 set$(ctx, "readyToRender", true);
218255 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) {
220277 }
221278
222279 // src/core/clampScrollOffset.ts
......@@ -242,7 +299,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
242299 return clampedOffset;
243300 }
244301
245@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) {
302@@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) {
246303 finishScrollTo(ctx);
247304 }
248305 }
......@@ -255,7 +312,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
255312 x: ctx.state.props.horizontal ? offset : 0,
256313 y: ctx.state.props.horizontal ? 0 : offset
257314 });
258@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) {
315@@ -1503,7 +1512,10 @@ function checkFinishedScrollFallback(ctx) {
259316 );
260317 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
261318 } else if (shouldRetryUnalignedEndScroll) {
......@@ -267,7 +324,17 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
267324 scheduleFallbackCheck(100);
268325 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
269326 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;
271338 }
272339 if (shouldMaintainScrollAtEnd) {
273340 state.pendingMaintainScrollAtEnd = false;
......@@ -287,7 +354,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
287354 }
288355 if (!state.maintainingScrollAtEnd) {
289356 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
290@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) {
357@@ -1591,9 +1616,18 @@ function doMaintainScrollAtEnd(ctx) {
291358 y: 0
292359 });
293360 } else {
......@@ -309,7 +376,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
309376 }
310377 setTimeout(
311378 () => {
312@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) {
379@@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) {
313380 function requestAdjust(ctx, positionDiff, dataChanged) {
314381 const state = ctx.state;
315382 if (Math.abs(positionDiff) > 0.1) {
......@@ -320,7 +387,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
320387 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
321388 const doit = () => {
322389 if (needsScrollWorkaround) {
323@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
390@@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
324391 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
325392 return 0;
326393 }
......@@ -331,7 +398,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
331398 const clampDelta = maxScroll - state.scroll;
332399 if (unresolvedAmount < 0) {
333400 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) {
335402 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
336403 return true;
337404 }
......@@ -340,7 +407,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
340407 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
341408 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
342409 if (isAtExpectedNativeClamp) {
343@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) {
410@@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) {
344411 if (diff > 0) {
345412 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
346413 } else {
......@@ -349,7 +416,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
349416 state.scroll = maxScroll;
350417 state.scrollPending = maxScroll;
351418 diff = 0;
352@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, {
419@@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, {
353420 }
354421
355422 // src/core/initialScroll.ts
......@@ -471,7 +538,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
471538 const requestedIndex = target.index;
472539 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
473540 const itemSize = getItemSizeAtIndex(ctx, index);
474@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
541@@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
475542 return;
476543 }
477544 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
......@@ -482,7 +549,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
482549 if (!shouldKeepEndTargetAlive) {
483550 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
484551 clearPendingInitialScrollFooterLayout(ctx, {
485@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
552@@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
486553 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
487554 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
488555 isReady = !hasUnknownTailSize;
......@@ -492,7 +559,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
492559 } else if (anchorIndex >= 0) {
493560 isReady = false;
494561 }
495@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
562@@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
496563 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
497564 }
498565 (_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
505572 }
506573 return nextSize;
507574 }
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
509653 dataVersion,
510654 drawDistance = 250,
511655 contentInsetEndAdjustment,
......@@ -513,7 +657,20 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
513657 estimatedItemSize = 100,
514658 estimatedListSize,
515659 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
517674 const combinedRef = useCombinedRef(refScroller, refScrollView);
518675 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
519676 const stickyHeaderIndices = stickyHeaderIndicesProp;
......@@ -522,7 +679,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
522679 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
523680 const alwaysRenderIndices = React2.useMemo(() => {
524681 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
526683 contentContainerAlignItems: contentContainerStyle.alignItems,
527684 contentInset,
528685 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
......@@ -530,7 +687,15 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
530687 data: dataProp,
531688 dataKey,
532689 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
534699 return void 0;
535700 }
536701 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
......@@ -544,15 +709,43 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
544709 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
545710 }, [usesBootstrapInitialScroll]);
546711 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
548729 onScroll: (event) => onScroll(ctx, event),
549730 onScrollBeginDrag: (event) => {
550731 var _a4, _b2;
551732+ ctx.state.didUserDrag = true;
733+ ctx.state.isUserDragging = true;
552734 prepareReachedEdgeForNextUserScroll(ctx);
553735 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
554736 },
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,
556749 onLayout,
557750 onLayoutFooter,
558751 onMomentumScrollEnd: fns.onMomentumScrollEnd,
......@@ -561,7 +754,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4
561754 recycleItems,
562755 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
563756diff --git a/react-native.mjs b/react-native.mjs
564index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644
757index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644
565758--- a/react-native.mjs
566759+++ b/react-native.mjs
567760@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, {
......@@ -573,7 +766,27 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
573766 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
574767 set$(ctx, "readyToRender", true);
575768 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) {
577790 }
578791
579792 // src/core/clampScrollOffset.ts
......@@ -599,7 +812,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
599812 return clampedOffset;
600813 }
601814
602@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) {
815@@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) {
603816 finishScrollTo(ctx);
604817 }
605818 }
......@@ -612,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
612825 x: ctx.state.props.horizontal ? offset : 0,
613826 y: ctx.state.props.horizontal ? 0 : offset
614827 });
615@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) {
828@@ -1482,7 +1491,10 @@ function checkFinishedScrollFallback(ctx) {
616829 );
617830 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
618831 } else if (shouldRetryUnalignedEndScroll) {
......@@ -624,7 +837,17 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
624837 scheduleFallbackCheck(100);
625838 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
626839 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;
628851 }
629852 if (shouldMaintainScrollAtEnd) {
630853 state.pendingMaintainScrollAtEnd = false;
......@@ -644,7 +867,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
644867 }
645868 if (!state.maintainingScrollAtEnd) {
646869 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
647@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) {
870@@ -1570,9 +1595,18 @@ function doMaintainScrollAtEnd(ctx) {
648871 y: 0
649872 });
650873 } else {
......@@ -666,7 +889,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
666889 }
667890 setTimeout(
668891 () => {
669@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) {
892@@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) {
670893 function requestAdjust(ctx, positionDiff, dataChanged) {
671894 const state = ctx.state;
672895 if (Math.abs(positionDiff) > 0.1) {
......@@ -677,7 +900,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
677900 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
678901 const doit = () => {
679902 if (needsScrollWorkaround) {
680@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
903@@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
681904 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
682905 return 0;
683906 }
......@@ -688,7 +911,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
688911 const clampDelta = maxScroll - state.scroll;
689912 if (unresolvedAmount < 0) {
690913 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) {
692915 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
693916 return true;
694917 }
......@@ -697,7 +920,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
697920 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
698921 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
699922 if (isAtExpectedNativeClamp) {
700@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) {
923@@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) {
701924 if (diff > 0) {
702925 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
703926 } else {
......@@ -706,7 +929,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
706929 state.scroll = maxScroll;
707930 state.scrollPending = maxScroll;
708931 diff = 0;
709@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, {
932@@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, {
710933 }
711934
712935 // src/core/initialScroll.ts
......@@ -828,7 +1051,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
8281051 const requestedIndex = target.index;
8291052 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
8301053 const itemSize = getItemSizeAtIndex(ctx, index);
831@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
1054@@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
8321055 return;
8331056 }
8341057 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
......@@ -839,7 +1062,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
8391062 if (!shouldKeepEndTargetAlive) {
8401063 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
8411064 clearPendingInitialScrollFooterLayout(ctx, {
842@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1065@@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
8431066 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
8441067 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
8451068 isReady = !hasUnknownTailSize;
......@@ -849,7 +1072,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
8491072 } else if (anchorIndex >= 0) {
8501073 isReady = false;
8511074 }
852@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1075@@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
8531076 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
8541077 }
8551078 (_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
8621085 }
8631086 return nextSize;
8641087 }
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
8661166 dataVersion,
8671167 drawDistance = 250,
8681168 contentInsetEndAdjustment,
......@@ -870,7 +1170,20 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
8701170 estimatedItemSize = 100,
8711171 estimatedListSize,
8721172 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
8741187 const combinedRef = useCombinedRef(refScroller, refScrollView);
8751188 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
8761189 const stickyHeaderIndices = stickyHeaderIndicesProp;
......@@ -879,7 +1192,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
8791192 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
8801193 const alwaysRenderIndices = useMemo(() => {
8811194 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
8831196 contentContainerAlignItems: contentContainerStyle.alignItems,
8841197 contentInset,
8851198 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
......@@ -887,7 +1200,15 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
8871200 data: dataProp,
8881201 dataKey,
8891202 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
8911212 return void 0;
8921213 }
8931214 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
......@@ -901,15 +1222,43 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
9011222 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
9021223 }, [usesBootstrapInitialScroll]);
9031224 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
9051242 onScroll: (event) => onScroll(ctx, event),
9061243 onScrollBeginDrag: (event) => {
9071244 var _a4, _b2;
9081245+ ctx.state.didUserDrag = true;
1246+ ctx.state.isUserDragging = true;
9091247 prepareReachedEdgeForNextUserScroll(ctx);
9101248 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
9111249 },
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,
9131262 onLayout,
9141263 onLayoutFooter,
9151264 onMomentumScrollEnd: fns.onMomentumScrollEnd,
......@@ -918,7 +1267,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c
9181267 recycleItems,
9191268 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
9201269diff --git a/react.js b/react.js
921index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9b774d49b 100644
1270index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644
9221271--- a/react.js
9231272+++ b/react.js
9241273@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
......@@ -931,8 +1280,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9
9311280 } else if (anchorIndex >= 0) {
9321281 isReady = false;
9331282 }
934@@ -4721,7 +4722,13 @@ function maybeUpdateAnchoredEndSpace(ctx) {
935 updateContentMetricsState(ctx);
1283@@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
9361284 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
9371285 }
9381286 (_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
9451293 }
9461294 return nextSize;
9471295 }
948@@ -6448,8 +6455,8 @@ function ScrollAdjust() {
1296@@ -6446,8 +6453,8 @@ function ScrollAdjust() {
9491297 window.getComputedStyle(contentNode)[axis.paddingEndProp]
9501298 );
9511299 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
......@@ -956,7 +1304,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9
9561304 scrollBy();
9571305 if (resetPaddingRafRef.current !== void 0) {
9581306diff --git a/react.mjs b/react.mjs
959index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e86820262da4 100644
1307index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644
9601308--- a/react.mjs
9611309+++ b/react.mjs
9621310@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
......@@ -969,8 +1317,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868
9691317 } else if (anchorIndex >= 0) {
9701318 isReady = false;
9711319 }
972@@ -4700,7 +4701,13 @@ function maybeUpdateAnchoredEndSpace(ctx) {
973 updateContentMetricsState(ctx);
1320@@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
9741321 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
9751322 }
9761323 (_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
9831330 }
9841331 return nextSize;
9851332 }
986@@ -6427,8 +6434,8 @@ function ScrollAdjust() {
1333@@ -6425,8 +6432,8 @@ function ScrollAdjust() {
9871334 window.getComputedStyle(contentNode)[axis.paddingEndProp]
9881335 );
9891336 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
......@@ -1011,19 +1358,22 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613
10111358 * Number of columns to render items in.
10121359 * @default 1
10131360diff --git a/reanimated.js b/reanimated.js
1014index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644
1361index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644
10151362--- a/reanimated.js
10161363+++ 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;
10181366 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
10191367 const prevItemKeyRef = React__namespace.useRef(void 0);
1368+ const previousPositionRef = React__namespace.useRef(positionValue);
1369+ const lastContentSizeEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
10201370 let shouldSkipTransitionForRecycleReuse = false;
10211371- if (recycleItems && layoutTransition) {
10221372+ if (layoutTransition) {
10231373 const itemKeySignal = `containerItemKey${id}`;
10241374 const itemKey = peek$(ctx, itemKeySignal);
10251375 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(
10271377 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
10281378 [horizontal, positionValue, style]
10291379 );
......@@ -1035,30 +1385,168 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274
10351385+ // transition exists to smooth at-rest shifts (streaming text, work-log
10361386+ // folds), so gate it to at-rest moments.
10371387+ 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;
10401399 return /* @__PURE__ */ React__namespace.createElement(
10411400 Reanimated__default.default.View,
10421401 {
10431402- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1044+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
1403+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
10451404 ref: refView,
10461405 style: viewStyle,
10471406 ...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 } : {}
10481524diff --git a/reanimated.mjs b/reanimated.mjs
1049index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644
1525index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644
10501526--- a/reanimated.mjs
10511527+++ 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;
10531539 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
10541540 const prevItemKeyRef = React.useRef(void 0);
1541+ const previousPositionRef = React.useRef(positionValue);
1542+ const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
10551543 let shouldSkipTransitionForRecycleReuse = false;
10561544- if (recycleItems && layoutTransition) {
10571545+ if (layoutTransition) {
10581546 const itemKeySignal = `containerItemKey${id}`;
10591547 const itemKey = peek$(ctx, itemKeySignal);
10601548 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(
10621550 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
10631551 [horizontal, positionValue, style]
10641552 );
......@@ -1070,13 +1558,139 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a
10701558+ // transition exists to smooth at-rest shifts (streaming text, work-log
10711559+ // folds), so gate it to at-rest moments.
10721560+ 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;
10751572 return /* @__PURE__ */ React.createElement(
10761573 Reanimated.View,
10771574 {
10781575- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1079+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
1576+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
10801577 ref: refView,
10811578 style: viewStyle,
10821579 ...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 @@
11diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js
2index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644
2index db8cfb1d289f91563f13c4dd842c783c99facc32..64c7e38ad8d9e866b15a53e561509e6b0ad33cb9 100644
33--- a/lib/commonjs/components/KeyboardChatScrollView/index.js
44+++ 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)(({
66 offset = 0,
77 extraContentPadding = ZERO_CONTENT_PADDING,
88 blankSpace = ZERO_BLANK_SPACE,
99+ adjustedInsetCompensation = 0,
10+ adjustedStartInsetCompensation = 0,
1011 applyWorkaroundForContentInsetHitTestBug = false,
1112 onLayout: onLayoutProp,
1213 onContentSizeChange: onContentSizeChangeProp,
......@@ -14,13 +15,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414
1415 onEndVisible,
1516 ...rest
1617 }, ref) => {
17@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
18@@ -50,13 +53,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
1819 freeze: freezeSV,
1920 offset,
2021 blankSpace,
2122- extraContentPadding
2223+ extraContentPadding,
23+ adjustedInsetCompensation
24+ adjustedInsetCompensation,
25+ adjustedStartInsetCompensation
2426 });
2527 (0, _useExtraContentPadding.useExtraContentPadding)({
2628 scrollViewRef,
......@@ -28,17 +30,23 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414
2830 keyboardPadding: padding,
2931 blankSpace,
3032+ adjustedInsetCompensation,
33+ adjustedStartInsetCompensation,
3134 scroll,
3235 layout,
3336 size,
34@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
37@@ -82,10 +89,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
3538 // a bug for you, please open an issue.
3639 const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
3740
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+
3846+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
3947+ // to the consumer - a virtualized list needs it in its own scroll math or
4048+ // 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) => {
4250+ if (onContentInsetChange && current !== previous) {
4351+ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({
4452+ bottom: current
......@@ -54,21 +62,57 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414
5462 const onLayout = (0, _react.useCallback)(e => {
5563 onLayoutInternal(e);
5664 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
65diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
66index 4d33b3fe426070cf312fc493ce902b2c24d853ea..e619708b95865afd8b43dd8f0a6523fcf67b1f53 100644
67--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
68+++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
69@@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight,
70 * ```
71 */
72 exports.clampedScrollTarget = clampedScrollTarget;
73-const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => {
74+const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => {
75 "worklet";
76
77 const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight;
78@@ -214,8 +214,9 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight,
79 const maxScroll = Math.max(contentHeight - layoutHeight, 0);
80 return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax);
81 }
82- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
83- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
84+ const minScroll = -startInsetCompensation;
85+ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll);
86+ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll);
87 };
88 exports.computeIOSContentOffset = computeIOSContentOffset;
89 //# sourceMappingURL=helpers.js.map
5790diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
58index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644
91index 2073da84b8b2be291aa3181700c2b18a75d0fc56..7ae9bb220ce3cc23c788a79677afdc7d9547a27c 100644
5992--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
6093+++ 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) {
6295 freeze,
6396 offset,
6497 blankSpace,
6598- extraContentPadding
6699+ extraContentPadding,
67+ adjustedInsetCompensation
100+ adjustedInsetCompensation,
101+ adjustedStartInsetCompensation
68102 } = options;
69103 const padding = (0, _reactNativeReanimated.useSharedValue)(0);
70104 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) {
72116 const visiblePadding = visibleFraction * blankSpace.value;
73117 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
74118 const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed);
......@@ -77,28 +121,81 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e
77121
78122 // persistent mode: when keyboard shrinks, clamp to valid range
79123 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 }
81176 const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
177+ currentHeight.value = effective;
82178 padding.value = effective;
83179 }
84180- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
85+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);
181+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]);
86182 return {
87183 padding,
88184 currentHeight,
89185diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
90index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644
186index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..e0b73653fd1dd23f0f2e2c85835a3d107e3f666d 100644
91187--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
92188+++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
93@@ -29,6 +29,7 @@ function useExtraContentPadding(options) {
189@@ -29,6 +29,8 @@ function useExtraContentPadding(options) {
94190 extraContentPadding,
95191 keyboardPadding,
96192 blankSpace,
97193+ adjustedInsetCompensation,
194+ adjustedStartInsetCompensation,
98195 scroll,
99196 layout,
100197 size,
101@@ -68,8 +69,8 @@ function useExtraContentPadding(options) {
198@@ -68,8 +70,8 @@ function useExtraContentPadding(options) {
102199 }
103200
104201 // Compute effective delta considering blankSpace floor
......@@ -109,33 +206,41 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da
109206 const effectiveDelta = currentTotal - previousTotal;
110207 if (effectiveDelta === 0) {
111208 // 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));
114218 scrollToTarget(target);
115219 }
116220- }, [inverted, keyboardLiftBehavior]);
117+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);
221+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]);
118222 }
119223 //# sourceMappingURL=index.js.map
120\ No newline at end of file
121224diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js
122index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644
225index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..76b858a6590ad0592cc663864ea822db55eaeceb 100644
123226--- a/lib/module/components/KeyboardChatScrollView/index.js
124227+++ b/lib/module/components/KeyboardChatScrollView/index.js
125228@@ -1,7 +1,7 @@
126229 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); }
127230 import React, { forwardRef, useCallback, useMemo } from "react";
128 import { StyleSheet } from "react-native";
231-import { StyleSheet } from "react-native";
129232-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
233+import { Platform, StyleSheet } from "react-native";
130234+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
131235 import Reanimated from "react-native-reanimated";
132236 import useCombinedRef from "../hooks/useCombinedRef";
133237 import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
134@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
238@@ -19,9 +19,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
135239 offset = 0,
136240 extraContentPadding = ZERO_CONTENT_PADDING,
137241 blankSpace = ZERO_BLANK_SPACE,
138242+ adjustedInsetCompensation = 0,
243+ adjustedStartInsetCompensation = 0,
139244 applyWorkaroundForContentInsetHitTestBug = false,
140245 onLayout: onLayoutProp,
141246 onContentSizeChange: onContentSizeChangeProp,
......@@ -143,13 +248,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116
143248 onEndVisible,
144249 ...rest
145250 }, ref) => {
146@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
251@@ -43,13 +46,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
147252 freeze: freezeSV,
148253 offset,
149254 blankSpace,
150255- extraContentPadding
151256+ extraContentPadding,
152+ adjustedInsetCompensation
257+ adjustedInsetCompensation,
258+ adjustedStartInsetCompensation
153259 });
154260 useExtraContentPadding({
155261 scrollViewRef,
......@@ -157,17 +263,23 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116
157263 keyboardPadding: padding,
158264 blankSpace,
159265+ adjustedInsetCompensation,
266+ adjustedStartInsetCompensation,
160267 scroll,
161268 layout,
162269 size,
163@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
270@@ -75,10 +82,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
164271 // a bug for you, please open an issue.
165272 const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
166273
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+
167279+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
168280+ // to the consumer - a virtualized list needs it in its own scroll math or
169281+ // its end/maintain targets point at the under-the-keyboard resting offset.
170+ useAnimatedReaction(() => totalPadding.value, (current, previous) => {
282+ useAnimatedReaction(() => reportedPadding.value, (current, previous) => {
171283+ if (onContentInsetChange && current !== previous) {
172284+ runOnJS(onContentInsetChange)({
173285+ bottom: current
......@@ -183,21 +295,56 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116
183295 const onLayout = useCallback(e => {
184296 onLayoutInternal(e);
185297 onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
298diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
299index 295e2219b560519b8d033968b5105d1da3e77315..ff7ee3fe0d880c2287a7b70d5ecf952e99357adf 100644
300--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
301+++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js
302@@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH
303 * computeIOSContentOffset(100, 300, 1000, 800, false); // 400
304 * ```
305 */
306-export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => {
307+export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => {
308 "worklet";
309
310 const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight;
311@@ -201,7 +201,8 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH
312 const maxScroll = Math.max(contentHeight - layoutHeight, 0);
313 return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax);
314 }
315- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
316- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
317+ const minScroll = -startInsetCompensation;
318+ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll);
319+ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll);
320 };
321 //# sourceMappingURL=helpers.js.map
186322diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
187index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644
323index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..cc4bfae859191aeb848179028b7217098cba2340 100644
188324--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
189325+++ 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) {
191327 freeze,
192328 offset,
193329 blankSpace,
194330- extraContentPadding
195331+ extraContentPadding,
196+ adjustedInsetCompensation
332+ adjustedInsetCompensation,
333+ adjustedStartInsetCompensation
197334 } = options;
198335 const padding = useSharedValue(0);
199336 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) {
201348 const visiblePadding = visibleFraction * blankSpace.value;
202349 const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
203350 const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);
......@@ -206,28 +353,81 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669
206353
207354 // persistent mode: when keyboard shrinks, clamp to valid range
208355 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 }
210408 const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
409+ currentHeight.value = effective;
211410 padding.value = effective;
212411 }
213412- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
214+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);
413+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]);
215414 return {
216415 padding,
217416 currentHeight,
218417diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
219index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644
418index 1afa50987a8d2a5fe3f36b20945efe804d48a873..7b05d0721c50e118cf78c07913b580a31293db77 100644
220419--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
221420+++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
222@@ -23,6 +23,7 @@ function useExtraContentPadding(options) {
421@@ -23,6 +23,8 @@ function useExtraContentPadding(options) {
223422 extraContentPadding,
224423 keyboardPadding,
225424 blankSpace,
226425+ adjustedInsetCompensation,
426+ adjustedStartInsetCompensation,
227427 scroll,
228428 layout,
229429 size,
230@@ -62,8 +63,8 @@ function useExtraContentPadding(options) {
430@@ -62,8 +64,8 @@ function useExtraContentPadding(options) {
231431 }
232432
233433 // Compute effective delta considering blankSpace floor
......@@ -238,61 +438,85 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3
238438 const effectiveDelta = currentTotal - previousTotal;
239439 if (effectiveDelta === 0) {
240440 // 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));
243450 scrollToTarget(target);
244451 }
245452- }, [inverted, keyboardLiftBehavior]);
246+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);
453+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]);
247454 }
248455 export { useExtraContentPadding };
249456 //# sourceMappingURL=index.js.map
250\ No newline at end of file
251457diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
252index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644
458index a036b431f03efb9d5379527c17db6fce62bfee09..5e56bf7edec9161698a745e8e6292bfccd68d3db 100644
253459--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts
254460+++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
255@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = {
461@@ -86,6 +86,10 @@ export type KeyboardChatScrollViewProps = {
256462 * Default is `undefined` (equivalent to `0` — no minimum floor).
257463 */
258464 blankSpace?: SharedValue<number>;
259465+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
260466+ adjustedInsetCompensation?: number;
467+ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */
468+ adjustedStartInsetCompensation?: number;
261469 /**
262470 * Fires whenever the effective content inset changes — the static `contentInset`
263471 * prop combined with the dynamic keyboard-driven padding.
472diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
473index 2b51cc4737b4a05b5396da31e67e54177f43c498..3acec1fbddee6227915590a6c8d449757cb4d7b2 100644
474--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
475+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts
476@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH
477 * computeIOSContentOffset(100, 300, 1000, 800, false); // 400
478 * ```
479 */
480-export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number;
481+export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number;
264482diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
265index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644
483index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..fdd83953271123be6caefaa786c469f0d92cce2b 100644
266484--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
267485+++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
268@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = {
486@@ -9,6 +9,10 @@ type UseChatKeyboardOptions = {
269487 blankSpace: SharedValue<number>;
270488 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
271489 extraContentPadding: SharedValue<number>;
272490+ /** Safe-area extra beyond raw contentInset. Offset math only. */
273491+ adjustedInsetCompensation: number;
492+ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */
493+ adjustedStartInsetCompensation: number;
274494 };
275495 type UseChatKeyboardReturn = {
276496 /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */
277497diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
278index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644
498index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..45b387446587fd3e611fa02a01534bb5d9fa6bbd 100644
279499--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
280500+++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
281@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = {
501@@ -8,6 +8,10 @@ type UseExtraContentPaddingOptions = {
282502 keyboardPadding: SharedValue<number>;
283503 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
284504 blankSpace: SharedValue<number>;
285505+ /** Safe-area extra beyond raw contentInset. Offset math only. */
286506+ adjustedInsetCompensation: number;
507+ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */
508+ adjustedStartInsetCompensation: number;
287509 /** Current vertical scroll offset. */
288510 scroll: SharedValue<number>;
289511 /** Visible viewport dimensions. */
290512diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx
291index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644
513index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..277a1ecada428ffbae517d6edc720d029c8ecc10 100644
292514--- a/src/components/KeyboardChatScrollView/index.tsx
293515+++ 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";
296520 import {
297521 makeMutable,
298522+ runOnJS,
......@@ -300,11 +524,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
300524 useAnimatedRef,
301525 useAnimatedStyle,
302526 useDerivedValue,
303@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef<
527@@ -35,9 +37,12 @@ const KeyboardChatScrollView = forwardRef<
304528 offset = 0,
305529 extraContentPadding = ZERO_CONTENT_PADDING,
306530 blankSpace = ZERO_BLANK_SPACE,
307531+ adjustedInsetCompensation = 0,
532+ adjustedStartInsetCompensation = 0,
308533 applyWorkaroundForContentInsetHitTestBug = false,
309534 onLayout: onLayoutProp,
310535 onContentSizeChange: onContentSizeChangeProp,
......@@ -312,23 +537,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
312537 onEndVisible,
313538 ...rest
314539 },
315@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef<
540@@ -64,6 +69,8 @@ const KeyboardChatScrollView = forwardRef<
316541 offset,
317542 blankSpace,
318543 extraContentPadding,
319544+ adjustedInsetCompensation,
545+ adjustedStartInsetCompensation,
320546 });
321547
322548 useExtraContentPadding({
323@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef<
549@@ -71,6 +78,8 @@ const KeyboardChatScrollView = forwardRef<
324550 extraContentPadding,
325551 keyboardPadding: padding,
326552 blankSpace,
327553+ adjustedInsetCompensation,
554+ adjustedStartInsetCompensation,
328555 scroll,
329556 layout,
330557 size,
331@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef<
558@@ -102,13 +111,40 @@ const KeyboardChatScrollView = forwardRef<
332559 ),
333560 );
334561
......@@ -337,19 +564,34 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
337564- // scrollIndicatorInsets adjustment at the application layer.
338565- const indicatorPadding = useDerivedValue(
339566- () => 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
340582+ // Mirror the effective bottom padding (keyboard + composer + blank floor)
341583+ // to the consumer — a virtualized list needs it in its own scroll math or
342584+ // its end/maintain targets point at the under-the-keyboard resting offset.
343585+ useAnimatedReaction(
344+ () => totalPadding.value,
586+ () => reportedPadding.value,
345587+ (current, previous) => {
346588+ if (onContentInsetChange && current !== previous) {
347589+ runOnJS(onContentInsetChange)({ bottom: current });
348590+ }
349591+ },
350592+ [onContentInsetChange],
351 );
352
593+ );
594+
353595+ // Scroll indicator inset = keyboard only (excludes extraContentPadding and
354596+ // blankSpace): with a floating composer the indicator track should run the
355597+ // full height of the scroll view, behind the composer, like iOS Messages.
......@@ -360,10 +602,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d
360602 (e: LayoutChangeEvent) => {
361603 onLayoutInternal(e);
362604diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts
363index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644
605index dd222b57bc7a71729524670bad3812f30920bd73..7be71b5460eb143ddf460ce5c87b843c66bfb2cd 100644
364606--- a/src/components/KeyboardChatScrollView/types.ts
365607+++ b/src/components/KeyboardChatScrollView/types.ts
366@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = {
608@@ -90,6 +90,17 @@ export type KeyboardChatScrollViewProps = {
367609 * Default is `undefined` (equivalent to `0` — no minimum floor).
368610 */
369611 blankSpace?: SharedValue<number>;
......@@ -376,22 +618,65 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb
376618+ * Default is `0`.
377619+ */
378620+ adjustedInsetCompensation?: number;
621+ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */
622+ adjustedStartInsetCompensation?: number;
379623 /**
380624 * Fires whenever the effective content inset changes — the static `contentInset`
381625 * prop combined with the dynamic keyboard-driven padding.
626diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
627index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb670b097b 100644
628--- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
629+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts
630@@ -245,6 +245,7 @@ export const computeIOSContentOffset = (
631 layoutHeight: number,
632 inverted: boolean,
633 totalPaddingForMaxScroll?: number,
634+ startInsetCompensation: number = 0,
635 ): number => {
636 "worklet";
637
638@@ -262,7 +263,14 @@ export const computeIOSContentOffset = (
639 );
640 }
641
642- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0);
643+ const minScroll = -startInsetCompensation;
644+ const maxScroll = Math.max(
645+ contentHeight - layoutHeight + paddingForMax,
646+ minScroll,
647+ );
648
649- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll);
650+ return Math.min(
651+ Math.max(keyboardHeight + relativeScroll, minScroll),
652+ maxScroll,
653+ );
654 };
382655diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
383index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644
656index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..c375a8c4cc889c2fc2c0c1034788c605633192ed 100644
384657--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
385658+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
386@@ -43,6 +43,7 @@ function useChatKeyboard(
659@@ -43,6 +43,8 @@ function useChatKeyboard(
387660 offset,
388661 blankSpace,
389662 extraContentPadding,
390663+ adjustedInsetCompensation,
664+ adjustedStartInsetCompensation,
391665 } = options;
392666
393667 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(
395680 effective,
396681 minimumPaddingAbsorbed,
397682 );
......@@ -408,50 +693,131 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670
408693
409694 // persistent mode: when keyboard shrinks, clamp to valid range
410695 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;
412766 padding.value = effective;
413767 },
414768 },
415769- [inverted, keyboardLiftBehavior, offset, extraContentPadding],
416+ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation],
770+ [
771+ inverted,
772+ keyboardLiftBehavior,
773+ offset,
774+ extraContentPadding,
775+ adjustedInsetCompensation,
776+ adjustedStartInsetCompensation,
777+ ],
417778 );
418779
419780 return {
420781diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
421index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644
782index 02abf5cd9490900826678175462b234e07e30e92..4ee0ddd65e0b105568ab43b73997af4be8adae00 100644
422783--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
423784+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
424@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = {
785@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = {
425786 blankSpace: SharedValue<number>;
426787 /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
427788 extraContentPadding: SharedValue<number>;
428789+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
429790+ adjustedInsetCompensation: number;
791+ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */
792+ adjustedStartInsetCompensation: number;
430793 };
431794
432795 type UseChatKeyboardReturn = {
433796diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
434index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644
797index 833acbe78f1b1245251ddd3431d6546decdd0ade..8f7c10cb6ab392f10df4ca43549b89c25844f0b8 100644
435798--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
436799+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
437@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = {
800@@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = {
438801 keyboardPadding: SharedValue<number>;
439802 /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
440803 blankSpace: SharedValue<number>;
441804+ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
442805+ adjustedInsetCompensation: number;
806+ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */
807+ adjustedStartInsetCompensation: number;
443808 /** Current vertical scroll offset. */
444809 scroll: SharedValue<number>;
445810 /** Visible viewport dimensions. */
446@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
811@@ -49,6 +53,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
447812 extraContentPadding,
448813 keyboardPadding,
449814 blankSpace,
450815+ adjustedInsetCompensation,
816+ adjustedStartInsetCompensation,
451817 scroll,
452818 layout,
453819 size,
454@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
820@@ -97,14 +103,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
455821 }
456822
457823 // Compute effective delta considering blankSpace floor
......@@ -472,12 +838,32 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15
472838 const effectiveDelta = currentTotal - previousTotal;
473839
474840 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
476857 scrollToTarget(target);
477858 }
478859 },
479860- [inverted, keyboardLiftBehavior],
480+ [inverted, keyboardLiftBehavior, adjustedInsetCompensation],
861+ [
862+ inverted,
863+ keyboardLiftBehavior,
864+ adjustedInsetCompensation,
865+ adjustedStartInsetCompensation,
866+ ],
481867 );
482868 }
483869
pnpm-lock.yaml+8-8
......@@ -86,13 +86,13 @@ patchedDependencies:
8686 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
8787 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
8888 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
89 '@legendapp/list@3.3.5': 064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706
89 '@legendapp/list@3.3.5': f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b
9090 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
9191 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
9292 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
9393 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
9494 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
9696 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
9797 react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006
9898 uniwind@1.11.0: 329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196
......@@ -228,7 +228,7 @@ importers:
228228 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)
229229 '@legendapp/list':
230230 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)
232232 '@noble/curves':
233233 specifier: 'catalog:'
234234 version: 1.9.1
......@@ -402,7 +402,7 @@ importers:
402402 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)
403403 react-native-keyboard-controller:
404404 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)
406406 react-native-nitro-markdown:
407407 specifier: ^0.5.0
408408 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:
560560 version: 0.9.0
561561 '@legendapp/list':
562562 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)
564564 '@lexical/react':
565565 specifier: ^0.41.0
566566 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:
1282812828 dependencies:
1282912829 jsbi: 4.3.2
1283012830
12831 '@legendapp/list@3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12831 '@legendapp/list@3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
1283212832 dependencies:
1283312833 react: 19.2.3
1283412834 use-sync-external-store: 1.6.0(react@19.2.3)
......@@ -12836,7 +12836,7 @@ snapshots:
1283612836 react-dom: 19.2.3(react@19.2.3)
1283712837 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1283812838
12839 '@legendapp/list@3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12839 '@legendapp/list@3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
1284012840 dependencies:
1284112841 react: 19.2.6
1284212842 use-sync-external-store: 1.6.0(react@19.2.6)
......@@ -19356,7 +19356,7 @@ snapshots:
1935619356 react: 19.2.3
1935719357 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1935819358
19359 react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
19359 react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
1936019360 dependencies:
1936119361 react: 19.2.3
1936219362 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)