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(
13311331 readonly renderMarkdownImage: MarkdownImageRenderer;
13321332 readonly renderViewedImage: MarkdownImageRenderer;
13331333 readonly iconSubtleColor: string | import("react-native").ColorValue;
1334 readonly screenColor: string;
13341335 readonly userBubbleColor: string | import("react-native").ColorValue;
13351336 readonly markdownStyles: MarkdownStyleSets;
13361337 readonly reviewCommentColors: ReviewCommentColors;
......@@ -1597,6 +1598,7 @@ function renderFeedEntry(
15971598 rowSizing={props.workRowSizing}
15981599 scrollPositions={props.workGroupScrollPositions}
15991600 iconSubtleColor={iconSubtleColor}
1601 edgeFadeColor={props.screenColor}
16001602 themeAppearance={props.themeAppearance}
16011603 onCopyRow={props.onCopyWorkRow}
16021604 onToggleRow={props.onToggleWorkRow}
......@@ -1986,6 +1988,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19861988
19871989 const theme = useUniwindTheme();
19881990 const iconSubtleColor = theme["--color-icon-subtle"];
1991 const screenColor = theme["--color-screen"];
19891992 const userBubbleColor = theme["--color-user-bubble"];
19901993 const onMarkdownLinkPress = useCallback(
19911994 (href: string) => {
......@@ -2634,6 +2637,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26342637 renderMarkdownImage,
26352638 renderViewedImage,
26362639 iconSubtleColor,
2640 screenColor,
26372641 userBubbleColor,
26382642 markdownStyles,
26392643 reviewCommentColors,
......@@ -2656,6 +2660,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26562660 terminalAssistantMessageIds,
26572661 unsettledTurnId,
26582662 iconSubtleColor,
2663 screenColor,
26592664 userBubbleColor,
26602665 markdownStyles,
26612666 reviewCommentColors,
apps/mobile/src/features/threads/thread-work-log.tsx+80-48
......@@ -385,6 +385,8 @@ interface ThreadWorkLogProps {
385385 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
386386 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
387387 readonly iconSubtleColor: ColorValue;
388 /** Feed background, painted as the scroll-edge fade over a long group. */
389 readonly edgeFadeColor: string;
388390 readonly themeAppearance: "light" | "dark";
389391 readonly onCopyRow: (rowId: string, value: string) => void;
390392 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
......@@ -430,6 +432,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
430432 {props.activities[0]?.groupedToolDetail ? (
431433 <ThreadWorkGroupList
432434 activities={props.activities}
435 edgeFadeColor={props.edgeFadeColor}
433436 expandedRows={props.expandedRows}
434437 groupId={props.anchorKey}
435438 rowSizing={props.rowSizing}
......@@ -445,6 +448,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
445448
446449function ThreadWorkGroupList(props: {
447450 readonly activities: ReadonlyArray<ThreadFeedActivity>;
451 readonly edgeFadeColor: string;
448452 readonly expandedRows: Readonly<Record<string, boolean>>;
449453 readonly groupId: string;
450454 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
......@@ -485,21 +489,17 @@ function ThreadWorkGroupList(props: {
485489 const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT);
486490 const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0);
487491 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 offset
492 // stays on the UI thread; only content-size changes update React state.
493 const topCoverStyle = useAnimatedStyle(() => ({
494 opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
493 // Each edge fades only while content continues past it. Scroll offset stays
494 // on the UI thread; only content-size changes update React state.
495 const topFadeStyle = useAnimatedStyle(() => ({
496 opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
495497 }));
496 const bottomCoverStyle = useAnimatedStyle(() => ({
497 opacity:
498 1 -
499 Math.min(
500 1,
501 Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
502 ),
498 const bottomFadeStyle = useAnimatedStyle(() => ({
499 opacity: Math.min(
500 1,
501 Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
502 ),
503503 }));
504504 const rememberPosition = useCallback(() => {
505505 if (!loadedRef.current) return;
......@@ -525,7 +525,7 @@ function ThreadWorkGroupList(props: {
525525 }
526526 }, []);
527527 const onContentSizeChange = useCallback(
528 (_width: number, nextHeight: number) => {
528 (nextHeight: number) => {
529529 const previous = previousContent.current;
530530 const detailsChanged = previous.expandedRows !== props.expandedRows;
531531 const followAppend =
......@@ -565,6 +565,24 @@ function ThreadWorkGroupList(props: {
565565 },
566566 [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition],
567567 );
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 }, []);
568586 const getFixedItemSize = useCallback(
569587 (row: ThreadFeedActivity, index: number) =>
570588 props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined
......@@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: {
582600 );
583601
584602 return (
585 <MaskedView
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 >
603 <View style={{ height, overflow: "hidden" }}>
611604 <AnimatedLegendList
612 ref={listRef}
605 ref={subscribeToContentSize}
613606 data={props.activities}
614607 keyExtractor={workLogRowKey}
615608 estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP}
......@@ -624,7 +617,6 @@ function ThreadWorkGroupList(props: {
624617 extraData={props.renderRow}
625618 renderItem={renderItem}
626619 sharedValues={sharedValues}
627 onContentSizeChange={onContentSizeChange}
628620 onLayout={finishPendingAppend}
629621 onLoad={() => {
630622 loadedRef.current = true;
......@@ -654,12 +646,47 @@ function ThreadWorkGroupList(props: {
654646 scrollsToTop={false}
655647 bounces={false}
656648 keyboardShouldPersistTaps="handled"
657 // MaskedView bridges through a native host whose absolute-fill bounds
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 }]}
649 style={{ height }}
661650 />
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>
663690 );
664691}
665692
......@@ -670,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string {
670697const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
671698 props: Omit<
672699 ThreadWorkLogProps,
673 "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions"
700 | "activities"
701 | "copiedRowId"
702 | "edgeFadeColor"
703 | "expandedRows"
704 | "rowSizing"
705 | "scrollPositions"
674706 > & {
675707 readonly row: ThreadFeedActivity;
676708 readonly copied: boolean;