authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 15:57:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 15:57:44-07:00
log89cc7434f0a5373f1b9c73ee579480e8f862fa5b
tree8636eed4b03703b28ed7524745c49eb6768287cd
parent579a77588684fc4012e28754cc4f662aa24730c7
signature Signed by PGP key B5690EEEBB952194

fix(mobile): stop clipping expanded tool groups (#10212)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 85 insertions(+), 48 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+5
...@@ -1331,6 +1331,7 @@ function renderFeedEntry(...@@ -1331,6 +1331,7 @@ function renderFeedEntry(
1331 readonly renderMarkdownImage: MarkdownImageRenderer;1331 readonly renderMarkdownImage: MarkdownImageRenderer;
1332 readonly renderViewedImage: MarkdownImageRenderer;1332 readonly renderViewedImage: MarkdownImageRenderer;
1333 readonly iconSubtleColor: string | import("react-native").ColorValue;1333 readonly iconSubtleColor: string | import("react-native").ColorValue;
1334 readonly screenColor: string;
1334 readonly userBubbleColor: string | import("react-native").ColorValue;1335 readonly userBubbleColor: string | import("react-native").ColorValue;
1335 readonly markdownStyles: MarkdownStyleSets;1336 readonly markdownStyles: MarkdownStyleSets;
1336 readonly reviewCommentColors: ReviewCommentColors;1337 readonly reviewCommentColors: ReviewCommentColors;
...@@ -1597,6 +1598,7 @@ function renderFeedEntry(...@@ -1597,6 +1598,7 @@ function renderFeedEntry(
1597 rowSizing={props.workRowSizing}1598 rowSizing={props.workRowSizing}
1598 scrollPositions={props.workGroupScrollPositions}1599 scrollPositions={props.workGroupScrollPositions}
1599 iconSubtleColor={iconSubtleColor}1600 iconSubtleColor={iconSubtleColor}
1601 edgeFadeColor={props.screenColor}
1600 themeAppearance={props.themeAppearance}1602 themeAppearance={props.themeAppearance}
1601 onCopyRow={props.onCopyWorkRow}1603 onCopyRow={props.onCopyWorkRow}
1602 onToggleRow={props.onToggleWorkRow}1604 onToggleRow={props.onToggleWorkRow}
...@@ -1986,6 +1988,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1986,6 +1988,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19861988
1987 const theme = useUniwindTheme();1989 const theme = useUniwindTheme();
1988 const iconSubtleColor = theme["--color-icon-subtle"];1990 const iconSubtleColor = theme["--color-icon-subtle"];
1991 const screenColor = theme["--color-screen"];
1989 const userBubbleColor = theme["--color-user-bubble"];1992 const userBubbleColor = theme["--color-user-bubble"];
1990 const onMarkdownLinkPress = useCallback(1993 const onMarkdownLinkPress = useCallback(
1991 (href: string) => {1994 (href: string) => {
...@@ -2634,6 +2637,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2634,6 +2637,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2634 renderMarkdownImage,2637 renderMarkdownImage,
2635 renderViewedImage,2638 renderViewedImage,
2636 iconSubtleColor,2639 iconSubtleColor,
2640 screenColor,
2637 userBubbleColor,2641 userBubbleColor,
2638 markdownStyles,2642 markdownStyles,
2639 reviewCommentColors,2643 reviewCommentColors,
...@@ -2656,6 +2660,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2656,6 +2660,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2656 terminalAssistantMessageIds,2660 terminalAssistantMessageIds,
2657 unsettledTurnId,2661 unsettledTurnId,
2658 iconSubtleColor,2662 iconSubtleColor,
2663 screenColor,
2659 userBubbleColor,2664 userBubbleColor,
2660 markdownStyles,2665 markdownStyles,
2661 reviewCommentColors,2666 reviewCommentColors,
apps/mobile/src/features/threads/thread-work-log.tsx+80-48
...@@ -385,6 +385,8 @@ interface ThreadWorkLogProps {...@@ -385,6 +385,8 @@ interface ThreadWorkLogProps {
385 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;385 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
386 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;386 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
387 readonly iconSubtleColor: ColorValue;387 readonly iconSubtleColor: ColorValue;
388 /** Feed background, painted as the scroll-edge fade over a long group. */
389 readonly edgeFadeColor: string;
388 readonly themeAppearance: "light" | "dark";390 readonly themeAppearance: "light" | "dark";
389 readonly onCopyRow: (rowId: string, value: string) => void;391 readonly onCopyRow: (rowId: string, value: string) => void;
390 readonly onToggleRow: (rowId: string, anchorKey: string) => void;392 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
...@@ -430,6 +432,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {...@@ -430,6 +432,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
430 {props.activities[0]?.groupedToolDetail ? (432 {props.activities[0]?.groupedToolDetail ? (
431 <ThreadWorkGroupList433 <ThreadWorkGroupList
432 activities={props.activities}434 activities={props.activities}
435 edgeFadeColor={props.edgeFadeColor}
433 expandedRows={props.expandedRows}436 expandedRows={props.expandedRows}
434 groupId={props.anchorKey}437 groupId={props.anchorKey}
435 rowSizing={props.rowSizing}438 rowSizing={props.rowSizing}
...@@ -445,6 +448,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {...@@ -445,6 +448,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
445448
446function ThreadWorkGroupList(props: {449function ThreadWorkGroupList(props: {
447 readonly activities: ReadonlyArray<ThreadFeedActivity>;450 readonly activities: ReadonlyArray<ThreadFeedActivity>;
451 readonly edgeFadeColor: string;
448 readonly expandedRows: Readonly<Record<string, boolean>>;452 readonly expandedRows: Readonly<Record<string, boolean>>;
449 readonly groupId: string;453 readonly groupId: string;
450 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;454 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
...@@ -485,21 +489,17 @@ function ThreadWorkGroupList(props: {...@@ -485,21 +489,17 @@ function ThreadWorkGroupList(props: {
485 const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT);489 const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT);
486 const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0);490 const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0);
487 const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]);491 const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]);
488 const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`;
489 const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height;
490492
491 // Opaque covers remove each edge fade at the scroll boundary. Scroll offset493 // Each edge fades only while content continues past it. Scroll offset stays
492 // stays on the UI thread; only content-size changes update React state.494 // on the UI thread; only content-size changes update React state.
493 const topCoverStyle = useAnimatedStyle(() => ({495 const topFadeStyle = useAnimatedStyle(() => ({
494 opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),496 opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
495 }));497 }));
496 const bottomCoverStyle = useAnimatedStyle(() => ({498 const bottomFadeStyle = useAnimatedStyle(() => ({
497 opacity:499 opacity: Math.min(
498 1 -500 1,
499 Math.min(501 Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
500 1,502 ),
501 Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
502 ),
503 }));503 }));
504 const rememberPosition = useCallback(() => {504 const rememberPosition = useCallback(() => {
505 if (!loadedRef.current) return;505 if (!loadedRef.current) return;
...@@ -525,7 +525,7 @@ function ThreadWorkGroupList(props: {...@@ -525,7 +525,7 @@ function ThreadWorkGroupList(props: {
525 }525 }
526 }, []);526 }, []);
527 const onContentSizeChange = useCallback(527 const onContentSizeChange = useCallback(
528 (_width: number, nextHeight: number) => {528 (nextHeight: number) => {
529 const previous = previousContent.current;529 const previous = previousContent.current;
530 const detailsChanged = previous.expandedRows !== props.expandedRows;530 const detailsChanged = previous.expandedRows !== props.expandedRows;
531 const followAppend =531 const followAppend =
...@@ -565,6 +565,24 @@ function ThreadWorkGroupList(props: {...@@ -565,6 +565,24 @@ function ThreadWorkGroupList(props: {
565 },565 },
566 [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition],566 [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition],
567 );567 );
568 // The native ScrollView reports its content size a frame or more after
569 // LegendList has laid the rows out, so a detail toggle rendered the group
570 // at its old height while the rows below already moved. Read the size
571 // LegendList computes on the JS thread instead; it settles in the same
572 // commit as the row measurement that changed it.
573 const onContentSizeChangeRef = useRef(onContentSizeChange);
574 useLayoutEffect(() => {
575 onContentSizeChangeRef.current = onContentSizeChange;
576 }, [onContentSizeChange]);
577 const subscribeToContentSize = useCallback((list: LegendListRef | null) => {
578 listRef.current = list;
579 if (!list) return;
580 const unsubscribe = list.getState().listen("totalSize", () => {
581 onContentSizeChangeRef.current(list.getState().contentLength);
582 });
583 onContentSizeChangeRef.current(list.getState().contentLength);
584 return unsubscribe;
585 }, []);
568 const getFixedItemSize = useCallback(586 const getFixedItemSize = useCallback(
569 (row: ThreadFeedActivity, index: number) =>587 (row: ThreadFeedActivity, index: number) =>
570 props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined588 props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined
...@@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: {...@@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: {
582 );600 );
583601
584 return (602 return (
585 <MaskedView603 <View style={{ height, overflow: "hidden" }}>
586 style={{ height }}
587 maskElement={
588 <View style={StyleSheet.absoluteFill} pointerEvents="none">
589 <Svg width="100%" height="100%">
590 <Defs>
591 <LinearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
592 <Stop offset={0} stopColor="white" stopOpacity={0} />
593 <Stop offset={fadeFraction} stopColor="white" stopOpacity={1} />
594 <Stop offset={1 - fadeFraction} stopColor="white" stopOpacity={1} />
595 <Stop offset={1} stopColor="white" stopOpacity={0} />
596 </LinearGradient>
597 </Defs>
598 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
599 </Svg>
600 <Animated.View
601 className="absolute inset-x-0 top-0 bg-white"
602 style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, topCoverStyle]}
603 />
604 <Animated.View
605 className="absolute inset-x-0 bottom-0 bg-white"
606 style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, bottomCoverStyle]}
607 />
608 </View>
609 }
610 >
611 <AnimatedLegendList604 <AnimatedLegendList
612 ref={listRef}605 ref={subscribeToContentSize}
613 data={props.activities}606 data={props.activities}
614 keyExtractor={workLogRowKey}607 keyExtractor={workLogRowKey}
615 estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP}608 estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP}
...@@ -624,7 +617,6 @@ function ThreadWorkGroupList(props: {...@@ -624,7 +617,6 @@ function ThreadWorkGroupList(props: {
624 extraData={props.renderRow}617 extraData={props.renderRow}
625 renderItem={renderItem}618 renderItem={renderItem}
626 sharedValues={sharedValues}619 sharedValues={sharedValues}
627 onContentSizeChange={onContentSizeChange}
628 onLayout={finishPendingAppend}620 onLayout={finishPendingAppend}
629 onLoad={() => {621 onLoad={() => {
630 loadedRef.current = true;622 loadedRef.current = true;
...@@ -654,12 +646,47 @@ function ThreadWorkGroupList(props: {...@@ -654,12 +646,47 @@ function ThreadWorkGroupList(props: {
654 scrollsToTop={false}646 scrollsToTop={false}
655 bounces={false}647 bounces={false}
656 keyboardShouldPersistTaps="handled"648 keyboardShouldPersistTaps="handled"
657 // MaskedView bridges through a native host whose absolute-fill bounds649 style={{ height }}
658 // can lag behind a resize. Keep the list's viewport at the group's
659 // current height when expanding details or appending calls.
660 style={[StyleSheet.absoluteFill, { height }]}
661 />650 />
662 </MaskedView>651 <Animated.View
652 pointerEvents="none"
653 className="absolute inset-x-0 top-0"
654 style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, topFadeStyle]}
655 >
656 <EdgeFade color={props.edgeFadeColor} direction="down" />
657 </Animated.View>
658 <Animated.View
659 pointerEvents="none"
660 className="absolute inset-x-0 bottom-0"
661 style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, bottomFadeStyle]}
662 >
663 <EdgeFade color={props.edgeFadeColor} direction="up" />
664 </Animated.View>
665 </View>
666 );
667}
668
669/** A screen-colored gradient painted over the list edge that still has content past it. */
670function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) {
671 const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`;
672 return (
673 <Svg width="100%" height="100%">
674 <Defs>
675 <LinearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
676 <Stop
677 offset={0}
678 stopColor={props.color}
679 stopOpacity={props.direction === "down" ? 1 : 0}
680 />
681 <Stop
682 offset={1}
683 stopColor={props.color}
684 stopOpacity={props.direction === "down" ? 0 : 1}
685 />
686 </LinearGradient>
687 </Defs>
688 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
689 </Svg>
663 );690 );
664}691}
665692
...@@ -670,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string {...@@ -670,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string {
670const ThreadWorkLogRow = memo(function ThreadWorkLogRow(697const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
671 props: Omit<698 props: Omit<
672 ThreadWorkLogProps,699 ThreadWorkLogProps,
673 "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions"700 | "activities"
701 | "copiedRowId"
702 | "edgeFadeColor"
703 | "expandedRows"
704 | "rowSizing"
705 | "scrollPositions"
674 > & {706 > & {
675 readonly row: ThreadFeedActivity;707 readonly row: ThreadFeedActivity;
676 readonly copied: boolean;708 readonly copied: boolean;