| ... | @@ -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 | <ThreadWorkGroupList | 433 | <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) { |
| 445 | | 448 | |
| 446 | function ThreadWorkGroupList(props: { | 449 | function 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; | | |
| 490 | | 492 | |
| 491 | // Opaque covers remove each edge fade at the scroll boundary. Scroll offset | 493 | // 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 === undefined | 588 | props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined |
| ... | @@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: { | ... | @@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: { |
| 582 | ); | 600 | ); |
| 583 | | 601 | |
| 584 | return ( | 602 | return ( |
| 585 | <MaskedView | 603 | <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 | <AnimatedLegendList | 604 | <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 bounds | 649 | 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. */ |
| | 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,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string { |
| 670 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | 697 | const 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; |