# Files changed · PR \#10212 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10212/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): stop clipping expanded tool groups](https://git.cafe/pingdotgg/t3code/pull/10212.md)

Version: 2

Head commit: 65b1b9c5da6b279ef7c67615112f455b0ed7d82c

Base commit: a83379df0175a23b22518b52fcb7f968248f4935

Comparison base: a83379df0175a23b22518b52fcb7f968248f4935

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/threads/ThreadFeed.tsx

Status: modified; +5; −0

```diff
@@ -1326,6 +1326,7 @@
     readonly renderMarkdownImage: MarkdownImageRenderer;
     readonly renderViewedImage: MarkdownImageRenderer;
     readonly iconSubtleColor: string | import("react-native").ColorValue;
+    readonly screenColor: string;
     readonly userBubbleColor: string | import("react-native").ColorValue;
     readonly markdownStyles: MarkdownStyleSets;
     readonly reviewCommentColors: ReviewCommentColors;
@@ -1584,6 +1585,7 @@
       rowSizing={props.workRowSizing}
       scrollPositions={props.workGroupScrollPositions}
       iconSubtleColor={iconSubtleColor}
+      edgeFadeColor={props.screenColor}
       themeAppearance={props.themeAppearance}
       onCopyRow={props.onCopyWorkRow}
       onToggleRow={props.onToggleWorkRow}
@@ -1972,6 +1974,7 @@
 
   const theme = useUniwindTheme();
   const iconSubtleColor = theme["--color-icon-subtle"];
+  const screenColor = theme["--color-screen"];
   const userBubbleColor = theme["--color-user-bubble"];
   const onMarkdownLinkPress = useCallback(
     (href: string) => {
@@ -2620,6 +2623,7 @@
             renderMarkdownImage,
             renderViewedImage,
             iconSubtleColor,
+            screenColor,
             userBubbleColor,
             markdownStyles,
             reviewCommentColors,
@@ -2641,6 +2645,7 @@
       terminalAssistantMessageIds,
       unsettledTurnId,
       iconSubtleColor,
+      screenColor,
       userBubbleColor,
       markdownStyles,
       reviewCommentColors,
```

## apps/mobile/src/features/threads/thread-work-log.tsx

Status: modified; +80; −48

```diff
@@ -385,6 +385,8 @@
   readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
   readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
   readonly iconSubtleColor: ColorValue;
+  /** Feed background, painted as the scroll-edge fade over a long group. */
+  readonly edgeFadeColor: string;
   readonly themeAppearance: "light" | "dark";
   readonly onCopyRow: (rowId: string, value: string) => void;
   readonly onToggleRow: (rowId: string, anchorKey: string) => void;
@@ -430,6 +432,7 @@
       {props.activities[0]?.groupedToolDetail ? (
         <ThreadWorkGroupList
           activities={props.activities}
+          edgeFadeColor={props.edgeFadeColor}
           expandedRows={props.expandedRows}
           groupId={props.anchorKey}
           rowSizing={props.rowSizing}
@@ -445,6 +448,7 @@
 
 function ThreadWorkGroupList(props: {
   readonly activities: ReadonlyArray<ThreadFeedActivity>;
+  readonly edgeFadeColor: string;
   readonly expandedRows: Readonly<Record<string, boolean>>;
   readonly groupId: string;
   readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
@@ -485,21 +489,17 @@
   const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT);
   const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0);
   const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]);
-  const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`;
-  const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height;
 
