| ... | ... | @@ -385,6 +385,8 @@ interface ThreadWorkLogProps { |
| 385 | 385 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| 386 | 386 | readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; |
| 387 | 387 | readonly iconSubtleColor: ColorValue; |
| 388 | /** Feed background, painted as the scroll-edge fade over a long group. */ |
| 389 | readonly edgeFadeColor: string; |
| 388 | 390 | readonly themeAppearance: "light" | "dark"; |
| 389 | 391 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 390 | 392 | readonly onToggleRow: (rowId: string, anchorKey: string) => void; |
| ... | ... | @@ -430,6 +432,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { |
| 430 | 432 | {props.activities[0]?.groupedToolDetail ? ( |
| 431 | 433 | <ThreadWorkGroupList |
| 432 | 434 | activities={props.activities} |
| 435 | edgeFadeColor={props.edgeFadeColor} |
| 433 | 436 | expandedRows={props.expandedRows} |
| 434 | 437 | groupId={props.anchorKey} |
| 435 | 438 | rowSizing={props.rowSizing} |
| ... | ... | @@ -445,6 +448,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { |
| 445 | 448 | |
| 446 | 449 | function ThreadWorkGroupList(props: { |
| 447 | 450 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 451 | readonly edgeFadeColor: string; |
| 448 | 452 | readonly expandedRows: Readonly<Record<string, boolean>>; |
| 449 | 453 | readonly groupId: string; |
| 450 | 454 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| ... | ... | @@ -485,21 +489,17 @@ function ThreadWorkGroupList(props: { |
| 485 | 489 | const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); |
| 486 | 490 | const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); |
| 487 | 491 | const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); |
| 488 | | const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; |
| 489 | | const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; |
| 490 | 492 | |
| 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), |
| 495 | 497 | })); |
| 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 | ), |
| 503 | 503 | })); |
| 504 | 504 | const rememberPosition = useCallback(() => { |
| 505 | 505 | if (!loadedRef.current) return; |
| ... | ... | @@ -525,7 +525,7 @@ function ThreadWorkGroupList(props: { |
| 525 | 525 | } |
| 526 | 526 | }, []); |
| 527 | 527 | const onContentSizeChange = useCallback( |
| 528 | | (_width: number, nextHeight: number) => { |
| 528 | (nextHeight: number) => { |
| 529 | 529 | const previous = previousContent.current; |
| 530 | 530 | const detailsChanged = previous.expandedRows !== props.expandedRows; |
| 531 | 531 | const followAppend = |
| ... | ... | @@ -565,6 +565,24 @@ function ThreadWorkGroupList(props: { |
| 565 | 565 | }, |
| 566 | 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 | 586 | const getFixedItemSize = useCallback( |
| 569 | 587 | (row: ThreadFeedActivity, index: number) => |
| 570 | 588 | props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined |
| ... | ... | @@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: { |
| 582 | 600 | ); |
| 583 | 601 | |
| 584 | 602 | 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" }}> |
| 611 | 604 | <AnimatedLegendList |
| 612 | | ref={listRef} |
| 605 | ref={subscribeToContentSize} |
| 613 | 606 | data={props.activities} |
| 614 | 607 | keyExtractor={workLogRowKey} |
| 615 | 608 | estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP} |
| ... | ... | @@ -624,7 +617,6 @@ function ThreadWorkGroupList(props: { |
| 624 | 617 | extraData={props.renderRow} |
| 625 | 618 | renderItem={renderItem} |
| 626 | 619 | sharedValues={sharedValues} |
| 627 | | onContentSizeChange={onContentSizeChange} |
| 628 | 620 | onLayout={finishPendingAppend} |
| 629 | 621 | onLoad={() => { |
| 630 | 622 | loadedRef.current = true; |
| ... | ... | @@ -654,12 +646,47 @@ function ThreadWorkGroupList(props: { |
| 654 | 646 | scrollsToTop={false} |
| 655 | 647 | bounces={false} |
| 656 | 648 | 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 }} |
| 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. */ |
| 670 | function 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 | } |
| 665 | 692 | |
| ... | ... | @@ -670,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string { |
| 670 | 697 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 671 | 698 | props: Omit< |
| 672 | 699 | ThreadWorkLogProps, |
| 673 | | "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" |
| 700 | | "activities" |
| 701 | | "copiedRowId" |
| 702 | | "edgeFadeColor" |
| 703 | | "expandedRows" |
| 704 | | "rowSizing" |
| 705 | | "scrollPositions" |
| 674 | 706 | > & { |
| 675 | 707 | readonly row: ThreadFeedActivity; |
| 676 | 708 | readonly copied: boolean; |