From 71297974c666b0db50a3e3b1a861742f2cdd4d7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 01:40:57 -0700 Subject: [PATCH 01/11] fix(mobile): prevent chat from disappearing when scrolling (#10479) --- patches/@legendapp__list@3.3.5.patch | 72 ++++++++++++++++++---------- pnpm-lock.yaml | 10 ++-- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 2fea500e1bde..084cf771df74 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -241,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac0047a8ead3 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { @@ -674,7 +674,19 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6010,7 +6209,10 @@ var ListComponent = typedMemo(function ListComponent2({ + ], + contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, + horizontal, +- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, ++ // Keep iOS anchored to ScrollAdjust even when JS position restoration is ++ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor ++ // with the 1e7 sentinel and scroll the entire list out of view. ++ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, + onLayout, + onScroll: onScroll2, + ref: refScrollView, +@@ -6751,7 +6953,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -683,7 +695,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7075,6 +7274,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7277,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -691,7 +703,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7132,10 +7335,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -704,7 +716,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7405,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -713,7 +725,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7341,6 +7543,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7546,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -721,7 +733,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7578,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -729,7 +741,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7423,6 +7627,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7630,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -743,7 +755,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7547,6 +7758,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7547,6 +7761,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -751,7 +763,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7643,6 +7858,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -759,7 +771,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7651,6 +7864,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7867,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -768,7 +780,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7676,11 +7891,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7676,11 +7894,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -788,7 +800,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97440bc623 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { @@ -1221,7 +1233,19 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -5989,7 +6188,10 @@ var ListComponent = typedMemo(function ListComponent2({ + ], + contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, + horizontal, +- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, ++ // Keep iOS anchored to ScrollAdjust even when JS position restoration is ++ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor ++ // with the 1e7 sentinel and scroll the entire list out of view. ++ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, + onLayout, + onScroll: onScroll2, + ref: refScrollView, +@@ -6730,7 +6932,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -1230,7 +1254,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7054,6 +7253,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7256,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1238,7 +1262,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7111,10 +7314,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -1251,7 +1275,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7384,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -1260,7 +1284,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7320,6 +7522,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7525,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -1268,7 +1292,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7557,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1276,7 +1300,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7402,6 +7606,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7609,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -1290,7 +1314,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7526,6 +7737,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7526,6 +7740,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1298,7 +1322,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7622,6 +7837,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -1306,7 +1330,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7630,6 +7843,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7846,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -1315,7 +1339,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7655,11 +7870,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7655,11 +7873,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 567d72e6da35..a56a22cd05e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 + '@legendapp/list@3.3.5': 07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -236,7 +236,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -580,7 +580,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -13269,7 +13269,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -13277,7 +13277,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From b248f5ad566a8cd3ea2d7d678c1e8aa49c748879 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 02:14:28 -0700 Subject: [PATCH 02/11] fix(mobile): smooth composer status pill resizing (#10484) --- .../threads/floating-working-control.tsx | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 88454206af19..89cf39e89e39 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -6,6 +6,7 @@ import { type LayoutChangeEvent, Pressable, Text as SystemText, + useWindowDimensions, View, } from "react-native"; import Animated, { @@ -65,6 +66,9 @@ export function FloatingWorkingControl(props: { readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { + const { width: windowWidth } = useWindowDimensions(); + const [overlayWidth, setOverlayWidth] = useState(windowWidth); + const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16); const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); useEffect(() => { @@ -78,17 +82,10 @@ export function FloatingWorkingControl(props: { opacity: separationProgress.value, })); - // The label swaps between connection, syncing, compacting, and working while - // the capsule stays mounted. A layout transition on the capsule would move - // its left edge, and labels laid out from that edge slide with it, so the pill - // reads as shifting sideways. Instead an in-flow sizer animates to the - // measured label width and the capsule takes its size from that, while the - // labels sit centered on top. The row re-centers as the capsule grows, so its - // midpoint never moves and the text underneath stays put. - // - // The sizer has to carry the width rather than the capsule itself: the native - // glass view only picks up a size from a real layout pass, so an animated - // width set straight on it leaves the glass stuck at its mounted size. + // Animate an in-flow sizer so native glass receives real layout updates. + // Measure labels in a separate, fixed-width host: measuring against the + // animated capsule constrains the incoming text to each intermediate width + // and repeatedly retargets the animation as it grows. const capsuleWidth = useSharedValue(null); const measuredWidthRef = useRef(null); const handleLabelLayout = (event: LayoutChangeEvent) => { @@ -123,15 +120,27 @@ export function FloatingWorkingControl(props: { // Only the connection label is a button (tap to reconnect); the others // pass touches through to the feed like before. const statusInteractive = props.status?.kind === "connection"; - // Yoga centers an absolute child that has no insets on its parent's align and - // justify, so each label row lands centered on the capsule without measuring - // itself, and the capsule clips whatever a wider label overhangs while it - // catches up. + // The host stays centered on the capsule, but its measurement constraint + // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( <> - + + + ) : null; @@ -140,6 +149,7 @@ export function FloatingWorkingControl(props: { pointerEvents="box-none" className="absolute left-0 right-0 z-20 items-center" style={{ top: -CONTROL_OVERLAY_OFFSET }} + onLayout={(event) => setOverlayWidth(event.nativeEvent.layout.width)} entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} > @@ -252,7 +262,9 @@ function FloatingStatusLabel(props: { onLayout={props.onLayout} > - {props.status.label} + + {props.status.label} + ); } @@ -274,7 +286,10 @@ function FloatingStatusLabel(props: { ) : ( )} - + {props.status.label} @@ -311,8 +326,7 @@ function FloatingStatusLabel(props: { ); } -// Rows are absolute with no insets, so the capsule centers them on itself and an -// exiting row fading out never shifts the incoming one. +// Absolute rows cross-fade around the same center without affecting each other. function StatusLabelRow(props: { readonly accessibilityLabel: string; readonly accessibilityRole?: "button"; @@ -324,7 +338,7 @@ function StatusLabelRow(props: { const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; return ( Date: Mon, 7 Sep 2026 02:21:37 -0700 Subject: [PATCH 03/11] fix(mobile): release initial scroll target after dragging (#10483) --- patches/@legendapp__list@3.3.5.patch | 4 ++-- pnpm-lock.yaml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 084cf771df74..9b7268fec585 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -570,7 +570,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; -+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList); if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { @@ -1129,7 +1129,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; -+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList); if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a56a22cd05e6..95403e871a9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': 07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4 + '@legendapp/list@3.3.5': 2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -236,7 +236,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -580,7 +580,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -13269,7 +13269,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -13277,7 +13277,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=07ecf538deedf140bb0f3836291714960e0bbcafe0514192b59957f498d8d9d4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From e32dd42f8ba0daa2d0c6d6d2e474b7af6b3eb7b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 02:57:58 -0700 Subject: [PATCH 04/11] fix(mobile): animate thread lifecycle transitions consistently (#10487) --- .../archive/ArchivedThreadsScreen.tsx | 2 + .../features/home/thread-dismissal.test.ts | 73 +++++++++++ .../src/features/home/thread-dismissal.ts | 35 +++++ .../features/home/thread-swipe-actions.tsx | 124 ++++++++---------- .../src/features/home/useThreadListActions.ts | 77 ++++++----- .../features/threads/thread-list-items.tsx | 1 + .../features/threads/thread-list-v2-items.tsx | 4 +- 7 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 apps/mobile/src/features/home/thread-dismissal.test.ts create mode 100644 apps/mobile/src/features/home/thread-dismissal.ts diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index c2d5b6cf65ab..97f9c5b69d0f 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -422,6 +422,8 @@ function ArchivedThreadRow(props: { ); return ( { + it("waits for every visible copy before changing thread state", async () => { + const home = Promise.withResolvers(); + const sidebar = Promise.withResolvers(); + const restore = vi.fn(); + const unregisterHome = registerThreadDismissal("env:thread", () => ({ + finished: home.promise, + restore, + })); + const unregisterSidebar = registerThreadDismissal("env:thread", () => ({ + finished: sidebar.promise, + restore, + })); + const command = vi.fn(async () => true); + try { + const result = withThreadDismissal("env:thread", command, Boolean); + expect(command).not.toHaveBeenCalled(); + home.resolve(); + await home.promise; + expect(command).not.toHaveBeenCalled(); + sidebar.resolve(); + expect(await result).toBe(true); + expect(command).toHaveBeenCalledOnce(); + expect(restore).not.toHaveBeenCalled(); + } finally { + unregisterHome(); + unregisterSidebar(); + } + }); + + it.each([false, "throw"])( + "restores dismissed rows when the command returns %s", + async (outcome) => { + const restore = vi.fn(); + const unregister = registerThreadDismissal("env:thread", () => ({ + finished: Promise.resolve(), + restore, + })); + try { + const result = withThreadDismissal( + "env:thread", + async () => { + if (outcome === "throw") throw new Error("Disconnected"); + return outcome; + }, + Boolean, + ); + if (outcome === "throw") await expect(result).rejects.toThrow("Disconnected"); + else expect(await result).toBe(false); + expect(restore).toHaveBeenCalledOnce(); + } finally { + unregister(); + } + }, + ); + + it("does not animate another environment or a recycled row", async () => { + const dismiss = vi.fn(() => ({ finished: Promise.resolve(), restore: vi.fn() })); + const unregisterOther = registerThreadDismissal("other:thread", dismiss); + const unregisterRecycled = registerThreadDismissal("env:thread", dismiss); + unregisterRecycled(); + try { + expect(await withThreadDismissal("env:thread", async () => true, Boolean)).toBe(true); + expect(dismiss).not.toHaveBeenCalled(); + } finally { + unregisterOther(); + } + }); +}); diff --git a/apps/mobile/src/features/home/thread-dismissal.ts b/apps/mobile/src/features/home/thread-dismissal.ts new file mode 100644 index 000000000000..1e1561570d7a --- /dev/null +++ b/apps/mobile/src/features/home/thread-dismissal.ts @@ -0,0 +1,35 @@ +interface ThreadDismissal { + readonly finished: Promise; + readonly restore: () => void; +} + +const rows = new Map ThreadDismissal>>(); + +/** A thread can be visible in Home and the navigation sidebar at once. */ +export function registerThreadDismissal(key: string, dismiss: () => ThreadDismissal) { + const registrations = rows.get(key) ?? new Set(); + registrations.add(dismiss); + rows.set(key, registrations); + return () => { + registrations.delete(dismiss); + if (registrations.size === 0) rows.delete(key); + }; +} + +/** Finish the exit before mutating the list; failed commands put the rows back. */ +export async function withThreadDismissal( + key: string, + action: () => Promise, + succeeded: (result: T) => boolean, +): Promise { + const dismissals = Array.from(rows.get(key) ?? [], (dismiss) => dismiss()); + let committed = false; + try { + await Promise.all(dismissals.map(({ finished }) => finished)); + const result = await action(); + committed = succeeded(result); + return result; + } finally { + if (!committed) dismissals.forEach(({ restore }) => restore()); + } +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 93085fec6c22..44f3c36e6655 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -20,7 +20,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Alert, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). const ACTION_ITEM_WIDTH = 58; @@ -68,13 +69,6 @@ interface ThreadSwipeAction { readonly onPress: () => void; } -/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */ -type ThreadSwipePrimaryAction = Omit & - ( - | { readonly dismissOnPress: true; readonly onPress: () => Promise } - | { readonly dismissOnPress?: false; readonly onPress: () => void } - ); - interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { readonly tone: "primary" | "secondary" | "danger"; } @@ -252,7 +246,8 @@ interface ThreadSwipeableProps { readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; - readonly primaryAction: ThreadSwipePrimaryAction; + readonly primaryAction: ThreadSwipeAction; + readonly threadKey: string; /** * Omitted keeps the v1 destructive Delete action. Explicit null opts out of * a secondary action entirely so a gated Snooze can never fall back to an @@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const mountedRef = useRef(true); - const pendingDismissRef = useRef<(() => Promise) | null>(null); + const dismissalRef = useRef<{ finished: Promise; restore: () => void } | null>(null); + const pendingDismissRef = useRef<(() => void) | null>(null); const activeTranslationRef = useRef | null>(null); const [isDismissing, setIsDismissing] = useState(false); const dismissing = useSharedValue(false); const rowHeight = useSharedValue(0); const rowWidth = useSharedValue(props.fullSwipeWidth); const collapse = useSharedValue(0); + const fallbackTranslation = useSharedValue(0); const actionOpacity = useSharedValue(1); const primaryAction = props.primaryAction; const onSwipeableClose = props.onSwipeableClose; const restoreRow = useCallback(() => { - swipeableRef.current?.close(); + if (!mountedRef.current) return; + dismissalRef.current = null; + swipeableRef.current?.reset(); + fallbackTranslation.set(0); collapse.set(0); actionOpacity.set(1); dismissing.set(false); setIsDismissing(false); - }, [actionOpacity, collapse, dismissing]); + }, [actionOpacity, collapse, dismissing, fallbackTranslation]); - const finishDismiss = useCallback(async () => { - const action = pendingDismissRef.current; - if (!action) return; + const finishDismiss = useCallback(() => { + const finish = pendingDismissRef.current; pendingDismissRef.current = null; - try { - const succeeded = await action(); - if (!succeeded && mountedRef.current) restoreRow(); - } catch (error) { - if (mountedRef.current) restoreRow(); - Alert.alert( - "Could not settle thread", - error instanceof Error ? error.message : "The thread could not be settled.", - ); - } - }, [restoreRow]); + finish?.(); + }, []); useLayoutEffect(() => { mountedRef.current = true; @@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { mountedRef.current = false; cancelAnimation(collapse); cancelAnimation(actionOpacity); + cancelAnimation(fallbackTranslation); if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); - // Scrolling a committed row out of the recycled list must still settle it. - void finishDismiss(); + // Recycling a row must not prevent the waiting action from running. + finishDismiss(); }; - }, [actionOpacity, collapse, finishDismiss]); - - const beginDismiss = useCallback( - (translation: SharedValue) => { - if (!primaryAction.dismissOnPress) return; - pendingDismissRef.current = primaryAction.onPress; - activeTranslationRef.current = translation; - fullSwipeArmedRef.current = false; - if (!mountedRef.current) { - void finishDismiss(); - return; - } - setIsDismissing(true); - if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); - }, - [finishDismiss, primaryAction, onSwipeableClose], - ); + }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); const dismiss = useCallback( (translation: SharedValue) => { "worklet"; if (dismissing.value) return; dismissing.set(true); - runOnJS(beginDismiss)(translation); const timing = { duration: 220, easing: Easing.out(Easing.cubic), @@ -375,30 +349,46 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }), ); }, - [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth], + [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], + ); + useLayoutEffect( + () => + registerThreadDismissal(props.threadKey, () => { + if (dismissalRef.current) return dismissalRef.current; + const finished = new Promise((resolve) => { + pendingDismissRef.current = resolve; + }); + fullSwipeArmedRef.current = false; + setIsDismissing(true); + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); + dismissalRef.current = { finished, restore: restoreRow }; + return dismissalRef.current; + }), + [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow], ); const dismissStyle = useAnimatedStyle(() => ({ height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, pointerEvents: dismissing.value ? "none" : "auto", overflow: "hidden", + transform: [{ translateX: fallbackTranslation.value }], })); const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" })); - const dismissOnPress = primaryAction.dismissOnPress === true; + const commitPrimaryAction = useCallback(() => { + primaryAction.onPress(); + if (!pendingDismissRef.current) swipeableRef.current?.close(); + }, [primaryAction]); const handleRelease = useCallback( (translation: SharedValue) => { "worklet"; if (dismissing.value) return true; - if ( - dismissOnPress && - fullSwipeAction === "primary" && - -translation.value >= fullSwipeThreshold - ) { - dismiss(translation); + if (fullSwipeAction === "primary" && -translation.value >= fullSwipeThreshold) { + runOnJS(commitPrimaryAction)(); return true; } return false; }, - [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold], + [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold], ); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { if (armed && !fullSwipeArmedRef.current) { @@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { } props.onSwipeableWillOpen?.(methods); - if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) { + if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") { fullSwipeArmedRef.current = false; methods.close(); - if (fullSwipeAction === "primary") { - props.primaryAction.onPress(); - } else { - props.onDelete(); - } + props.onDelete(); } }} overshootFriction={1} overshootRight renderRightActions={(_progress, translation, methods) => ( - + { + activeTranslationRef.current = translation; + }} + style={actionStyle} + > { - if (primaryAction.dismissOnPress) { - runOnUI(dismiss)(translation); - } else { - methods.close(); - primaryAction.onPress(); - } - }, + onPress: commitPrimaryAction, }} secondaryAction={resolveSecondaryAction({ close: () => methods.close(), diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index f6983bd58d1b..2c08f2647569 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -5,6 +5,7 @@ import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; import { Alert } from "react-native"; +import { withThreadDismissal } from "./thread-dismissal"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; @@ -131,26 +132,30 @@ function useThreadActionExecutor( ); return false; } - const result = - action === "unsettle" - ? // reason "user" pins the thread active: auto-settle stays - // suppressed until real activity clears the pin server-side. - await unsettleMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }) - : await ( - action === "settle" - ? settleMutation - : action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation - )({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + key, + async () => + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return false; @@ -275,13 +280,18 @@ export function useThreadListActions(): { } selectionHaptic(); - const result = await snoozeMutation({ - environmentId: thread.environmentId, - input: { - threadId: thread.id, - snoozedUntil, - }, - }); + const result = await withThreadDismissal( + key, + () => + snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Alert.alert( @@ -316,10 +326,15 @@ export function useThreadListActions(): { } selectionHaptic(); - const result = await unsnoozeMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }); + const result = await withThreadDismissal( + key, + () => + unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Alert.alert( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 03443c9f7868..7a677a5ec797 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -725,6 +725,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { return ( From b7175371d9f32f3b759a922cdbf727a3214c99a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 03:01:51 -0700 Subject: [PATCH 05/11] fix(mobile): restore assistant message bottom padding (#10491) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e0a6022d84e0..503077bbc2ea 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2260,11 +2260,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); + const unsettledTurnId = + props.latestTurn && + (props.latestTurn.completedAt === null || props.latestTurn.state === "running") + ? props.latestTurn.turnId + : null; // LegendList does not invalidate visible rows when only the renderItem closure changes. - // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. + // Include turn completion so unchanged message rows reveal their footer and spacing + // even when the final message update arrives before the turn settles. const listAppearanceData = useMemo( () => ({ dispatchingMessageId: props.dispatchingMessageId, + unsettledTurnId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2277,6 +2284,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }), [ props.dispatchingMessageId, + unsettledTurnId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2472,12 +2480,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return new Set(terminalIdsByTurn.values()); }, [props.feed]); - const unsettledTurnId = - props.latestTurn && - (props.latestTurn.completedAt === null || props.latestTurn.state === "running") - ? props.latestTurn.turnId - : null; - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; From dc39615aec702ea6d402168f80b4d1613f4f2e0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 03:05:57 -0700 Subject: [PATCH 06/11] fix(mobile): preserve chat rows when toggling commands (#10492) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 503077bbc2ea..c4f8eea9275a 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -210,6 +210,7 @@ function formatMessageTime(input: string): string { // text fits at the current font settings. Larger accessibility text is measured. const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); +const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0); // Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on // assistant rows. Images size their frame from these before their own layout. const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; @@ -2865,8 +2866,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} + // LegendList swaps its position and size component types when this + // becomes undefined, remounting the feed and replaying row entrances. + // Keep those containers mounted while ordinary updates stay immediate. itemLayoutAnimation={ - disclosureToggleSettling ? THREAD_FEED_LAYOUT_TRANSITION : undefined + disclosureToggleSettling + ? THREAD_FEED_LAYOUT_TRANSITION + : THREAD_FEED_IMMEDIATE_TRANSITION } onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual From 8b2838e0e8a73d3fa6476940445c372e47b99db4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 7 Sep 2026 03:48:14 -0700 Subject: [PATCH 07/11] feat(web): group onboarding project import by repository (#10493) Onboarding listed every directory Claude Code or Codex had ever run in as one flat list of paths, with everything from the last 30 days preselected. On my machine that was 270 rows and 80 preselected. Most of them were Codex scratch folders, worktrees, and one-off questions. I wanted two or three projects and had no fast way to get there. The scanner now reads each candidate's `.git/config` directly, so the client can group clones by origin and show the GitHub `owner/name`. Linked worktrees, Codex scratch directories under `~/Documents/Codex`, `~/Downloads`, and temp roots are no longer offered. Folders that are not git repositories collapse under "Other folders". The default selection requires a git repository with at least three threads. Select all and Select none sit above the list, and each row shows the source icons, thread count, and last activity. On the same machine this drops the list to 162 rows and the default selection to 16. Mobile has no project import step, so there is no mobile change. Created with Claude Fable 5.1 in Claude Code. --- .../src/project/AgentSessionScanner.test.ts | 118 +++++++ .../server/src/project/AgentSessionScanner.ts | 75 ++++- .../components/onboarding/WelcomeWizard.tsx | 305 +++++++++++++++--- .../onboarding/projectImport.logic.test.ts | 70 +++- .../web/src/onboarding/projectImport.logic.ts | 82 ++++- docs/user/welcome-wizard.md | 13 +- packages/contracts/src/agentSessions.ts | 14 + packages/shared/src/git.test.ts | 70 ++++ packages/shared/src/git.ts | 66 +++- 9 files changed, 764 insertions(+), 49 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 0ca550160507..c797eab6f550 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-03-01T00:00:00.000Z", alreadyImported: false, + git: null, }, { path: olderWorkspace, @@ -221,6 +222,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), @@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-02-09T11:00:00.000Z", alreadyImported: false, + git: null, }, { path: workspace, @@ -271,6 +274,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-02-09T10:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), @@ -389,6 +393,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-04-01T09:00:00.000Z", alreadyImported: true, + git: null, }, ]); }), @@ -421,6 +426,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path: workspace, projectId: ProjectId.make("project-1"), alreadyImported: true, + git: null, }); }), ); @@ -452,6 +458,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path: workspaceAlias, projectId: ProjectId.make("project-1"), alreadyImported: true, + git: null, }); }), ); @@ -498,6 +505,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: true, + git: null, }, ]); }), @@ -855,6 +863,115 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("excludes Codex scratch directories and Downloads", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + // The exclusions key off the real home directory, so these fixtures + // must live there. Each run owns a uniquely named subtree and removes + // only that subtree, never the shared Codex or Downloads parents. + const home = NodeOS.homedir(); + // Borrow a unique suffix from a scoped temp dir instead of reaching for + // Date.now or Math.random, which the Effect lint rejects. + const runId = path.basename(yield* makeTempDir("t3code-scanner-test-")); + const scratchRoot = path.join(home, "Documents", "Codex", runId); + const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation"); + const downloads = path.join(home, "Downloads", runId); + const keep = yield* makeTempDir("t3code-workspace-keep-"); + yield* fileSystem.makeDirectory(scratch, { recursive: true }); + yield* fileSystem.makeDirectory(downloads, { recursive: true }); + yield* Effect.addFinalizer(() => + Effect.all([ + fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore), + fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore), + ]), + ); + + for (const [index, cwd] of [scratch, downloads, keep].entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "09", + "01", + `rollout-${index}.jsonl`, + ), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); + }), + ); + + it.effect("skips linked git worktrees and reports the origin of real checkouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const worktree = yield* makeTempDir("t3code-workspace-worktree-"); + const plain = yield* makeTempDir("t3code-workspace-plain-"); + const noRemote = yield* makeTempDir("t3code-workspace-noremote-"); + const submodule = yield* makeTempDir("t3code-workspace-submodule-"); + + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* fileSystem.writeFileString( + path.join(repo, ".git", "config"), + '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + ); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`, + ); + yield* fileSystem.makeDirectory(path.join(noRemote, ".git")); + yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n"); + // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`. + const submoduleGitDir = path.join(repo, ".git", "modules", "vendor"); + yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(submoduleGitDir, "config"), + '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + ); + yield* fileSystem.writeFileString( + path.join(submodule, ".git"), + `gitdir: ${submoduleGitDir}\n`, + ); + + for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + ).toEqual([ + { + path: submodule, + git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + }, + { path: noRemote, git: { remoteKey: null, repository: null } }, + { path: plain, git: null }, + { + path: repo, + git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }, + }, + ]); + }), + ); + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1231,6 +1348,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-05-03T00:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 06d57441b54c..83dc215cca40 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -38,6 +38,11 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import { + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseOriginUrlFromGitConfig, +} from "@t3tools/shared/git"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; @@ -624,14 +629,28 @@ export const make = Effect.gen(function* () { // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; const hostEnvironment = yield* HostProcessEnvironment; + const homeDir = NodeOS.homedir(); + // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( - [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => + [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => normalizeProjectPathForComparison(path.resolve(directory)), ), ); + // Codex creates one scratch directory per conversation under + // ~/Documents/Codex//. Neither those nor anything a user + // unpacked into Downloads is a project. + const excludedProjectAncestors = [ + path.join(homeDir, "Downloads"), + path.join(homeDir, "Documents", "Codex"), + ]; const isExcludedProjectPath = (candidatePath: string) => excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + excludedProjectAncestors.some((ancestor) => + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(ancestor, foldWorktreeCase), + ), + ) || normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || @@ -664,6 +683,48 @@ export const make = Effect.gen(function* () { return `path:${normalizeProjectPathForComparison(realPath)}`; }); + /** + * Git identity of a directory, or the reason it has none. Reads `.git` + * directly instead of spawning git so a scan over hundreds of candidates + * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a + * `worktrees/` directory the checkout is a linked worktree, which + * onboarding skips because its history belongs to the main checkout. + * Submodules use the same pointer shape but live under `modules/`, and + * are offered like any other repository. + */ + const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( + directory: string, + ): Effect.fn.Return< + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] } + | { readonly _tag: "Worktree" } + | { readonly _tag: "NotGit" } + > { + const gitPath = path.join(directory, ".git"); + const gitStats = yield* statOption(gitPath); + if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; + let gitDir = gitPath; + if (gitStats.value.type !== "Directory") { + const pointer = yield* fileSystem + .readFileString(gitPath) + .pipe(Effect.orElseSucceed(() => "")); + const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; + gitDir = path.resolve(directory, target); + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const; + } + const configText = yield* fileSystem + .readFileString(path.join(gitDir, "config")) + .pipe(Effect.orElseSucceed(() => "")); + const originUrl = parseOriginUrlFromGitConfig(configText); + return { + _tag: "Repository", + git: { + remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), + repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + }, + } as const; + }); + // A large history snapshot can precede session metadata. Read bounded // chunks until a complete record names its cwd or the safety budget ends. const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( @@ -1148,9 +1209,11 @@ export const make = Effect.gen(function* () { sources: Array; threadCount: number; lastActiveAtMs: number | null; + git: AgentSessionProjectCandidate["git"]; } >(); const directoryKeys = new Map(); + const gitIdentities = new Map(); for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim()); @@ -1173,7 +1236,13 @@ export const make = Effect.gen(function* () { if (isExcludedProjectPath(realPath)) { key = ""; } else { - key = yield* directoryIdentity(resolved, stats.value); + const gitIdentity = yield* readGitIdentity(resolved); + if (gitIdentity._tag === "Worktree") { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); + } } directoryKeys.set(resolved, key); } @@ -1186,6 +1255,7 @@ export const make = Effect.gen(function* () { sources: [candidate.source], threadCount: candidate.threadCount, lastActiveAtMs: candidate.lastActiveAtMs, + git: gitIdentities.get(key) ?? null, }); continue; } @@ -1234,6 +1304,7 @@ export const make = Effect.gen(function* () { ? null : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), alreadyImported: importedProject !== undefined, + git: entry.git, }); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 24e38d353a87..ca187b3475d1 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1,6 +1,7 @@ import { useAuth } from "@clerk/react"; import { useAtomValue } from "@effect/atom-react"; import type { + AgentSessionProjectCandidate, EnvironmentId, ProjectId, ScopedProjectRef, @@ -32,10 +33,12 @@ import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { + groupOnboardingProjects, partitionOnboardingProjects, onboardingProjectKey, resolveOnboardingLandingProject, resolveOnboardingProjectId, + type OnboardingProjectGroup, } from "../../onboarding/projectImport.logic"; import { getOnboardingProviderState, @@ -59,6 +62,7 @@ import { getProviderSummary } from "../settings/providerStatus"; import { getDriverOption } from "../settings/providerDriverMeta"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { ClaudeAI, OpenAI } from "../Icons"; import { T3Wordmark } from "../T3Wordmark"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -71,6 +75,7 @@ import { WizardPanel, WizardSteps } from "../ui/wizard"; import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { cn } from "../../lib/utils"; +import { formatRelativeTime } from "../../timestampFormat"; /** * First-run welcome wizard. Rendered over the workspace at `/welcome` on a @@ -1008,11 +1013,11 @@ function ImportStep({ ), [scans], ); - const selected = candidates.filter((candidate) => - selectedPaths - ? selectedPaths.has(candidate.key) - : recent.some((item) => item.key === candidate.key), + const selectedKeys = useMemo( + () => selectedPaths ?? new Set(recent.map((candidate) => candidate.key)), + [selectedPaths, recent], ); + const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key)); const finishAfterImport = () => { const projectRef = resolveOnboardingLandingProject( @@ -1176,13 +1181,38 @@ function ImportStep({ title="Choose your projects" description="Import projects and conversations from your selected computers." > + {candidates.length > 0 ? ( +
+ + {selected.length} of {candidates.length} selected + +
+ + +
+
+ ) : null}
{scans.map((scan) => { - const groupCandidates = candidates.filter( + const scanCandidates = candidates.filter( (candidate) => candidate.environmentId === scan.environmentId, ); const label = @@ -1191,10 +1221,12 @@ function ImportStep({ return (
- {label} + {scans.length > 1 ? ( + {label} + ) : null} {scan.isPending && scan.data === null ? (
@@ -1210,7 +1242,7 @@ function ImportStep({ Retry
- ) : groupCandidates.length === 0 ? ( + ) : scanCandidates.length === 0 ? (

No existing Claude Code or Codex projects found.

@@ -1220,38 +1252,11 @@ function ImportStep({ {SCAN_LIMIT_MESSAGE}

) : null} - {groupCandidates.map((candidate) => ( - - ))} +
); })} @@ -1280,6 +1285,224 @@ function ImportStep({ ); } +type ImportCandidate = AgentSessionProjectCandidate & { + readonly environmentId: EnvironmentId; + readonly key: string; +}; + +/** + * Repositories first, newest activity on top. Clones of one repository share + * a group with a tri-state checkbox. Folders that are not git repositories + * sit collapsed at the bottom so they stay reachable without adding noise. + * Source icons appear only on repository rows so the columns stay still. + */ +function ImportCandidateList({ + candidates, + selectedKeys, + onSelectionChange, +}: { + readonly candidates: ReadonlyArray; + readonly selectedKeys: ReadonlySet; + readonly onSelectionChange: (next: ReadonlySet) => void; +}) { + const { repositories, other } = useMemo(() => groupOnboardingProjects(candidates), [candidates]); + const setKeys = (keys: ReadonlyArray, checked: boolean) => { + const next = new Set(selectedKeys); + for (const key of keys) { + if (checked) next.add(key); + else next.delete(key); + } + onSelectionChange(next); + }; + const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length; + + return ( + <> + {repositories.map((group) => ( + + ))} + {other.length > 0 ? ( + +
+ 0 && otherSelected < other.length} + onCheckedChange={(checked) => + setKeys( + other.map((candidate) => candidate.key), + checked === true, + ) + } + /> + + + Other folders + + {other.length} {other.length === 1 ? "folder" : "folders"} + + +
+ + {other.map((candidate) => ( + setKeys([candidate.key], checked)} + /> + ))} + +
+ ) : null} + + ); +} + +function ImportRepositoryGroup({ + group, + selectedKeys, + onToggle, +}: { + readonly group: OnboardingProjectGroup; + readonly selectedKeys: ReadonlySet; + readonly onToggle: (keys: ReadonlyArray, checked: boolean) => void; +}) { + const keys = group.candidates.map((candidate) => candidate.key); + const selectedCount = keys.filter((key) => selectedKeys.has(key)).length; + const single = group.candidates.length === 1; + const only = group.candidates[0]; + if (single && only !== undefined) { + return ( + onToggle([only.key], checked)} + /> + ); + } + return ( + +
+ 0 && selectedCount < keys.length} + onCheckedChange={(checked) => onToggle(keys, checked === true)} + /> + + + {group.label} + c.sources))]} + threadCount={group.threadCount} + lastActiveAt={group.lastActiveAt} + /> + +
+ + {group.candidates.map((candidate) => ( + onToggle([candidate.key], checked)} + /> + ))} + +
+ ); +} + +function ImportCandidateRow({ + candidate, + label, + secondary, + nested = false, + checked, + onCheckedChange, +}: { + readonly candidate: ImportCandidate; + readonly label: string; + readonly secondary?: string; + readonly nested?: boolean; + readonly checked: boolean; + readonly onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +/** + * Trailing columns shared by every import row: source icons, thread count, + * last activity. Each column has a fixed width and each icon has its own slot + * so nothing shifts between rows that differ in sources or digit count. + */ +function ImportRowMeta({ + sources, + threadCount, + lastActiveAt, +}: { + readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null; + readonly threadCount: number; + readonly lastActiveAt: string | null; +}) { + const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt); + // "just now" does not fit the fixed column, so collapse it. + const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value; + return ( + + + {sources?.includes("claudeAgent") ? ( + + ) : null} + + + {sources?.includes("codex") ? : null} + + {threadCount} + {age} + + ); +} + // ── Shared bits ────────────────────────────────────────────── function StepShell({ diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts index 58264759b82a..2ddfb2de6e64 100644 --- a/apps/web/src/onboarding/projectImport.logic.test.ts +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3 import { describe, expect, it } from "vite-plus/test"; import { + groupOnboardingProjects, partitionOnboardingProjects, onboardingProjectKey, resolveOnboardingLandingProject, @@ -18,13 +19,19 @@ function candidate( title: path.split("/").at(-1) ?? path, path, sources: ["codex"], - threadCount: 1, + threadCount: 3, lastActiveAt: "2026-08-20T12:00:00.000Z", alreadyImported: false, + git: { remoteKey: null, repository: null }, ...overrides, }; } +const github = (repository: string) => ({ + remoteKey: `github.com/${repository.toLowerCase()}`, + repository, +}); + describe("partitionOnboardingProjects", () => { it("keeps existing projects available for thread history import", () => { const imported = candidate("/projects/current", { alreadyImported: true }); @@ -59,6 +66,67 @@ describe("partitionOnboardingProjects", () => { recent: [recent], }); }); + + it("keeps non-git folders and thin histories out of the default selection", () => { + const repo = candidate("/projects/repo"); + const folder = candidate("/projects/folder", { git: null }); + const thin = candidate("/projects/thin", { threadCount: 2 }); + + expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]); + }); +}); + +describe("groupOnboardingProjects", () => { + it("groups clones by origin, keeps local repos separate, and folds non-git folders away", () => { + const main = candidate("/code/t3code", { + git: github("pingdotgg/t3code"), + threadCount: 79, + lastActiveAt: "2026-08-21T12:00:00.000Z", + }); + const clone = candidate("/code/clones/t3code-2", { + git: github("pingdotgg/t3code"), + threadCount: 13, + lastActiveAt: "2026-08-10T12:00:00.000Z", + }); + const older = candidate("/code/fleet", { + git: github("t3dotgg/fleet"), + threadCount: 295, + lastActiveAt: "2026-08-22T00:00:00.000Z", + }); + const local = candidate("/code/scratch-repo", { title: "scratch-repo" }); + const folder = candidate("/tmp/notes", { git: null }); + + const grouped = groupOnboardingProjects([main, clone, older, local, folder]); + + expect(grouped.other).toEqual([folder]); + expect( + grouped.repositories.map((group) => ({ + label: group.label, + paths: group.candidates.map((item) => item.path), + threadCount: group.threadCount, + lastActiveAt: group.lastActiveAt, + })), + ).toEqual([ + { + label: "t3dotgg/fleet", + paths: ["/code/fleet"], + threadCount: 295, + lastActiveAt: "2026-08-22T00:00:00.000Z", + }, + { + label: "pingdotgg/t3code", + paths: ["/code/t3code", "/code/clones/t3code-2"], + threadCount: 92, + lastActiveAt: "2026-08-21T12:00:00.000Z", + }, + { + label: "scratch-repo", + paths: ["/code/scratch-repo"], + threadCount: 3, + lastActiveAt: "2026-08-20T12:00:00.000Z", + }, + ]); + }); }); describe("resolveOnboardingProjectId", () => { diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts index 962fe1e0abd8..3fa78128748a 100644 --- a/apps/web/src/onboarding/projectImport.logic.ts +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -2,8 +2,14 @@ import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +/** One or two threads in a directory is usually a one-off question, not a project. */ +const DEFAULT_SELECTION_MIN_THREADS = 3; -/** Existing projects still need their agent history imported, so every scan candidate is offered. */ +/** + * Existing projects still need their agent history imported, so every scan + * candidate is offered. The default selection is narrower: git repositories + * active in the last 30 days with enough threads to look like real work. + */ export function partitionOnboardingProjects( candidates: ReadonlyArray, now = Date.now(), @@ -13,6 +19,8 @@ export function partitionOnboardingProjects { + if (candidate.git === null) return false; + if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false; if (candidate.lastActiveAt === null) return false; const lastActiveAt = Date.parse(candidate.lastActiveAt); return lastActiveAt >= cutoff && lastActiveAt <= now; @@ -20,6 +28,78 @@ export function partitionOnboardingProjects { + /** Stable identity for collapse state and React keys. */ + readonly key: string; + /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */ + readonly label: string; + readonly repository: string | null; + readonly candidates: ReadonlyArray; + readonly threadCount: number; + readonly lastActiveAt: string | null; +} + +function latestActivity(left: string | null, right: string | null): string | null { + if (left === null) return right; + if (right === null) return left; + return left > right ? left : right; +} + +/** + * Group scan candidates for the onboarding picker. Clones of one repository + * share a group keyed by their normalized origin URL. Repositories without an + * origin get a group each. Directories that are not git repositories are + * returned separately so the UI can fold them away by default. Groups sort by + * most recent activity, newest first. + */ +export function groupOnboardingProjects< + T extends Pick< + AgentSessionProjectCandidate, + "path" | "title" | "git" | "threadCount" | "lastActiveAt" + >, +>(candidates: ReadonlyArray) { + const groups = new Map & { candidates: Array }>(); + const other: Array = []; + + for (const candidate of candidates) { + if (candidate.git === null) { + other.push(candidate); + continue; + } + const key = + candidate.git.remoteKey === null + ? `path:${candidate.path}` + : `remote:${candidate.git.remoteKey}`; + const existing = groups.get(key); + if (existing === undefined) { + groups.set(key, { + key, + label: candidate.git.repository ?? candidate.title, + repository: candidate.git.repository, + candidates: [candidate], + threadCount: candidate.threadCount, + lastActiveAt: candidate.lastActiveAt, + }); + continue; + } + existing.candidates.push(candidate); + groups.set(key, { + ...existing, + threadCount: existing.threadCount + candidate.threadCount, + lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt), + }); + } + + const repositories = [...groups.values()].sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { repositories, other }; +} + /** Use the server's project match before the client snapshot, which can lag behind the scan. */ export function resolveOnboardingProjectId( projects: ReadonlyArray<{ diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index b169a7b7f251..d124ab6efbe2 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -46,9 +46,16 @@ terminal metadata while the terminal process can use them. ## Import your projects -T3 Code finds directories that Claude Code or Codex has used. The default -selection includes projects active within the last 30 days. Use the checkboxes -to include older projects or change the selection. +T3 Code finds directories that Claude Code or Codex has used. Git repositories +are listed first, newest activity on top. When the remote is on GitHub, the +group shows the repository as `owner/name`. Clones with the same remote share +one group. Directories that are not git repositories sit under "Other folders". + +The default selection includes git repositories active within the last 30 days +with at least three conversations. Use the checkboxes, or "Select all" and +"Select none", to change the selection. Linked git worktrees, Codex scratch +directories under `Documents/Codex`, and anything under `Downloads` are not +offered. A large or malformed history can reach the scan limit. T3 Code keeps the projects it found and warns when projects or conversations may be missing. diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index ffd90dd79db8..0248f75860a7 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -37,6 +37,18 @@ export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; * T3 Code project. `alreadyImported` marks candidates that already have an * active project rooted at the same path. */ +/** + * Git identity of a candidate directory, read from `.git/config` without + * spawning git. `remoteKey` is the normalized origin URL, shared by every + * clone of the same repository so the client can group them. `repository` + * is the GitHub `owner/name` when the origin is on GitHub. + */ +export const AgentSessionProjectGit = Schema.Struct({ + remoteKey: Schema.NullOr(Schema.String), + repository: Schema.NullOr(Schema.String), +}); +export type AgentSessionProjectGit = typeof AgentSessionProjectGit.Type; + export const AgentSessionProjectCandidate = Schema.Struct({ path: TrimmedNonEmptyString, title: TrimmedNonEmptyString, @@ -45,6 +57,8 @@ export const AgentSessionProjectCandidate = Schema.Struct({ threadCount: NonNegativeInt, lastActiveAt: Schema.NullOr(IsoDateTime), alreadyImported: Schema.Boolean, + /** `null` when the directory is not the root of a git repository. */ + git: Schema.NullOr(AgentSessionProjectGit), }); export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..9d7ed527088d 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -7,6 +7,7 @@ import { isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseOriginUrlFromGitConfig, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; @@ -51,6 +52,72 @@ describe("normalizeGitRemoteUrl", () => { }); }); +describe("parseOriginUrlFromGitConfig", () => { + it("reads the origin url and ignores other remotes", () => { + const config = [ + "[core]", + "\trepositoryformatversion = 0", + '[remote "upstream"]', + "\turl = https://github.com/other/repo.git", + '[remote "origin"]', + "\turl = git@github.com:pingdotgg/t3code.git", + "\tfetch = +refs/heads/*:refs/remotes/origin/*", + '[branch "main"]', + "\tremote = origin", + ].join("\n"); + expect(parseOriginUrlFromGitConfig(config)).toBe("git@github.com:pingdotgg/t3code.git"); + }); + + it("strips inline comments and quotes from the url value", () => { + expect( + parseOriginUrlFromGitConfig( + '[remote "origin"]\n\turl = https://github.com/acme/repo.git # mirror\n', + ), + ).toBe("https://github.com/acme/repo.git"); + expect( + parseOriginUrlFromGitConfig('[remote "origin"]\n\turl = "git@github.com:acme/repo.git"\n'), + ).toBe("git@github.com:acme/repo.git"); + }); + + it("accepts legacy dotted headers, header comments, and line continuations", () => { + expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe( + "git@github.com:a/b.git", + ); + // Git folds the dotted form to lowercase but keeps quoted names as written. + expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe( + "git@github.com:a/b.git", + ); + expect( + parseOriginUrlFromGitConfig( + '[remote "Origin"]\n\turl = git@github.com:x/y.git\n[remote "origin"]\n\turl = git@github.com:a/b.git\n', + ), + ).toBe("git@github.com:a/b.git"); + expect( + parseOriginUrlFromGitConfig('[remote "origin"] # primary\n\turl = git@github.com:a/b.git\n'), + ).toBe("git@github.com:a/b.git"); + expect( + parseOriginUrlFromGitConfig( + '[remote "origin"]\n\turl = https://github.com/acme/\\\n\t\trepo.git\n', + ), + ).toBe("https://github.com/acme/repo.git"); + }); + + it("falls back to the first remote when there is no origin", () => { + const config = [ + '[remote "upstream"]', + "\turl = https://github.com/acme/repo.git", + '[remote "fork"]', + "\turl = https://github.com/me/repo.git", + ].join("\n"); + expect(parseOriginUrlFromGitConfig(config)).toBe("https://github.com/acme/repo.git"); + }); + + it("returns null when there is no remote section", () => { + expect(parseOriginUrlFromGitConfig("[core]\n\tbare = false\n")).toBeNull(); + expect(parseOriginUrlFromGitConfig("")).toBeNull(); + }); +}); + describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { it("extracts the owner and repository from common GitHub remote shapes", () => { expect( @@ -59,6 +126,9 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { expect( parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..8e618d2d6441 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -143,6 +143,70 @@ export function normalizeGitRemoteUrl(value: string): string { return normalized; } +/** + * Unquote a git config value: strip an inline `#` or `;` comment outside + * quotes, then drop surrounding quotes and backslash escapes. + */ +function parseGitConfigValue(raw: string): string { + let out = ""; + let quoted = false; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]!; + if (char === "\\" && index + 1 < raw.length) { + out += raw[index + 1]; + index += 1; + continue; + } + if (char === '"') { + quoted = !quoted; + continue; + } + if (!quoted && (char === "#" || char === ";")) break; + out += char; + } + return out.trim(); +} + +/** + * Read the primary remote URL from raw `.git/config` text without spawning + * git. Prefers `remote.origin.url` and falls back to the first remote so + * clones made with `git clone --origin ` still resolve. + */ +export function parseOriginUrlFromGitConfig(configText: string): string | null { + let section: string | null = null; + let originUrl: string | null = null; + let firstRemoteUrl: string | null = null; + // A trailing backslash continues the value on the next line. + const joined = configText.replace(/\\\r?\n[ \t]*/g, ""); + for (const rawLine of joined.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue; + // Both `[remote "origin"]` and the legacy `[remote.origin]` form, with an + // optional trailing comment. Git keeps quoted subsections case-sensitive + // but folds the dotted form to lowercase. + const header = /^\[\s*remote(?:\s+"([^"]+)"|\.([^\]\s]+))\s*\](?:\s*[#;].*)?$/i.exec(line); + if (header) { + section = header[1] ?? header[2]?.toLowerCase() ?? null; + continue; + } + if (line.startsWith("[")) { + section = null; + continue; + } + if (section === null) continue; + const match = /^url\s*=\s*(.*)$/i.exec(line); + if (!match) continue; + const url = parseGitConfigValue(match[1] ?? ""); + if (url.length === 0) continue; + if (section === "origin") { + originUrl ??= url; + } else { + firstRemoteUrl ??= url; + } + } + return originUrl ?? firstRemoteUrl; +} + /** * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. */ @@ -153,7 +217,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; From fe07ffe7c0bf1858ba4d1cd1ce0a67b10802a747 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:29:02 +0300 Subject: [PATCH 08/11] fix(web): remove inserted citations on cancel (#10518) --- .../web/src/components/ComposerCitationNode.tsx | 17 +++++++++++------ .../components/chat/AssistantCitationChip.tsx | 9 ++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ComposerCitationNode.tsx b/apps/web/src/components/ComposerCitationNode.tsx index ffaae589343f..2c8f02586688 100644 --- a/apps/web/src/components/ComposerCitationNode.tsx +++ b/apps/web/src/components/ComposerCitationNode.tsx @@ -42,6 +42,7 @@ export type ComposerCitationCommentRequest = { export type ComposerCitationCommentTarget = { nodeKey: NodeKey; sourceAnchor?: AssistantCitationSourceAnchor; + removeOnCancel?: boolean; }; export const ComposerCitationCommentContext = createContext<{ @@ -68,7 +69,11 @@ export function $consumeComposerCitationCommentRequest(requestRef: { let offset = 0; for (const node of paragraph.getChildren()) { if (offset === request.citationStart && node instanceof ComposerCitationNode) { - return { nodeKey: node.getKey(), sourceAnchor: request.sourceAnchor }; + return { + nodeKey: node.getKey(), + sourceAnchor: request.sourceAnchor, + removeOnCancel: true, + }; } offset += node.getTextContentSize(); } @@ -78,6 +83,8 @@ export function $consumeComposerCitationCommentRequest(requestRef: { function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) { const [editor] = useLexicalComposerContext(); const commentContext = use(ComposerCitationCommentContext); + const commentTarget = + commentContext.openComment?.nodeKey === props.nodeKey ? commentContext.openComment : null; const onSaveComment = (comment: string): boolean => { if (!editor.isEditable()) return false; let accepted = false; @@ -118,15 +125,13 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey { if (open && !editor.isEditable()) return; commentContext.onOpenChange(props.nodeKey, open); }, + ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), onSave: onSaveComment, onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 7582bb19c5c6..ccfd746666a4 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -41,6 +41,7 @@ export function AssistantCitationChip({ open: boolean; sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; + onCancel?: () => void; onSave: (comment: string) => boolean; onSaveAndSend?: (comment: string) => boolean; }; @@ -168,7 +169,13 @@ export function AssistantCitationChip({ }, } : {})} - onCancel={() => commentEditor.onOpenChange(false)} + onCancel={() => { + if (commentEditor.onCancel) { + commentEditor.onCancel(); + } else { + commentEditor.onOpenChange(false); + } + }} /> ) : null} From 357b8d5217cd62826767b6ce6d7c47915f2777d2 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 7 Sep 2026 15:28:06 +0200 Subject: [PATCH 09/11] fix(mobile): match Working status color to desktop --- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 2 +- apps/mobile/src/features/threads/threadPresentation.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2d5251fb5f8d..c2e66ece53de 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -55,7 +55,7 @@ const STATUS_LABEL_BY_STATUS: Partial< > = { approval: { label: "Approval", className: "text-warning-foreground" }, input: { label: "Input", className: "text-foreground-secondary" }, - working: { label: "Working", className: "text-foreground-secondary" }, + working: { label: "Working", className: "text-adaptive-sky-600-400" }, failed: { label: "Failed", className: "text-danger-foreground" }, }; diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index bfc72d2ac9b8..dcff521342c6 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -67,7 +67,7 @@ export function resolveThreadStatus( kind: "working", label: "Working", pillClassName: "bg-primary/10", - textClassName: "text-foreground-secondary", + textClassName: "text-adaptive-sky-600-400", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, From 8d7f78121660eb8599d4c5ff40cb4394159abfab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 10:10:00 -0700 Subject: [PATCH 10/11] fix(mobile): wait for native thread scroll before reveal (#10486) --- patches/@legendapp__list@3.3.5.patch | 258 +++++++++++---------- pnpm-lock.yaml | 10 +- scripts/legend-list-initial-reveal.test.ts | 214 +++++++++++++++++ 3 files changed, 357 insertions(+), 125 deletions(-) create mode 100644 scripts/legend-list-initial-reveal.test.ts diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 9b7268fec585..98784b999549 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -241,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac0047a8ead3 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d0677d75f 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { @@ -269,16 +269,25 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); set$( ctx, -@@ -954,7 +963,7 @@ function setInitialRenderState(ctx, { +@@ -954,7 +963,16 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ // Native contentOffset can seed an end target without dispatchInitialScroll. ++ // Both overflow completion paths must wait for the native end landing. ++ // Underflow needs no scroll: preserve UIKit's untouched resting position. ++ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) { ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) { +@@ -1090,7 +1108,7 @@ function getRawContentLength(ctx) { function getAlignItemsAtEndPadding(ctx) { const { state } = ctx; const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; @@ -287,7 +296,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 } function updateContentMetricsState(ctx) { const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; -@@ -1115,6 +1124,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { +@@ -1115,6 +1133,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { totalSize += add; } if (prevTotalSize !== totalSize) { @@ -298,7 +307,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { state.pendingTotalSize = totalSize; } else { -@@ -1304,18 +1317,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1304,18 +1326,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -324,7 +333,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 return clampedOffset; } -@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -337,7 +346,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1503,7 +1521,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1503,7 +1530,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -349,7 +358,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1560,15 +1590,28 @@ function doMaintainScrollAtEnd(ctx) { } = state; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); @@ -379,7 +388,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1591,9 +1625,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1634,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -401,7 +410,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 } setTimeout( () => { -@@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1676,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -412,7 +421,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1784,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -423,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1790,7 +1839,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1848,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -432,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1981,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -441,7 +450,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2378,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -451,11 +460,11 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; +function startInsetEndSettleWatchdog(ctx) { + const state = ctx.state; -+ if (state.insetEndSettleWatchdogActive) { ++ if (state.insetEndSettleWatchdogStarted || state.didLoad) { + return; + } ++ state.insetEndSettleWatchdogStarted = true; + state.insetEndSettleWatchdogActive = true; -+ state.didUserDrag = false; + // Hold the readyToRender opacity gate until the end landing is stable, so + // the estimated-to-measured settle chase happens before first VISIBLE + // paint instead of in front of the user. Capped so slow measurement can @@ -464,6 +473,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 + let frames = 0; + let settledFrames = 0; + let revealStableFrames = 0; ++ let previousEndOffset; + const releaseRevealHold = () => { + if (state.insetEndRevealHold) { + state.insetEndRevealHold = false; @@ -503,33 +513,32 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); + const contentSize = getContentSize(ctx); + const scrollLength = state.scrollLength; -+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { -+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); -+ const distance = endOffset - state.scroll; -+ // Estimated row sizes converging to measured ones can strand the initial -+ // end landing when the library's own end-anchor bookkeeping gives up. -+ // While still near the end (never fighting a user who scrolled away), -+ // re-pin to the current true end until sizes stop changing. -+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { -+ settledFrames = 0; -+ revealStableFrames = 0; ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit ++ // moves. Only a native scroll event proves that the requested offset landed. ++ const nativeOffset = state.lastNativeScroll; ++ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset); ++ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity; ++ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd; ++ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize); ++ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1; ++ previousEndOffset = endOffset; ++ if (isStable) { ++ settledFrames++; ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } else { ++ settledFrames = 0; ++ revealStableFrames = 0; ++ // Keep the existing near-end correction limit. In-flight scrolls and ++ // unknown native offsets wait; neither counts as a settled frame. ++ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { + const scroller = state.refScroller.current; + if (scroller) { + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); + } -+ } else { -+ settledFrames++; -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ } else { -+ // Conditions that make re-pinning unnecessary (underflow, in-flight -+ // programmatic scroll) count toward stability for the reveal. -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); + } + } + requestAnimationFrame(tick); @@ -563,7 +572,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2918,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -574,7 +583,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4672,7 +4845,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -584,7 +593,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4692,6 +4866,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -597,7 +606,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 } return nextSize; } -@@ -5715,6 +5886,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5715,6 +5895,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -605,7 +614,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const columnWrapperStyle = ctx.columnWrapperStyle; const animSize = useValue$("totalSize"); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); -@@ -5725,6 +5897,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5725,6 +5906,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ opacity: isVisible ? 1 : 0, width: animSize } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; @@ -619,7 +628,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 if (columnWrapperStyle) { const { columnGap, rowGap, gap } = columnWrapperStyle; const gapX = columnGap || gap || 0; -@@ -5745,7 +5924,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5745,7 +5933,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ } } } @@ -629,7 +638,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 }); var Containers = typedMemo(function Containers2({ freshDataTransitionEpoch, -@@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet; +@@ -5896,7 +6085,12 @@ var StyleSheet = ReactNative.StyleSheet; // src/components/ListComponent.tsx var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { @@ -642,7 +651,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 if (alignItemsAtEndPadding <= 0) { return null; } -@@ -5929,8 +6114,12 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5929,8 +6123,12 @@ var ListComponent = typedMemo(function ListComponent2({ refScrollView, renderScrollComponent, onLayoutFooter, @@ -655,7 +664,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6001,7 +6199,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -674,7 +683,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6010,7 +6209,10 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6010,7 +6218,10 @@ var ListComponent = typedMemo(function ListComponent2({ ], contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, horizontal, @@ -686,7 +695,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 onLayout, onScroll: onScroll2, ref: refScrollView, -@@ -6751,7 +6953,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -695,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7075,6 +7277,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -703,7 +712,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7335,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7132,10 +7344,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -716,7 +725,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7405,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7414,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -725,7 +734,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7341,6 +7546,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -733,7 +742,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7578,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -741,7 +750,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7423,6 +7630,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -755,7 +764,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7547,6 +7761,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -763,7 +772,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7858,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7643,6 +7867,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -771,7 +780,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7651,6 +7867,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -780,7 +789,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7676,11 +7894,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -800,7 +809,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97440bc623 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { @@ -828,16 +837,25 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); set$( ctx, -@@ -933,7 +942,7 @@ function setInitialRenderState(ctx, { +@@ -933,7 +942,16 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ // Native contentOffset can seed an end target without dispatchInitialScroll. ++ // Both overflow completion paths must wait for the native end landing. ++ // Underflow needs no scroll: preserve UIKit's untouched resting position. ++ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) { ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) { +@@ -1069,7 +1087,7 @@ function getRawContentLength(ctx) { function getAlignItemsAtEndPadding(ctx) { const { state } = ctx; const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; @@ -846,7 +864,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } function updateContentMetricsState(ctx) { const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; -@@ -1094,6 +1103,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { +@@ -1094,6 +1112,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { totalSize += add; } if (prevTotalSize !== totalSize) { @@ -857,7 +875,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { state.pendingTotalSize = totalSize; } else { -@@ -1283,18 +1296,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1283,18 +1305,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -883,7 +901,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 return clampedOffset; } -@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -896,7 +914,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1482,7 +1500,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1482,7 +1509,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -908,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1539,15 +1569,28 @@ function doMaintainScrollAtEnd(ctx) { } = state; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); @@ -938,7 +956,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1570,9 +1604,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1613,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -960,7 +978,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } setTimeout( () => { -@@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1655,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -971,7 +989,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1763,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -982,7 +1000,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1769,7 +1818,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1827,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -991,7 +1009,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1960,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -1000,7 +1018,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2357,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -1010,11 +1028,11 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; +function startInsetEndSettleWatchdog(ctx) { + const state = ctx.state; -+ if (state.insetEndSettleWatchdogActive) { ++ if (state.insetEndSettleWatchdogStarted || state.didLoad) { + return; + } ++ state.insetEndSettleWatchdogStarted = true; + state.insetEndSettleWatchdogActive = true; -+ state.didUserDrag = false; + // Hold the readyToRender opacity gate until the end landing is stable, so + // the estimated-to-measured settle chase happens before first VISIBLE + // paint instead of in front of the user. Capped so slow measurement can @@ -1023,6 +1041,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 + let frames = 0; + let settledFrames = 0; + let revealStableFrames = 0; ++ let previousEndOffset; + const releaseRevealHold = () => { + if (state.insetEndRevealHold) { + state.insetEndRevealHold = false; @@ -1062,33 +1081,32 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); + const contentSize = getContentSize(ctx); + const scrollLength = state.scrollLength; -+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { -+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); -+ const distance = endOffset - state.scroll; -+ // Estimated row sizes converging to measured ones can strand the initial -+ // end landing when the library's own end-anchor bookkeeping gives up. -+ // While still near the end (never fighting a user who scrolled away), -+ // re-pin to the current true end until sizes stop changing. -+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { -+ settledFrames = 0; -+ revealStableFrames = 0; ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit ++ // moves. Only a native scroll event proves that the requested offset landed. ++ const nativeOffset = state.lastNativeScroll; ++ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset); ++ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity; ++ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd; ++ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize); ++ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1; ++ previousEndOffset = endOffset; ++ if (isStable) { ++ settledFrames++; ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } else { ++ settledFrames = 0; ++ revealStableFrames = 0; ++ // Keep the existing near-end correction limit. In-flight scrolls and ++ // unknown native offsets wait; neither counts as a settled frame. ++ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { + const scroller = state.refScroller.current; + if (scroller) { + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); + } -+ } else { -+ settledFrames++; -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ } else { -+ // Conditions that make re-pinning unnecessary (underflow, in-flight -+ // programmatic scroll) count toward stability for the reveal. -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); + } + } + requestAnimationFrame(tick); @@ -1122,7 +1140,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2897,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -1133,7 +1151,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4651,7 +4824,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -1143,7 +1161,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4671,6 +4845,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -1156,7 +1174,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } return nextSize; } -@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -1164,7 +1182,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const columnWrapperStyle = ctx.columnWrapperStyle; const animSize = useValue$("totalSize"); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); -@@ -5704,6 +5876,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5704,6 +5885,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ opacity: isVisible ? 1 : 0, width: animSize } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; @@ -1178,7 +1196,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 if (columnWrapperStyle) { const { columnGap, rowGap, gap } = columnWrapperStyle; const gapX = columnGap || gap || 0; -@@ -5724,7 +5903,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5724,7 +5912,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ } } } @@ -1188,7 +1206,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 }); var Containers = typedMemo(function Containers2({ freshDataTransitionEpoch, -@@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1; +@@ -5875,7 +6064,12 @@ var StyleSheet = StyleSheet$1; // src/components/ListComponent.tsx var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { @@ -1201,7 +1219,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 if (alignItemsAtEndPadding <= 0) { return null; } -@@ -5908,8 +6093,12 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5908,8 +6102,12 @@ var ListComponent = typedMemo(function ListComponent2({ refScrollView, renderScrollComponent, onLayoutFooter, @@ -1214,7 +1232,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -1233,7 +1251,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -5989,7 +6188,10 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5989,7 +6197,10 @@ var ListComponent = typedMemo(function ListComponent2({ ], contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, horizontal, @@ -1245,7 +1263,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 onLayout, onScroll: onScroll2, ref: refScrollView, -@@ -6730,7 +6932,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -1254,7 +1272,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7054,6 +7256,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1262,7 +1280,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7314,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7111,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -1275,7 +1293,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7384,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -1284,7 +1302,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7320,6 +7525,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -1292,7 +1310,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7557,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1300,7 +1318,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7402,6 +7609,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -1314,7 +1332,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7526,6 +7740,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1322,7 +1340,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7837,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7622,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -1330,7 +1348,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7630,6 +7846,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -1339,7 +1357,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7655,11 +7873,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7655,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95403e871a9b..18b5667a3659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': 2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee + '@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -236,7 +236,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -580,7 +580,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -13269,7 +13269,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -13277,7 +13277,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=2c042ab630ac8b857024c178ee8c145d99c2f7297d9f94f5676238f08f0806ee)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) diff --git a/scripts/legend-list-initial-reveal.test.ts b/scripts/legend-list-initial-reveal.test.ts new file mode 100644 index 000000000000..15ebc3f597e5 --- /dev/null +++ b/scripts/legend-list-initial-reveal.test.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics nodeBuiltinImport:off - reads the installed native JS bundle for synchronous VM tests. +import * as NodeFS from "node:fs"; +import * as NodeVM from "node:vm"; +import { describe, expect, it, vi } from "vite-plus/test"; + +// Exercise the shipped patch without loading React Native in the Node runner. +// Native scroll delivery and animation frames advance independently here. +function createList(bundle: string) { + const source = NodeFS.readFileSync( + new URL(`../apps/mobile/node_modules/@legendapp/list/${bundle}`, import.meta.url), + "utf8", + ); + const renderState = source.slice( + source.indexOf("function setInitialRenderState("), + source.indexOf("// src/core/finishInitialScroll.ts"), + ); + const watchdog = source.slice( + source.indexOf("var INSET_END_SETTLE_WATCHDOG_FRAMES"), + source.indexOf("function dispatchInitialScroll("), + ); + const frames: Array<() => void> = []; + const values = new Map(); + const scrollTo = vi.fn(); + const onLoad = vi.fn(); + const state = { + props: { data: ["message"], onLoad, drawDistance: 500 }, + initialScroll: { index: 0, viewPosition: 1 }, + loadStartTime: 0, + didContainersLayout: true, + didFinishInitialScroll: true, + didLoad: false, + didUserDrag: false, + scrollLength: 800, + scroll: 400, + lastNativeScroll: 200 as number | undefined, + scrollingTo: undefined as { offset: number } | undefined, + maintainingScrollAtEnd: false, + refScroller: { current: { scrollTo } }, + }; + const ctx = { state }; + let contentSize = 1200; + let inset = 100; + const api = NodeVM.runInNewContext( + `${renderState}\n${watchdog}\n({ start: startInsetEndSettleWatchdog, complete: setInitialRenderState })`, + { + requestAnimationFrame: (callback: () => void) => frames.push(callback), + getContentSize: () => contentSize, + getContentInsetStartAdjustment: () => inset, + peek$: (_ctx: unknown, key: string) => values.get(key), + set$: (_ctx: unknown, key: string, value: unknown) => values.set(key, value), + setAdaptiveRender: vi.fn(), + scheduleFullDrawDistancePrewarm: vi.fn(), + INITIAL_DRAW_DISTANCE: 250, + }, + ) as { + start: (context: typeof ctx) => void; + complete: (context: typeof ctx, flags: Record) => void; + }; + return { + state, + scrollTo, + onLoad, + start: () => api.start(ctx), + complete: () => api.complete(ctx, {}), + ready: () => values.get("readyToRender") === true, + resize: (size: number) => { + contentSize = size; + }, + setInset: (value: number) => { + inset = value; + }, + advance(count: number) { + for (let index = 0; index < count; index++) { + const batch = frames.splice(0); + for (const frame of batch) frame(); + } + }, + }; +} + +for (const bundle of ["react-native.js", "react-native.mjs"]) { + describe(`initial inset end reveal (${bundle})`, () => { + it("waits for the native offset instead of the optimistic scroll target", () => { + const list = createList(bundle); + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + expect(list.scrollTo).toHaveBeenCalledWith({ animated: false, x: 0, y: 400 }); + list.state.lastNativeScroll = 400; + list.advance(7); + expect(list.ready()).toBe(true); + expect(list.onLoad).toHaveBeenCalledTimes(1); + }); + + it("gates the seeded contentOffset completion path too", () => { + const list = createList(bundle); + list.complete(); + expect(list.ready()).toBe(false); + list.state.lastNativeScroll = 400; + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("preserves an initial index before the last item", () => { + const list = createList(bundle); + list.state.props.data = ["requested message", "later message"]; + list.state.scroll = 200; + list.complete(); + list.advance(8); + expect(list.ready()).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + }); + + it("does not count an in-flight scroll as stability", () => { + const list = createList(bundle); + list.state.lastNativeScroll = 400; + list.state.scrollingTo = { offset: 400 }; + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + list.state.scrollingTo = undefined; + list.state.maintainingScrollAtEnd = true; + list.advance(8); + expect(list.ready()).toBe(false); + list.state.maintainingScrollAtEnd = false; + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("waits for a stable end target even when native scrolling follows each measurement", () => { + const list = createList(bundle); + list.start(); + for (let index = 0; index < 10; index++) { + list.resize(1200 + index * 10); + list.state.lastNativeScroll = 400 + index * 10; + list.advance(1); + } + expect(list.ready()).toBe(false); + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("accounts for header insets when content is shorter than the full viewport", () => { + const list = createList(bundle); + list.resize(780); + list.state.scroll = -20; + list.state.lastNativeScroll = -100; + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + expect(list.scrollTo).toHaveBeenCalledWith({ animated: false, x: 0, y: -20 }); + list.state.lastNativeScroll = -20; + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("keeps the reveal bounded when native events never arrive", () => { + const list = createList(bundle); + list.state.lastNativeScroll = undefined; + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + expect(list.scrollTo).not.toHaveBeenCalled(); + list.advance(32); + expect(list.ready()).toBe(true); + }); + + it("releases control on drag and never starts another initial hold", () => { + const list = createList(bundle); + list.start(); + list.state.didUserDrag = true; + list.advance(1); + expect(list.ready()).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + list.complete(); + list.start(); + list.advance(20); + expect(list.state.didUserDrag).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + expect(list.onLoad).toHaveBeenCalledTimes(1); + }); + + it("preserves a drag that started before initial layout completed", () => { + const list = createList(bundle); + list.state.didUserDrag = true; + list.complete(); + list.advance(1); + expect(list.ready()).toBe(true); + expect(list.state.didUserDrag).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + }); + + it("reveals short content at UIKit's resting offset without awaiting a scroll event", () => { + const list = createList(bundle); + list.resize(700); + list.state.lastNativeScroll = undefined; + list.complete(); + expect(list.ready()).toBe(true); + list.advance(10); + expect(list.scrollTo).not.toHaveBeenCalled(); + }); + + it("does not gate empty lists or lists without an automatic header inset", () => { + const list = createList(bundle); + list.setInset(0); + list.complete(); + expect(list.ready()).toBe(true); + const empty = createList(bundle); + empty.state.props.data = []; + empty.complete(); + expect(empty.ready()).toBe(true); + }); + }); +} From 1d1bf50405ccca8fdb8b57879d9def379e27a550 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:24:18 +0000 Subject: [PATCH 11/11] chore(mobile): bump app version to 1.1.0 Co-authored-by: codex --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c4a7717cd46a..f54d1a94d521 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -169,7 +169,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.4", + version: "1.1.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay