diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ac12131fc5c8..511b227d22b7 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -183,7 +183,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.4", + version: "1.1.0", // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay // fingerprinted so OTAs only reach binaries with matching native projects. diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 6aaad0ecf67a..02766f993a1a 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -414,6 +414,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 e4826b05eb00..8a7d9670bff8 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -6,6 +6,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"; @@ -113,26 +114,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; @@ -334,13 +339,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( @@ -375,10 +385,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/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 91bd65d9e984..60e44881921e 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; @@ -2280,11 +2281,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, @@ -2297,6 +2305,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }), [ props.dispatchingMessageId, + unsettledTurnId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2523,12 +2532,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; @@ -2917,8 +2920,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 diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index f3b7a36ab132..f898fabc96c6 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, { @@ -66,6 +67,9 @@ export function FloatingWorkingControl(props: { readonly hasUnreadActivity?: 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(() => { @@ -79,17 +83,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) => { @@ -124,15 +121,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; @@ -141,6 +150,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} > @@ -264,7 +274,9 @@ function FloatingStatusLabel(props: { onLayout={props.onLayout} > - {props.status.label} + + {props.status.label} + ); } @@ -286,7 +298,10 @@ function FloatingStatusLabel(props: { ) : ( )} - + {props.status.label} @@ -323,8 +338,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"; @@ -336,7 +350,7 @@ function StatusLabelRow(props: { const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; return ( = { 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" }, }; @@ -671,7 +671,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel: `Settle ${thread.title}`, icon: "checkmark" as const, label: "Settle", - dismissOnPress: true as const, onPress: handleSettle, }; }, [ @@ -1006,6 +1005,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 35692d77abcd..717146555ad3 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -100,7 +100,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, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 8428ede35113..76c4884c8114 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -216,6 +216,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-03-01T00:00:00.000Z", alreadyImported: false, + git: null, }, { path: olderWorkspace, @@ -224,6 +225,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), @@ -266,6 +268,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-02-09T11:00:00.000Z", alreadyImported: false, + git: null, }, { path: workspace, @@ -274,6 +277,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-02-09T10:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), @@ -392,6 +396,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-04-01T09:00:00.000Z", alreadyImported: true, + git: null, }, ]); }), @@ -424,6 +429,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path: workspace, projectId: ProjectId.make("project-1"), alreadyImported: true, + git: null, }); }), ); @@ -455,6 +461,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path: workspaceAlias, projectId: ProjectId.make("project-1"), alreadyImported: true, + git: null, }); }), ); @@ -501,6 +508,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: true, + git: null, }, ]); }), @@ -858,6 +866,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; @@ -1234,6 +1351,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/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} 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 052fedeb526f..b4796df56b53 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 3f14e1b78d4a..5acd3e92b52c 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() ?? ""; diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 2fea500e1bde..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..24d0763aef074411eb7d17f0feb8df752a843de0 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 } 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 return clampedOffset; } -@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -337,7 +346,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 } 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..24d0763aef074411eb7d17f0feb8df75 } 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 +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..24d0763aef074411eb7d17f0feb8df75 + 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..24d0763aef074411eb7d17f0feb8df75 + 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,18 +572,18 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 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)) { - 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, { -@@ -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..24d0763aef074411eb7d17f0feb8df75 } 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..24d0763aef074411eb7d17f0feb8df75 } 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 }); 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..24d0763aef074411eb7d17f0feb8df75 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..24d0763aef074411eb7d17f0feb8df75 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,19 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -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, +- 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 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -683,7 +704,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 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -691,7 +712,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7332,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; @@ -704,7 +725,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7402,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; @@ -713,7 +734,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 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -721,7 +742,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -729,7 +750,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 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -743,7 +764,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 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -751,7 +772,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7855,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) => { @@ -759,7 +780,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 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -768,7 +789,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 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -788,7 +809,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..93aac741d2cf77ce35996362439108176f19da7a 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { @@ -816,16 +837,25 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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; @@ -834,7 +864,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } 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) { @@ -845,7 +875,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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 @@ -871,7 +901,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff return clampedOffset; } -@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -884,7 +914,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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) { @@ -896,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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); @@ -926,7 +956,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } 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 { @@ -948,7 +978,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } 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) { @@ -959,7 +989,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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; } @@ -970,7 +1000,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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; } @@ -979,7 +1009,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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 { @@ -988,7 +1018,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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 @@ -998,11 +1028,11 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff +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 @@ -1011,6 +1041,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff + let frames = 0; + let settledFrames = 0; + let revealStableFrames = 0; ++ let previousEndOffset; + const releaseRevealHold = () => { + if (state.insetEndRevealHold) { + state.insetEndRevealHold = false; @@ -1050,33 +1081,32 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff + 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); @@ -1110,18 +1140,18 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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)) { - 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, { -@@ -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; @@ -1131,7 +1161,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } 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 }); @@ -1144,7 +1174,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } return nextSize; } -@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -1152,7 +1182,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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 }; @@ -1166,7 +1196,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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({ } } } @@ -1176,7 +1206,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff }); 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 }) { @@ -1189,7 +1219,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff 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, @@ -1202,7 +1232,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -1221,7 +1251,19 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -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, +- 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 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -1230,7 +1272,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 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1238,7 +1280,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7311,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; @@ -1251,7 +1293,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7381,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; @@ -1260,7 +1302,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 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -1268,7 +1310,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1276,7 +1318,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 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -1290,7 +1332,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 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1298,7 +1340,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7834,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) => { @@ -1306,7 +1348,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 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -1315,7 +1357,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 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1d2f180a812..1f17efeff632 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,7 +94,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': 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 @@ -285,7 +285,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=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 @@ -660,7 +660,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=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) @@ -13550,7 +13550,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=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) @@ -13558,7 +13558,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=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); + }); + }); +}