-  // Opaque covers remove each edge fade at the scroll boundary. Scroll offset
-  // stays on the UI thread; only content-size changes update React state.
-  const topCoverStyle = useAnimatedStyle(() => ({
-    opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
+  // Each edge fades only while content continues past it. Scroll offset stays
+  // on the UI thread; only content-size changes update React state.
+  const topFadeStyle = useAnimatedStyle(() => ({
+    opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
   }));
-  const bottomCoverStyle = useAnimatedStyle(() => ({
-    opacity:
-      1 -
-      Math.min(
-        1,
-        Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
-      ),
+  const bottomFadeStyle = useAnimatedStyle(() => ({
+    opacity: Math.min(
+      1,
+      Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
+    ),
   }));
   const rememberPosition = useCallback(() => {
     if (!loadedRef.current) return;
@@ -525,7 +525,7 @@
     }
   }, []);
   const onContentSizeChange = useCallback(
-    (_width: number, nextHeight: number) => {
+    (nextHeight: number) => {
       const previous = previousContent.current;
       const detailsChanged = previous.expandedRows !== props.expandedRows;
       const followAppend =
@@ -565,6 +565,24 @@
     },
     [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition],
   );
+  // The native ScrollView reports its content size a frame or more after
+  // LegendList has laid the rows out, so a detail toggle rendered the group
+  // at its old height while the rows below already moved. Read the size
+  // LegendList computes on the JS thread instead; it settles in the same
+  // commit as the row measurement that changed it.
+  const onContentSizeChangeRef = useRef(onContentSizeChange);
+  useLayoutEffect(() => {
+    onContentSizeChangeRef.current = onContentSizeChange;
+  }, [onContentSizeChange]);
+  const subscribeToContentSize = useCallback((list: LegendListRef | null) => {
+    listRef.current = list;
+    if (!list) return;
+    const unsubscribe = list.getState().listen("totalSize", () => {
+      onContentSizeChangeRef.current(list.getState().contentLength);
+    });
+    onContentSizeChangeRef.current(list.getState().contentLength);
+    return unsubscribe;
+  }, []);
   const getFixedItemSize = useCallback(
     (row: ThreadFeedActivity, index: number) =>
       props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined
@@ -582,34 +600,9 @@
   );
 
   return (
-    <MaskedView
-      style={{ height }}
-      maskElement={
-        <View style={StyleSheet.absoluteFill} pointerEvents="none">
-          <Svg width="100%" height="100%">
-            <Defs>
-              <LinearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
-                <Stop offset={0} stopColor="white" stopOpacity={0} />
-                <Stop offset={fadeFraction} stopColor="white" stopOpacity={1} />
-                <Stop offset={1 - fadeFraction} stopColor="white" stopOpacity={1} />
-                <Stop offset={1} stopColor="white" stopOpacity={0} />
-              </LinearGradient>
-            </Defs>
-            <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
-          </Svg>
-          <Animated.View
-            className="absolute inset-x-0 top-0 bg-white"
-            style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, topCoverStyle]}
-          />
-          <Animated.View
-            className="absolute inset-x-0 bottom-0 bg-white"
-            style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, bottomCoverStyle]}
-          />
-        </View>
-      }
-    >
+    <View style={{ height, overflow: "hidden" }}>
       <AnimatedLegendList
-        ref={listRef}
+        ref={subscribeToContentSize}
         data={props.activities}
         keyExtractor={workLogRowKey}
         estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP}
@@ -624,7 +617,6 @@
         extraData={props.renderRow}
         renderItem={renderItem}
         sharedValues={sharedValues}
-        onContentSizeChange={onContentSizeChange}
         onLayout={finishPendingAppend}
         onLoad={() => {
           loadedRef.current = true;
@@ -654,15 +646,50 @@
         scrollsToTop={false}
         bounces={false}
         keyboardShouldPersistTaps="handled"
-        // MaskedView bridges through a native host whose absolute-fill bounds
-        // can lag behind a resize. Keep the list's viewport at the group's
-        // current height when expanding details or appending calls.
-        style={[StyleSheet.absoluteFill, { height }]}
+        style={{ height }}
       />
-    </MaskedView>
+      <Animated.View
+        pointerEvents="none"
+        className="absolute inset-x-0 top-0"
+        style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, topFadeStyle]}
+      >
+        <EdgeFade color={props.edgeFadeColor} direction="down" />
+      </Animated.View>
+      <Animated.View
+        pointerEvents="none"
+        className="absolute inset-x-0 bottom-0"
+        style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, bottomFadeStyle]}
+      >
+        <EdgeFade color={props.edgeFadeColor} direction="up" />
+      </Animated.View>
+    </View>
   );
 }
 
+/** A screen-colored gradient painted over the list edge that still has content past it. */
+function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) {
+  const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`;
+  return (
+    <Svg width="100%" height="100%">
+      <Defs>
+        <LinearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
+          <Stop
+            offset={0}
+            stopColor={props.color}
+            stopOpacity={props.direction === "down" ? 1 : 0}
+          />
+          <Stop
+            offset={1}
+            stopColor={props.color}
+            stopOpacity={props.direction === "down" ? 0 : 1}
+          />
+        </LinearGradient>
+      </Defs>
+      <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
+    </Svg>
+  );
+}
+
 function workLogRowKey(row: ThreadFeedActivity): string {
   return row.id;
 }
@@ -670,7 +697,12 @@
 const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
   props: Omit<
     ThreadWorkLogProps,
-    "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions"
+    | "activities"
+    | "copiedRowId"
+    | "edgeFadeColor"
+    | "expandedRows"
+    | "rowSizing"
+    | "scrollPositions"
   > & {
     readonly row: ThreadFeedActivity;
     readonly copied: boolean;
```
