diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml new file mode 100644 index 000000000000..e4fa135ad32b --- /dev/null +++ b/.github/workflows/mobile-eas-production.yml @@ -0,0 +1,110 @@ +name: Mobile EAS Production + +# Production builds and OTA updates run from CI (Linux) — never from a laptop. +# Under the fingerprint runtime-version policy the fingerprint must be computed +# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different +# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux +# runner, with corepack pinning pnpm 10.24 in eas.json, local == build. +on: + workflow_dispatch: + inputs: + mode: + description: "build (+ auto-submit to TestFlight) or update (OTA)" + required: true + type: choice + default: build + options: + - build + - update + platform: + description: "Target platform" + required: true + type: choice + default: ios + options: + - ios + - android + - all + message: + description: "OTA update message (mode=update only)" + required: false + type: string + +jobs: + production: + name: EAS Production ${{ inputs.mode }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - id: expo-token + name: Check for EXPO_TOKEN + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_TOKEN is not available; skipping EAS production job." + fi + + - name: Checkout + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + packager: pnpm + + - name: Pull production environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: eas env:pull production --non-interactive + + - name: Build and submit + if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait + + - name: Publish OTA update + if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + eas update \ + --channel production \ + --environment production \ + --platform ${{ inputs.platform }} \ + --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ + --non-interactive diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 8aa27efdf7b6..3ea4ad77b56d 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -106,7 +106,11 @@ const config: ExpoConfig = { scheme: variant.scheme, version: mobileAppVersion, runtimeVersion: { - policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion", + // Fingerprint (not appVersion) so an OTA only reaches binaries whose native + // project — native deps, config plugins, AND patches/ — matches the update. + // With appVersion, every 0.1.0 build shares a runtime version, so a JS update + // could land on a binary missing the native changes it needs and crash. + policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", }, orientation: "portrait", icon: "./assets/icon.png", diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index c351dc98dfc6..0541344ce4a4 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -6,6 +6,7 @@ }, "build": { "development": { + "corepack": true, "env": { "APP_VARIANT": "development", "EXPO_OWNER": "adam0410", @@ -17,6 +18,7 @@ "distribution": "internal" }, "preview": { + "corepack": true, "env": { "APP_VARIANT": "preview", "EXPO_OWNER": "adam0410", @@ -31,6 +33,7 @@ } }, "preview:dev": { + "corepack": true, "env": { "APP_VARIANT": "preview", "EXPO_OWNER": "adam0410", @@ -47,6 +50,7 @@ } }, "production": { + "corepack": true, "env": { "APP_VARIANT": "production", "EXPO_OWNER": "adam0410", @@ -68,7 +72,10 @@ "submit": { "production": { "ios": { - "ascAppId": "6761315631" + "ascAppId": "6787819824" + }, + "android": { + "track": "internal" } } } diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index 3698a0a5fc7b..3d4bf4944f14 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -1,4 +1,6 @@ import { Connection } from "@t3tools/client-runtime/connection"; +import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell"; +import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads"; import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; @@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( Layer.provide(runtimeContextLayer), ); +const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer); + type ConnectionLayerSource = | typeof Connection.layer + | typeof snapshotLoaderLayer | typeof runtimeContextLayer | typeof connectionPlatformLayer; -const connectionLayer = Connection.layer.pipe( +const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index abeba0def0e1..e44944229a92 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -16,8 +16,8 @@ import { } from "@t3tools/client-runtime/connection"; import { EnvironmentId, - OrchestrationThread, OrchestrationShellSnapshot, + OrchestrationThreadDetailSnapshot, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -32,7 +32,10 @@ import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; -const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +// v2 stores the snapshot sequence alongside the thread so a warm cache can +// resume via `afterSequence` instead of re-downloading the full thread body. +// Older v1 entries (no sequence) fail to decode and are treated as a cold cache. +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; const StoredShellSnapshot = Schema.Struct({ @@ -45,7 +48,7 @@ const StoredThreadSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), environmentId: EnvironmentId, threadId: ThreadId, - thread: OrchestrationThread, + snapshot: OrchestrationThreadDetailSnapshot, }); const LegacyStoredShellSnapshot = Schema.Struct({ @@ -361,18 +364,18 @@ export const connectionStorageLayer = Layer.effectContext( Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), ); return stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.thread) + ? Option.some(stored.snapshot) : Option.none(); }), - saveThread: (environmentId, thread) => + saveThread: (environmentId, snapshot) => Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); + const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread"); const encoded = yield* Effect.fromResult( encodeStoredThreadSnapshot({ schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, - threadId: thread.id, - thread, + threadId: snapshot.thread.id, + snapshot, }), ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); yield* Effect.try({ diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 11d363dbf691..d4d0a1ab9927 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,5 +1,7 @@ import { useAuth, useUser } from "@clerk/expo"; +import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; +import * as Updates from "expo-updates"; import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "expo-symbols"; @@ -422,6 +424,23 @@ function ConfiguredSettingsRouteScreen() { function AppSettingsSection() { const icon = useThemeColor("--color-icon"); + const version = Constants.expoConfig?.version ?? "0.0.0"; + // Fall back to "production" to match resolveAppVariant in app.config.ts, so a + // missing variant never mislabels a production build as development. + const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; + const variantLabel = variant === "production" ? "" : capitalize(variant); + const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + // Which JS is actually running: the bundle shipped in the binary, or an OTA + // update downloaded on top of it. Surfacing this makes "am I even on the + // right build?" answerable at a glance. + const bundleLabel = Updates.isEnabled + ? Updates.isEmbeddedLaunch + ? "Embedded" + : Updates.updateId + ? `OTA ${Updates.updateId.slice(0, 7)}` + : null + : null; + return ( @@ -433,12 +452,21 @@ function AppSettingsSection() { weight="regular" /> Version - Alpha + + {versionLabel} + {bundleLabel ? ( + {bundleLabel} + ) : null} + ); } +function capitalize(value: string): string { + return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} + function ArchivedThreadsSettingsSection() { return ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index b16d9c577ea9..e710c86865db 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -25,6 +25,7 @@ import { type ViewStyle, } from "react-native"; import ImageViewing from "react-native-image-viewing"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -113,6 +114,10 @@ export interface ThreadComposerProps { * The pill / card container — renders as LiquidGlassView on supported * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. */ +// One timing for every piece of the expanded↔compact morph so the surface, +// toolbar, and siblings move together instead of popping between layouts. +const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220); + function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; @@ -131,7 +136,7 @@ function ComposerSurface(props: { if (isLiquidGlassSupported) { return ( - + {props.children} - + ); } return ( - + {props.children} - + ); } @@ -668,7 +673,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return ( - - {composerTrigger && composerMenuItems.length > 0 ? ( @@ -732,13 +739,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer > {/* Attachment strip — inside the card, above the text input */} {isExpanded ? ( - 0 ? 10 : 0 }}> + 0 ? 10 : 0 }} + > - + ) : null} @@ -811,81 +822,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} {!isExpanded ? ( - showStopAction ? ( - - ) : ( - - ) + + {showStopAction ? ( + + ) : ( + + )} + ) : null} {/* Toolbar row — matches draft page layout (expanded only) */} {isExpanded ? ( - - - void props.onPickDraftImages()} - showChevron={false} - /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={currentModelOption?.label ?? currentModelSelection.model} - /> - - handleOptionsMenuAction(nativeEvent.event)} + + + - - - {showStopAction ? ( void props.onPickDraftImages()} showChevron={false} /> - ) : null} - - - + handleModelMenuAction(nativeEvent.event)} + > + + } + label={currentModelOption?.label ?? currentModelSelection.model} + /> + + handleOptionsMenuAction(nativeEvent.event)} + > + + + {showStopAction ? ( + + ) : null} + + + + ) : null} {/* Queue count */} {props.queueCount > 0 ? ( - - {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send - automatically. - + + + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send + automatically. + + ) : null} - + - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e0634102cb1d..f9f6e878b96b 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,8 +17,9 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; +import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; @@ -170,7 +171,11 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray + @@ -200,7 +210,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { - + ); }); @@ -238,11 +248,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; + const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + // The overlay's measured height includes the home-indicator inset (the + // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes + // UIKit add the safe-area bottom to the content inset AGAIN — leaving a + // dead strip between the resting content and the composer. Report the + // overlay height minus the safe area; UIKit adds it back, and ThreadFeed + // hands LegendList the same delta via contentInsetEndStaticAdjustment so + // its end-scroll math matches the real resting position. + const nativeInsetOvercount = + props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0; const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( listRef, composerOverlayRef, - estimatedOverlayHeight, + Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), + -nativeInsetOvercount, ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; @@ -408,18 +428,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > - - + {/* No paddingTop here: the overlay's measured height becomes the + list's bottom inset, so any padding above the pill/composer + pushes the resting content floor up by the same amount. */} + + {props.activeWorkStartedAt ? ( ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( - + {props.activePendingApproval ? ( ) : null} - + ) : null} - + + ) : null} - + ); } @@ -804,8 +830,12 @@ function renderFeedEntry( return null; } + const enterAnimated = isFreshTimestamp(message.createdAt); return ( - + {message.text.trim().length > 0 ? ( hasNativeSelectableMarkdownText() ? ( ) : null} - + ); } @@ -1146,6 +1176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); + const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1177,6 +1208,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; + // With automatic insets the header inset lives in UIKit's adjustedContentInset, + // which LegendList's JS anchoring math cannot see — it measures the anchored + // end space from the scroll view's frame top. Fold the header height back into + // the anchor offset or a just-sent message anchors underneath the header and + // the oversized end space keeps maintainScrollAtEnd snapping away from earlier + // messages. Read the context directly (useHeaderHeight throws outside a + // header-providing screen) and fall back to the standard iOS bar height. + const navigationHeaderHeight = useContext(HeaderHeightContext); + const anchorTopInset = usesNativeAutomaticInsets + ? navigationHeaderHeight || insets.top + 44 + : topContentInset; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); @@ -1242,13 +1284,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { - reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6); + // anchorTopInset, not topContentInset: under automatic insets the list + // rests at contentOffset.y = -headerHeight (the inset lives only in + // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the + // header height back or the material toggles a full header too late. + reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); }, - [reportHeaderMaterialVisibility, topContentInset], + [reportHeaderMaterialVisibility, anchorTopInset], ); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); + const nextHeight = Math.round(event.nativeEvent.layout.height); setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); useEffect(() => { @@ -1275,15 +1323,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], ); + // The empty↔filled key below remounts the list, which resets its imperative + // content-inset override — and useKeyboardChatComposerInset (mounted above + // the remount boundary) deduplicates by height, so it never re-reports the + // composer inset to the fresh instance. Without this, the remounted list's + // initial scroll-to-end computes with a zero end inset and rests one + // composer-height short of the end. Layout effect: it must land before the + // list's first positioning tick or the one-shot initial scroll misses it. + const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + useLayoutEffect(() => { + const bottom = props.contentInsetEndAdjustment.value; + if (bottom > 0) { + props.listRef.current?.reportContentInset({ bottom }); + } + }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, (entry) => (entry.type === "message" ? entry.id : null), - { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, + { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), - [presentedFeed, props.anchorMessageId, topContentInset], + [presentedFeed, props.anchorMessageId, anchorTopInset], ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); @@ -1506,7 +1569,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // an already-attached list under a transparent header can pin // short content at offset 0 (one header-height too high). A fresh // mount positions during attach, where UIKit applies the inset. - key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} + key={listMountKey} style={{ flex: 1 }} // RN 0.81+ drops touches inside the contentInset area // (facebook/react-native#54123); the anchored end space after a send @@ -1527,13 +1590,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} + // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): + // lets its scroll math clamp programmatic scrolls to -headerInset + // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short + // content rest below the transparent header rather than at frame top. + contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0} contentInsetEndAdjustment={props.contentInsetEndAdjustment} + // UIKit's automatic behavior adds the safe-area bottom on top of the + // raw contentInset the keyboard integration writes. The detail screen + // under-reports the composer inset by this amount (see + // ThreadDetailScreen); this tells LegendList's scroll math about the + // extra so programmatic end scrolls land at the true resting offset. + contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} + // The keyboard integration's offset math (end pinning, max scroll) + // must add the same UIKit-added extra, or its keyboard-open end + // targets land one safe-area short of the true resting offset. + adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0} freeze={props.freeze} + // Animated: on send, the optimistic message's dataChange fires + // maintainScrollAtEnd before any render-cycle suppression could + // engage — an instant snap there teleports the feed to the anchor + // instead of scrolling to it. Keeping it enabled (animated) during + // anchor scrolls also lets it correct a scroll that landed on a + // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ disclosureToggleSettling ? false : { - animated: false, + animated: true, on: { dataChange: true, itemLayout: true, @@ -1552,6 +1637,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { keyboardShouldPersistTaps="always" keyboardDismissMode="none" keyboardLiftBehavior="whenAtEnd" + // Seed the list's scroll math with the real viewport before its own + // onLayout: the empty→filled remount can then tell at mount that + // short content underflows the viewport and skip programmatic + // positioning entirely (any offset write during screen attach races + // UIKit's adjustedContentInset application and lands high or low). + {...(viewportHeight > 0 && viewportWidth > 0 + ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } } + : {})} + // RN's native scrollTo command clamps targets to a floor of + // -contentInset.top using the RAW inset — under automatic insets the + // header inset only exists in adjustedContentInset, so scrolls to + // negative offsets (content top below the transparent header) get + // clamped to 0. This prop disables that clamp; UIKit still bounces + // user overscroll back to the adjusted rest position. + scrollToOverflowEnabled estimatedItemSize={180} initialScrollAtEnd onScroll={handleScroll} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 2bf3b5c9a154..24005a898aef 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -5,6 +5,7 @@ import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "re import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import Animated, { FadeIn } from "react-native-reanimated"; const WORK_LOG_LAYOUT_ANIMATION = { duration: 180, @@ -68,6 +69,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { } } +// Entering fades only for rows created moments ago: rows remount whenever the +// list scrolls them back into view, and old rows must not replay an entrance. +const FRESH_ROW_WINDOW_MS = 3_000; +function isFreshRow(createdAt: string): boolean { + const timestamp = Date.parse(createdAt); + return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; +} + export function ThreadWorkLog(props: { readonly activities: ReadonlyArray; readonly copiedRowId: string | null; @@ -104,7 +113,10 @@ export function ThreadWorkLog(props: { const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; return ( - + ) : null} - + ); })} diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a0..780aaabde251 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -16,6 +16,8 @@ import { EnvironmentOperationForbiddenError, EnvironmentRequestInvalidError, type EnvironmentRequestInvalidReason, + EnvironmentResourceNotFoundError, + type EnvironmentResourceNotFoundReason, EnvironmentScopeRequiredError, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, @@ -137,6 +139,14 @@ function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_a ); } +export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReason) { + return currentEnvironmentTraceId.pipe( + Effect.flatMap((traceId) => + Effect.fail(new EnvironmentResourceNotFoundError({ code: "not_found", reason, traceId })), + ), + ); +} + export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { return Effect.gen(function* () { const traceId = yield* currentEnvironmentTraceId; diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c1dbc8337183..8e0e5fb74d51 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => { }), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), ), ); @@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), ), ); @@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), ), ); @@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), ), ); @@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => { getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts index b7a36f9f6d06..427a367ac6f4 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts @@ -279,6 +279,7 @@ const projectionLayer = Layer.succeed(ProjectionSnapshotQuery, { ? Effect.sleep(`${childDetailDelayMs} millis`).pipe(Effect.as(detail)) : Effect.succeed(detail); }), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }); const coordinatorLayer = Layer.succeed(ChildThreadCoordinator, { diff --git a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts index e5ad5a48db1c..32fc5b5d733f 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts @@ -355,6 +355,7 @@ describe("ChildThreadCoordinator", () => { const state = threadStates.get(threadId); return state ? Option.some(state.detail) : Option.none(); }), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }); const registryLayer = Layer.succeed(ProviderInstanceRegistry, { diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 36c6ec02bac2..0860643ab0f6 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => { getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 7277663e9485..9598d1c3ef5b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }), ); - const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) => - eventStore.readFromSequence(fromSequenceExclusive); + const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => + eventStore.readFromSequence(fromSequenceExclusive, limit); const dispatch: OrchestrationEngineShape["dispatch"] = (command) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fb8a37ea53c0..245330645646 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -9,6 +9,7 @@ import { OrchestrationReadModel, OrchestrationShellSnapshot, OrchestrationThread, + OrchestrationThreadDetailSnapshot, ProjectScript, TurnId, type OrchestrationCheckpointSummary, @@ -2068,6 +2069,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = ( + threadId, + ) => + // Read the thread detail and the snapshot sequence within a single + // transaction so the sequence is consistent with the returned state; a + // projector update landing between two separate reads could otherwise return + // a sequence ahead of the thread detail, causing the client to resume from + // too far and drop events. + sql + .withTransaction( + Effect.gen(function* () { + const thread = yield* getThreadDetailById(threadId); + if (Option.isNone(thread)) { + return Option.none(); + } + const { snapshotSequence } = yield* getSnapshotSequence(); + return Option.some({ snapshotSequence, thread: thread.value }); + }), + ) + .pipe( + Effect.mapError((error) => + isPersistenceError(error) + ? error + : toPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:transaction")( + error, + ), + ), + ); + return { getCommandReadModel, getSnapshot, @@ -2082,6 +2112,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getFullThreadDiffContext, getThreadShellById, getThreadDetailById, + getThreadDetailSnapshot, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts b/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts index 52364b08d9b2..04eeefa0f7e2 100644 --- a/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts @@ -168,6 +168,7 @@ describe("ScheduledTasksReactor", () => { return shell ? Option.some(shell) : Option.none(); }), getThreadDetailById: () => unsupported(), + getThreadDetailSnapshot: () => unsupported(), }); const coordinatorLayer = Layer.succeed(ChildThreadCoordinator, { diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index acb2b7b042da..dc1b5fd30035 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -26,10 +26,15 @@ export interface OrchestrationEngineShape { * Replay persisted orchestration events from an exclusive sequence cursor. * * @param fromSequenceExclusive - Sequence cursor (exclusive). + * @param limit - Maximum number of events to read. Defaults to the event + * store's page-bounded default; pass a higher value when the caller must + * read every event after the cursor (e.g. per-thread catch-up that filters + * a small subset out of a potentially larger global range). * @returns Stream containing ordered events. */ readonly readEvents: ( fromSequenceExclusive: number, + limit?: number, ) => Stream.Stream; /** diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 7d85f0240f74..23b291d8778a 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -14,6 +14,7 @@ import type { OrchestrationReadModel, OrchestrationShellSnapshot, OrchestrationThread, + OrchestrationThreadDetailSnapshot, OrchestrationThreadShell, ProjectId, ThreadId, @@ -157,6 +158,16 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailById: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread detail together with the projection snapshot + * sequence in one consistent transaction, so the returned `snapshotSequence` + * exactly matches the state reflected in `thread` (no interleaving projector + * update between the two reads). + */ + readonly getThreadDetailSnapshot: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index a148f98474bc..016c3d508ec9 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -4,6 +4,7 @@ import { EnvironmentHttpApi, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { normalizeDispatchCommand } from "./Normalizer.ts"; @@ -11,6 +12,7 @@ import { annotateEnvironmentRequest, failEnvironmentInternal, failEnvironmentInvalidRequest, + failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; @@ -38,6 +40,38 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( ); }), ) + .handle( + "shellSnapshot", + Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + return yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_snapshot_failed", cause), + ), + ); + }), + ) + .handle( + "threadSnapshot", + Effect.fn("environment.orchestration.threadSnapshot")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const snapshot = yield* projectionSnapshotQuery + .getThreadDetailSnapshot(args.params.threadId) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(snapshot)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return snapshot.value; + }), + ) .handle( "dispatch", Effect.fn("environment.orchestration.dispatch")(function* (args) { diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index fdf95df0b996..15612908079a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3a537bcd4bef..dfc93e6d1614 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -238,6 +238,7 @@ describe("ProviderSessionReaper", () => { : Option.none(), ), getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 72daa1cfefd7..cda6d0ba1e90 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -744,6 +744,7 @@ const buildAppUnderTest = (options?: { getProjectShellById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e331f0cd4d6c..1a331bc717f1 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -96,6 +96,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -158,6 +159,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -248,6 +251,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 594776c4ff51..a5c7794f2eb7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -28,6 +28,8 @@ import { OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, + type OrchestrationShellStreamItem, + type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -819,10 +821,54 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(toShellStreamEvent), + Stream.flatMap((event) => + Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + ), + ); + + // When the client already holds a shell snapshot (cached, or loaded + // over HTTP) it passes that snapshot's sequence, and we resume by + // replaying shell events after it instead of re-sending the whole + // projects/threads list over the socket. As in the thread path, the + // live subscription is attached (into a scope-bound buffer) before + // draining the catch-up replay so no event published during the + // replay window is lost; overlapping events are deduped by sequence + // on the client. The full range is read (not the store's default + // page limit) since the shell filter runs after reading. + if (input.afterSequence !== undefined) { + const afterSequence = input.afterSequence; + return Stream.unwrap( + Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), + ); + const catchUpStream = orchestrationEngine + .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) + .pipe( + Stream.mapEffect(toShellStreamEvent), + Stream.flatMap((event) => + Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + ), + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to replay orchestration shell events", + cause, + }), + ), + ); + return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); + }), + ); + } + const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), @@ -836,13 +882,6 @@ const makeWsRpcLayer = ( ), ); - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, - ), - ); - return Stream.concat( Stream.make({ kind: "snapshot" as const, @@ -874,8 +913,65 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeThread, Effect.gen(function* () { - const [threadDetail, snapshotSequence] = yield* Effect.all([ - projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( + const isThisThreadDetailEvent = (event: OrchestrationEvent) => + event.aggregateKind === "thread" && + event.aggregateId === input.threadId && + isThreadDetailEvent(event); + + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.filter(isThisThreadDetailEvent), + Stream.map((event) => ({ + kind: "event" as const, + event, + })), + ); + + // When the client already loaded the snapshot over HTTP it passes + // that snapshot's sequence, and we resume the live subscription by + // replaying persisted events after it instead of re-sending the + // (potentially multi-KB) snapshot frame over the socket. + // + // The live PubSub subscription must be attached *before* draining + // the catch-up replay, otherwise events published during the replay + // window are dropped (they are past the persisted tail the replay + // read, but the live stream is not yet subscribed). So fork the + // live stream into a buffer bound to this stream's scope, then emit + // catch-up followed by the buffered/ongoing live events. Overlapping + // events are deduped by sequence on the client. + // + // Read the full range after the cursor (not the store's default + // page-bounded limit): the range is normally tiny (a fresh HTTP + // snapshot sequence) and the per-thread filter runs after reading, + // so a global cap could otherwise omit this thread's events. + if (input.afterSequence !== undefined) { + const afterSequence = input.afterSequence; + return Stream.unwrap( + Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), + ); + const catchUpStream = orchestrationEngine + .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) + .pipe( + Stream.filter(isThisThreadDetailEvent), + Stream.map((event) => ({ kind: "event" as const, event })), + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to replay thread ${input.threadId} events`, + cause, + }), + ), + ); + return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); + }), + ); + } + + const snapshot = yield* projectionSnapshotQuery + .getThreadDetailSnapshot(input.threadId) + .pipe( Effect.mapError( (cause) => new OrchestrationGetSnapshotError({ @@ -883,46 +979,19 @@ const makeWsRpcLayer = ( cause, }), ), - ), - projectionSnapshotQuery.getSnapshotSequence().pipe( - Effect.map(({ snapshotSequence }) => snapshotSequence), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot sequence", - cause, - }), - ), - ), - ]); + ); - if (Option.isNone(threadDetail)) { + if (Option.isNone(snapshot)) { return yield* new OrchestrationGetSnapshotError({ message: `Thread ${input.threadId} was not found`, cause: input.threadId, }); } - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.aggregateKind === "thread" && - event.aggregateId === input.threadId && - isThreadDetailEvent(event), - ), - Stream.map((event) => ({ - kind: "event" as const, - event, - })), - ); - return Stream.concat( Stream.make({ kind: "snapshot" as const, - snapshot: { - snapshotSequence, - thread: threadDetail.value, - }, + snapshot: snapshot.value, }), liveStream, ); diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index 3698a0a5fc7b..3d4bf4944f14 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -1,4 +1,6 @@ import { Connection } from "@t3tools/client-runtime/connection"; +import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell"; +import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads"; import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; @@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( Layer.provide(runtimeContextLayer), ); +const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer); + type ConnectionLayerSource = | typeof Connection.layer + | typeof snapshotLoaderLayer | typeof runtimeContextLayer | typeof connectionPlatformLayer; -const connectionLayer = Connection.layer.pipe( +const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index d118a428ed74..df311d076150 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -20,7 +20,7 @@ import { import { EnvironmentId, OrchestrationShellSnapshot, - OrchestrationThread, + OrchestrationThreadDetailSnapshot, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -45,11 +45,14 @@ const StoredShellSnapshot = Schema.Struct({ snapshot: OrchestrationShellSnapshot, }); const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); +// v2 stores the snapshot sequence alongside the thread so a warm cache can +// resume via `afterSequence` instead of re-downloading the full thread body. +// Older v1 entries (no sequence) fail to decode and are treated as a cold cache. const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(1), + schemaVersion: Schema.Literal(2), environmentId: EnvironmentId, threadId: ThreadId, - thread: OrchestrationThread, + snapshot: OrchestrationThreadDetailSnapshot, }); const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); @@ -473,7 +476,7 @@ export const connectionStorageLayer = Layer.effectContext( Effect.mapError((cause) => persistenceError("load-thread", cause)), Effect.map((stored) => stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.thread) + ? Option.some(stored.snapshot) : Option.none(), ), ); @@ -484,18 +487,18 @@ export const connectionStorageLayer = Layer.effectContext( : persistenceError("load-thread", cause), ), ), - saveThread: (environmentId, thread) => + saveThread: (environmentId, snapshot) => Effect.gen(function* () { const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: 1, + schemaVersion: 2, environmentId, - threadId: thread.id, - thread, + threadId: snapshot.thread.id, + snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); yield* writeDatabaseValue( database, THREAD_STORE_NAME, - threadCacheKey(environmentId, thread.id), + threadCacheKey(environmentId, snapshot.thread.id), encoded, ); }).pipe( diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index e94e6011930e..35c8c86a77a1 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -249,6 +249,8 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): case "EnvironmentScopeRequiredError": case "EnvironmentOperationForbiddenError": return 403; + case "EnvironmentResourceNotFoundError": + return 404; case "EnvironmentInternalError": return 500; } diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index a1c7eccaaef5..dc1ce6529b2a 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -137,6 +137,15 @@ export function mapRemoteEnvironmentError( detail: "The environment rejected the authentication request.", traceId: error.traceId, }); + case "EnvironmentResourceNotFoundError": + // Not expected during connection authorization, but the shared request + // error type now includes it (used by resource fetches like the thread + // snapshot). Treat it as a configuration issue with the endpoint. + return new ConnectionBlockedError({ + reason: "configuration", + detail: "The environment endpoint could not be found.", + traceId: error.traceId, + }); case "RemoteEnvironmentAuthTimeoutError": return new ConnectionTransientError({ reason: "timeout", diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 71664bf46017..047e77936114 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -1,7 +1,7 @@ import { type EnvironmentId, - type OrchestrationThread, type OrchestrationShellSnapshot, + type OrchestrationThreadDetailSnapshot, type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -60,10 +60,13 @@ export class EnvironmentCacheStore extends Context.Service< readonly loadThread: ( environmentId: EnvironmentId, threadId: ThreadId, - ) => Effect.Effect, ConnectionPersistenceError>; + ) => Effect.Effect< + Option.Option, + ConnectionPersistenceError + >; readonly saveThread: ( environmentId: EnvironmentId, - thread: OrchestrationThread, + snapshot: OrchestrationThreadDetailSnapshot, ) => Effect.Effect; readonly removeThread: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index 2f9248dc22dc..6bc8b9b1bba5 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -5,6 +5,7 @@ import { type EnvironmentInternalError, type EnvironmentOperationForbiddenError, type EnvironmentRequestInvalidError, + type EnvironmentResourceNotFoundError, type EnvironmentScopeRequiredError, } from "@t3tools/contracts"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; @@ -86,6 +87,7 @@ export type RemoteEnvironmentRequestError = | EnvironmentAuthInvalidError | EnvironmentScopeRequiredError | EnvironmentOperationForbiddenError + | EnvironmentResourceNotFoundError | EnvironmentInternalError | RemoteEnvironmentAccessRejectedError | RemoteEnvironmentAuthFetchError diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts new file mode 100644 index 000000000000..097708066200 --- /dev/null +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -0,0 +1,73 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { FetchHttpClient, type HttpMethod } from "effect/unstable/http"; + +import type { PreparedHttpAuthorization } from "../connection/model.ts"; +import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { RemoteEnvironmentAuthFetchError } from "../rpc/http.ts"; + +export interface EnvironmentHttpAuthHeaders { + readonly authorization?: string; + readonly dpop?: string; +} + +/** + * Primary/local environments with no bearer or DPoP credential authenticate the + * browser via a session cookie. A cross-origin `fetch` does not send cookies by + * default, so those requests must opt into credentialed mode; bearer/DPoP + * connections carry their credential in a header and need no cookies. Applied + * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads + * from the fiber context at request time. + */ +export const withEnvironmentCredentials = ( + authorization: PreparedHttpAuthorization | null, + request: Effect.Effect, +): Effect.Effect => + authorization === null + ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })) + : request; + +/** + * Build the authorization headers for an authenticated environment HTTP + * request, matching the credential the connection was prepared with: + * - primary/local connections carry no credential, + * - bearer connections send a static `Bearer` token, + * - relay connections send a `DPoP` access token with a freshly signed proof + * bound to this request's method and URL. + * + * The DPoP signer is passed in (not resolved from context) and is only required + * for relay/DPoP connections, so bearer/primary connections work even when no + * signer is available. + */ +export const buildEnvironmentAuthHeaders = ( + authorization: PreparedHttpAuthorization | null, + method: HttpMethod.HttpMethod, + url: string, + signer: Option.Option, +): Effect.Effect => + Effect.gen(function* () { + if (authorization === null) { + return {}; + } + if (authorization._tag === "Bearer") { + return { authorization: `Bearer ${authorization.token}` }; + } + if (Option.isNone(signer)) { + return yield* new RemoteEnvironmentAuthFetchError({ + message: "No DPoP signer is available to authorize the environment request.", + cause: authorization._tag, + }); + } + const proof = yield* signer.value + .createProof({ method, url, accessToken: authorization.accessToken }) + .pipe( + Effect.mapError( + (cause) => + new RemoteEnvironmentAuthFetchError({ + message: "Could not create the environment request authorization proof.", + cause, + }), + ), + ); + return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof }; + }); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 2eab7214225a..de240d58bbc6 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -20,7 +20,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { makeEnvironmentShellState } from "./shell.ts"; +import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -29,6 +29,15 @@ const TARGET = new PrimaryConnectionTarget({ wsBaseUrl: "wss://environment.example.test", }); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; + const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { snapshotSequence: 1, projects: [], @@ -61,7 +70,7 @@ describe("environment shell synchronization", () => { target: TARGET, state: supervisorState, session: activeSession, - prepared: yield* SubscriptionRef.make(Option.none()), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, @@ -74,9 +83,15 @@ describe("environment shell synchronization", () => { removeThread: () => Effect.void, clear: () => Effect.void, }); + // Cold cache with no HTTP snapshot available → falls back to the + // socket-embedded snapshot. + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => Effect.succeed(Option.none()), + }); const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), ); yield* SubscriptionRef.set(supervisorState, { @@ -117,4 +132,65 @@ describe("environment shell synchronization", () => { expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); }), ); + + it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => + Effect.gen(function* () { + const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 5, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const events = yield* Queue.unbounded(); + const capturedAfterSequence = yield* SubscriptionRef.make(undefined); + const loaderCalls = yield* SubscriptionRef.make(0); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => + Stream.unwrap( + SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe( + Effect.as(Stream.fromQueue(events)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => + SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), + }); + yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + // Wait until the subscription is established from the warm cache. + yield* SubscriptionRef.changes(capturedAfterSequence).pipe( + Stream.filter((value) => value !== undefined), + Stream.runHead, + ); + + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); + }), + ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 2b0ba6346f5d..faa70bc4f3a7 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -19,6 +19,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; +import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -48,6 +49,7 @@ const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment d export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; + const snapshotLoader = yield* ShellSnapshotLoader; const environmentId = supervisor.target.environmentId; const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( Effect.catch((error) => @@ -147,13 +149,45 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Queue.offer(persistence, nextSnapshot); }); - yield* subscribe( - ORCHESTRATION_WS_METHODS.subscribeShell, - {}, - { - onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), - }, - ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); + yield* Effect.forkScoped( + Effect.gen(function* () { + // Establish the base shell snapshot to resume from, minimizing bytes over + // the wire: + // - Warm cache: reuse the cached snapshot (zero network) and resume via + // `afterSequence` so we only receive shell events since the cached + // sequence. + // - Cold cache: load the full shell snapshot over HTTP (gzip-compressible, + // and off the socket), then resume via `afterSequence`. + // If no base can be established we fall back to the socket-embedded + // snapshot so the shell still synchronizes. Overlapping/replayed events are + // deduped by sequence in applyItem. + const base = Option.isSome(cachedSnapshot) + ? cachedSnapshot + : yield* Effect.gen(function* () { + const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((current) => current.value), + Stream.runHead, + ); + return Option.isSome(prepared) + ? yield* snapshotLoader.load(prepared.value) + : Option.none(); + }); + + if (Option.isSome(base)) { + yield* applyItem({ kind: "snapshot", snapshot: base.value }); + } + + const subscribeInput = Option.match(base, { + onNone: () => ({}), + onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }), + }); + + yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { + onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), + }).pipe(Stream.runForEach(applyItem)); + }), + ); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { @@ -293,7 +327,10 @@ export function createEnvironmentServerConfigsAtom(input: { } export function createEnvironmentShellAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime< + EnvironmentRegistry | EnvironmentCacheStore | ShellSnapshotLoader | R, + E + >, ) { const stateAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom(shellStateChanges(environmentId), { @@ -316,4 +353,5 @@ export function createEnvironmentShellAtoms( export * from "./models.ts"; export * from "./shellCommands.ts"; export * from "./shellReducer.ts"; +export * from "./shellSnapshotHttp.ts"; export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts new file mode 100644 index 000000000000..b0a492a1305f --- /dev/null +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -0,0 +1,92 @@ +import type { OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket +// fallback for long. The cached shell renders while this runs. +const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 6_000; + +/** + * Load the environment shell snapshot (projects + thread shells) over HTTP + * instead of as the WebSocket subscription's first frame. The response is + * gzip-compressible by the transport and keeps the (potentially large) list off + * the socket. + */ +export const fetchEnvironmentShellSnapshot = Effect.fn( + "clientRuntime.state.fetchEnvironmentShellSnapshot", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/orchestration/shell"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.shellSnapshot({ headers }), + ), + ); +}); + +/** + * Loads the environment shell snapshot over HTTP, returning `Option.none()` when + * it cannot be loaded (so the caller falls back to the socket-embedded snapshot). + * Decouples the shell state machine from the underlying HTTP + DPoP details and + * keeps them out of test contexts. + */ +export class ShellSnapshotLoader extends Context.Service< + ShellSnapshotLoader, + { + readonly load: ( + prepared: PreparedConnection, + ) => Effect.Effect>; + } +>()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {} + +export const shellSnapshotLoaderLayer: Layer.Layer< + ShellSnapshotLoader, + never, + HttpClient.HttpClient +> = Layer.effect( + ShellSnapshotLoader, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + // Resolve the DPoP signer optionally: it is only needed for relay/DPoP + // connections, so the loader must not hard-require it. + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + return ShellSnapshotLoader.of({ + load: (prepared: PreparedConnection) => + fetchEnvironmentShellSnapshot({ prepared, signer }).pipe( + Effect.map(Option.some), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.catchCause((cause) => + Effect.logWarning( + "Could not load the environment shell snapshot over HTTP; using the socket snapshot instead.", + ).pipe( + Effect.annotateLogs({ cause: Cause.pretty(cause) }), + Effect.as(Option.none()), + ), + ), + ), + }); + }), +); diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts new file mode 100644 index 000000000000..874bcc30ebdf --- /dev/null +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -0,0 +1,120 @@ +import type { OrchestrationThreadDetailSnapshot, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { + executeEnvironmentHttpRequest, + makeEnvironmentHttpApiClient, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket +// fallback for long. The cached thread renders while this runs, so the wait only +// delays the transition to live data on the first open, not the initial paint. +const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; + +/** + * Load a thread's detail snapshot over HTTP instead of embedding it in the + * WebSocket subscription's first frame. The response is gzip-compressible by + * the transport and keeps the (potentially multi-KB) snapshot off the socket. + */ +export const fetchEnvironmentThreadSnapshot = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadSnapshot", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}`, + ); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.threadSnapshot({ + params: { threadId: input.threadId }, + headers, + }), + ), + ); +}); + +export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError; + +/** + * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it + * cannot be loaded (so the caller falls back to the socket-embedded snapshot). + * Decouples the thread state machine from the underlying HTTP + DPoP details and + * keeps them out of test contexts. + */ +export class ThreadSnapshotLoader extends Context.Service< + ThreadSnapshotLoader, + { + readonly load: ( + prepared: PreparedConnection, + threadId: ThreadId, + ) => Effect.Effect>; + } +>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} + +export const threadSnapshotLoaderLayer: Layer.Layer< + ThreadSnapshotLoader, + never, + HttpClient.HttpClient +> = Layer.effect( + ThreadSnapshotLoader, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + // Resolve the DPoP signer optionally: it is only needed for relay/DPoP + // connections, so the loader must not hard-require it (bearer/primary + // connections work without one). + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + return ThreadSnapshotLoader.of({ + load: (prepared: PreparedConnection, threadId: ThreadId) => + fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( + Effect.map(Option.some), + Effect.provideService(HttpClient.HttpClient, httpClient), + // A genuinely missing thread (404) is expected — the socket + // subscription is the source of truth for thread existence and will + // surface the deletion — so don't treat it as an error worth warning + // about; just defer to the socket path. + Effect.catchTags({ + EnvironmentResourceNotFoundError: () => + Effect.logDebug( + "Thread snapshot not found over HTTP; deferring to the socket subscription.", + ).pipe( + Effect.annotateLogs({ threadId }), + Effect.as(Option.none()), + ), + }), + Effect.catchCause((cause) => + Effect.logWarning( + "Could not load the thread snapshot over HTTP; using the socket snapshot instead.", + ).pipe( + Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }), + Effect.as(Option.none()), + ), + ), + ), + }); + }), +); diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 420f9412b68a..0a9b2cd21b18 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -6,12 +6,12 @@ import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import type { EnvironmentCacheStore } from "../platform/persistence.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; -import { createEnvironmentThreadStateAtoms } from "./threads.ts"; +import { createEnvironmentThreadStateAtoms, type ThreadSnapshotLoader } from "./threads.ts"; describe("createEnvironmentThreadStateAtoms", () => { it("retains thread state across short subscriber gaps", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< - EnvironmentRegistry | EnvironmentCacheStore, + EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader, never >; const threads = createEnvironmentThreadStateAtoms(runtime); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 7f3ff689368f..6f7928a5fb2f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -7,6 +7,7 @@ import { ProviderInstanceId, ThreadId, type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -31,6 +32,7 @@ import * as RpcSession from "../rpc/session.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, makeEnvironmentThreadState, + ThreadSnapshotLoader, type EnvironmentThreadState, } from "./threads.ts"; @@ -41,6 +43,15 @@ const TARGET = new PrimaryConnectionTarget({ wsBaseUrl: "wss://environment.example.test", }); const THREAD_ID = ThreadId.make("thread-1"); +const CACHED_SNAPSHOT_SEQUENCE = 7; +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; const BASE_THREAD: OrchestrationThread = { id: THREAD_ID, projectId: ProjectId.make("project-1"), @@ -90,13 +101,16 @@ function awaitThreadState( const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { readonly cached?: OrchestrationThread; + readonly httpSnapshot?: Option.Option; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); const latest = yield* Ref.make(EMPTY_ENVIRONMENT_THREAD_STATE); const retryCount = yield* Ref.make(0); const subscriptionCount = yield* Ref.make(0); - const savedThreads = yield* Ref.make>([]); + const loaderCalls = yield* Ref.make(0); + const lastSubscribeAfterSequence = yield* Ref.make(undefined); + const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); const supervisorState = yield* SubscriptionRef.make( AVAILABLE_CONNECTION_STATE, @@ -108,17 +122,30 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), ); const client = { - [ORCHESTRATION_WS_METHODS.subscribeThread]: () => + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( - Effect.map(() => streamFrom(inputs)), + Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)), + Effect.as(streamFrom(inputs)), ), ), } as unknown as WsRpcProtocolClient; const supervisorSession = yield* SubscriptionRef.make>( Option.some(testSession(client)), ); - const prepared = yield* SubscriptionRef.make>(Option.none()); + const prepared = yield* SubscriptionRef.make>( + Option.some(PREPARED), + ); + const snapshotLoader = ThreadSnapshotLoader.of({ + load: (_prepared, threadId) => + Ref.update(loaderCalls, (count) => count + 1).pipe( + Effect.as( + threadId === THREAD_ID + ? (options?.httpSnapshot ?? Option.none()) + : Option.none(), + ), + ), + }); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, @@ -134,7 +161,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o loadThread: (_environmentId, threadId) => Effect.succeed( threadId === THREAD_ID && options?.cached !== undefined - ? Option.some(options.cached) + ? Option.some({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + thread: options.cached, + }) : Option.none(), ), saveThread: (_environmentId, thread) => @@ -146,6 +176,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ThreadSnapshotLoader, snapshotLoader), ); yield* SubscriptionRef.changes(threadState).pipe( Stream.runForEach((state) => @@ -160,6 +191,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o latest, retryCount, subscriptionCount, + loaderCalls, + lastSubscribeAfterSequence, supervisorState, supervisorSession, savedThreads, @@ -221,19 +254,38 @@ const deleted = (): OrchestrationThreadStreamItem => ({ }); describe("EnvironmentThreads", () => { - it.effect("publishes cached data before a live snapshot arrives", () => + it.effect("publishes cached data immediately from a warm cache", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); - const state = yield* awaitThreadState( - harness.observed, - (value) => value.status === "cached" && Option.isSome(value.data), - ); + const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data)); expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); expect(Option.isNone(state.error)).toBe(true); }), ); + it.effect("resumes a warm cache via afterSequence without an HTTP fetch", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + + // The warm cache reaches live from the cached data, and a live event + // applies on top of it. + yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); + yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + + // The subscription resumed from the cached sequence and never fetched the + // full snapshot over HTTP. + expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); + expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + }), + ); + it.effect("reduces live events and persists the latest thread", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); @@ -251,7 +303,34 @@ describe("EnvironmentThreads", () => { yield* Effect.yieldNow; expect(Option.getOrThrow(state.data).title).toBe("Live title"); - expect((yield* Ref.get(harness.savedThreads)).at(-1)?.title).toBe("Live title"); + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.thread.title).toBe("Live title"); + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.snapshotSequence).toBe(2); + }), + ); + + it.effect("seeds the thread from the HTTP snapshot and resumes live events", () => + Effect.gen(function* () { + const httpThread: OrchestrationThread = { ...BASE_THREAD, title: "HTTP title" }; + const harness = yield* makeHarness({ + httpSnapshot: Option.some({ snapshotSequence: 1, thread: httpThread }), + }); + // No socket snapshot is pushed; only a live event arrives over the socket. + // It can only be applied if the HTTP snapshot already seeded the thread. + yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + // Cold cache: the full snapshot was loaded over HTTP and the socket + // resumed from that snapshot's sequence. + expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1); + expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(1); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a01baf1594d0..fd5b425fa2a8 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -2,6 +2,7 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; @@ -18,6 +19,7 @@ import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; +import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; @@ -44,6 +46,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; + const snapshotLoader = yield* ThreadSnapshotLoader; const environmentId = supervisor.target.environmentId; const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => @@ -53,22 +56,27 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, error: error.message, }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ); + const cachedThread = Option.map(cached, (snapshot) => snapshot.thread); const state = yield* SubscriptionRef.make({ - data: cached, - status: statusWithoutLiveData(cached), + data: cachedThread, + status: statusWithoutLiveData(cachedThread), error: Option.none(), }); - const lastSequence = yield* SubscriptionRef.make(0); - const persistence = yield* Queue.sliding(1); + // Seed the resume cursor from the cached snapshot so a warm cache can catch up + // via `afterSequence` instead of re-downloading the full thread body. + const lastSequence = yield* SubscriptionRef.make( + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), + ); + const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( - thread: OrchestrationThread, + snapshot: OrchestrationThreadDetailSnapshot, ) { - yield* cache.saveThread(environmentId, thread).pipe( + yield* cache.saveThread(environmentId, snapshot).pipe( Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( Effect.annotateLogs({ @@ -120,7 +128,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "live", error: Option.none(), }); - yield* Queue.offer(persistence, thread); + // Persist the thread together with the sequence it reflects so the next warm + // cache can resume from exactly here. + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + yield* Queue.offer(persistence, { snapshotSequence, thread }); }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { @@ -187,21 +198,55 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); yield* setSynchronizing; - yield* subscribe( - ORCHESTRATION_WS_METHODS.subscribeThread, - { threadId }, - { - onExpectedFailure: setStreamError, - retryExpectedFailureAfter: "250 millis", - }, - ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); + yield* Effect.forkScoped( + Effect.gen(function* () { + // Establish the base snapshot to resume from, minimizing bytes over the + // wire: + // - Warm cache: reuse the cached snapshot (zero network) and resume via + // `afterSequence` so we only receive events since the cached sequence. + // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and + // off the socket), then resume via `afterSequence`. + // If no base can be established we fall back to the socket-embedded + // snapshot so the thread still synchronizes. Overlapping/replayed events + // are deduped by sequence in applyItem. + const base = Option.isSome(cached) + ? cached + : yield* Effect.gen(function* () { + // Cold cache only: wait for a prepared connection so we can + // authenticate the HTTP request; this mirrors the socket path, which + // likewise waits for a live session. + const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((current) => current.value), + Stream.runHead, + ); + return Option.isSome(prepared) + ? yield* snapshotLoader.load(prepared.value, threadId) + : Option.none(); + }); + + if (Option.isSome(base)) { + yield* applyItem({ kind: "snapshot", snapshot: base.value }); + } + + const subscribeInput = Option.match(base, { + onNone: () => ({ threadId }), + onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }), + }); + + yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { + onExpectedFailure: setStreamError, + retryExpectedFailureAfter: "250 millis", + }).pipe(Stream.runForEach(applyItem)); + }), + ); yield* Effect.addFinalizer(() => - SubscriptionRef.get(state).pipe( - Effect.flatMap((current) => + Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( + Effect.flatMap(([current, snapshotSequence]) => Option.match(current.data, { onNone: () => Effect.void, - onSome: persist, + onSome: (thread) => persist({ snapshotSequence, thread }), }), ), ), @@ -218,7 +263,10 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T } export function createEnvironmentThreadStateAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime< + EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader | R, + E + >, ) { const family = Atom.family((key: string) => { const { environmentId, threadId } = parseThreadKey(key); @@ -240,6 +288,7 @@ export function createEnvironmentThreadStateAtoms( export * from "./archivedThreads.ts"; export * from "./checkpointDiff.ts"; +export * from "./threadSnapshotHttp.ts"; export * from "./composerPathSearch.ts"; export * from "./threadCommands.ts"; export * from "./threadDetail.ts"; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 4f132a7a5e4d..e5411005958e 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -24,12 +24,14 @@ import { AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; -import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ClientOrchestrationCommand, DispatchResult, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThreadDetailSnapshot, } from "./orchestration.ts"; import { RelayCloudEnvironmentHealthRequest, @@ -90,6 +92,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ "client_sessions_load_failed", "client_session_revoke_failed", "orchestration_snapshot_failed", + "orchestration_thread_snapshot_failed", "orchestration_dispatch_failed", "internal_error", ]); @@ -165,11 +168,29 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass()( + "EnvironmentResourceNotFoundError", + { + code: Schema.Literal("not_found"), + reason: EnvironmentResourceNotFoundReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 404 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentResourceNotFoundError)(this, { status: 404 }); + } +} + export const EnvironmentHttpCommonError = Schema.Union([ EnvironmentRequestInvalidError, EnvironmentAuthInvalidError, EnvironmentScopeRequiredError, EnvironmentOperationForbiddenError, + EnvironmentResourceNotFoundError, EnvironmentInternalError, ]); export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type; @@ -279,6 +300,11 @@ const EnvironmentOrchestrationSnapshotErrors = [ EnvironmentScopeRequiredError, EnvironmentInternalError, ] as const; +const EnvironmentOrchestrationThreadSnapshotErrors = [ + EnvironmentScopeRequiredError, + EnvironmentResourceNotFoundError, + EnvironmentInternalError, +] as const; const EnvironmentOrchestrationDispatchErrors = [ EnvironmentRequestInvalidError, EnvironmentScopeRequiredError, @@ -433,6 +459,10 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") }).middleware(EnvironmentAuthenticatedAuth), ) {} +const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ + threadId: ThreadId, +}); + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -441,6 +471,21 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr error: EnvironmentOrchestrationSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", { + headers: OptionalBearerHeaders, + success: OrchestrationShellSnapshot, + error: EnvironmentOrchestrationSnapshotErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", { + headers: OptionalBearerHeaders, + params: EnvironmentOrchestrationThreadSnapshotParams, + success: OrchestrationThreadDetailSnapshot, + error: EnvironmentOrchestrationThreadSnapshotErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) .add( HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c1c09cf2debf..efdd90e5d1ae 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -550,8 +550,29 @@ export const OrchestrationDeleteScheduledTaskResult = Schema.Struct({ export type OrchestrationDeleteScheduledTaskResult = typeof OrchestrationDeleteScheduledTaskResult.Type; +export const OrchestrationSubscribeShellInput = Schema.Struct({ + /** + * When provided, the server skips the initial full shell snapshot and instead + * replays shell events after this sequence before streaming live events. + * Clients that already hold a cached (or HTTP-loaded) shell snapshot pass its + * sequence here so the subscription resumes without re-sending the entire + * projects/threads list (overlapping events are deduped by sequence on the + * client). + */ + afterSequence: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type; + export const OrchestrationSubscribeThreadInput = Schema.Struct({ threadId: ThreadId, + /** + * When provided, the server skips the initial snapshot frame and instead + * replays events after this sequence before streaming live events. Clients + * that load the snapshot over HTTP pass the snapshot's sequence here so the + * live subscription resumes without a gap (overlapping events are deduped by + * sequence on the client). + */ + afterSequence: Schema.optionalKey(NonNegativeInt), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; @@ -1380,7 +1401,7 @@ export const OrchestrationRpcSchemas = { output: OrchestrationThreadStreamItem, }, subscribeShell: { - input: Schema.Struct({}), + input: OrchestrationSubscribeShellInput, output: OrchestrationShellStreamItem, }, subscribeScheduledTasks: { diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch new file mode 100644 index 000000000000..686ea249b7a3 --- /dev/null +++ b/patches/@legendapp__list@3.2.0.patch @@ -0,0 +1,922 @@ +diff --git a/keyboard.d.ts b/keyboard.d.ts +index 5a115ea..2c65d31 100644 +--- a/keyboard.d.ts ++++ b/keyboard.d.ts +@@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { + type KeyboardChatComposerRef = { + current: Pick | null; + }; +-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { ++declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { + contentInsetEndAdjustment: SharedValue; + onComposerLayout: (event: LayoutChangeEvent) => void; + }; +@@ -278,8 +278,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb + scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; + }; + declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { ++ adjustedInsetCompensation?: number; + anchoredEndSpace?: AnchoredEndSpaceConfig; + contentInsetEndAdjustment?: SharedValue; ++ contentInsetEndStaticAdjustment?: number; + keyboardOffset?: number; + } & React.RefAttributes) => React.ReactElement | null; + +diff --git a/keyboard.js b/keyboard.js +index 736286a..8218172 100644 +--- a/keyboard.js ++++ b/keyboard.js +@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. + "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." + ); + } +-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { + const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); + const lastHeightRef = React.useRef(void 0); + const reportHeight = React.useCallback( +- (height) => { ++ (rawHeight) => { ++ const height = Math.max(0, rawHeight + heightAdjustment); + var _a; + if (Number.isFinite(height) && height !== lastHeightRef.current) { + lastHeightRef.current = height; + contentInsetEndAdjustment.value = height; +- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + } + }, +- [contentInsetEndAdjustment, listRef] ++ [contentInsetEndAdjustment, heightAdjustment, listRef] + ); + React.useLayoutEffect(() => { + var _a; +@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { + } + var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { + const { ++ adjustedInsetCompensation, + anchoredEndSpace, + applyWorkaroundForContentInsetHitTestBug, + contentInsetEndAdjustment, ++ contentInsetEndStaticAdjustment, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + includeInEndInset: true, + onSizeChanged: (size) => { + var _a; +- blankSpace.value = size; ++ // The anchored blank is sized against the ADJUSTED viewport, but the ++ // scroll component writes it as a raw contentInset that UIKit tops up ++ // with the safe-area bottom — write it net of that or the end rest ++ // (and the anchored message) sinks one safe-area under the header. ++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; + (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); + } + }; +- }, [anchoredEndSpace, blankSpace]); ++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); + const onContentInsetChange = React.useCallback((insets) => { + var _a; + (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); +@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + reactNativeKeyboardController.KeyboardChatScrollView, + { + ...scrollProps, ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + extraContentPadding: contentInsetEndAdjustment, +@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ); + }, + [ ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + contentInsetEndAdjustment, +@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + AnimatedLegendListInternal, + { + anchoredEndSpace: anchoredEndSpaceWithBlankSpace, ++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, + ref: combinedRef, + renderScrollComponent: memoList, + ...rest +diff --git a/keyboard.mjs b/keyboard.mjs +index c1dd270..cb0d142 100644 +--- a/keyboard.mjs ++++ b/keyboard.mjs +@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { + "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." + ); + } +-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { + const contentInsetEndAdjustment = useSharedValue(initialHeight); + const lastHeightRef = useRef(void 0); + const reportHeight = useCallback( +- (height) => { ++ (rawHeight) => { ++ const height = Math.max(0, rawHeight + heightAdjustment); + var _a; + if (Number.isFinite(height) && height !== lastHeightRef.current) { + lastHeightRef.current = height; + contentInsetEndAdjustment.value = height; +- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + } + }, +- [contentInsetEndAdjustment, listRef] ++ [contentInsetEndAdjustment, heightAdjustment, listRef] + ); + useLayoutEffect(() => { + var _a; +@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { + } + var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { + const { ++ adjustedInsetCompensation, + anchoredEndSpace, + applyWorkaroundForContentInsetHitTestBug, + contentInsetEndAdjustment, ++ contentInsetEndStaticAdjustment, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + includeInEndInset: true, + onSizeChanged: (size) => { + var _a; +- blankSpace.value = size; ++ // The anchored blank is sized against the ADJUSTED viewport, but the ++ // scroll component writes it as a raw contentInset that UIKit tops up ++ // with the safe-area bottom — write it net of that or the end rest ++ // (and the anchored message) sinks one safe-area under the header. ++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; + (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); + } + }; +- }, [anchoredEndSpace, blankSpace]); ++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); + const onContentInsetChange = useCallback((insets) => { + var _a; + (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); +@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + KeyboardChatScrollView, + { + ...scrollProps, ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + extraContentPadding: contentInsetEndAdjustment, +@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ); + }, + [ ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + contentInsetEndAdjustment, +@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + AnimatedLegendListInternal, + { + anchoredEndSpace: anchoredEndSpaceWithBlankSpace, ++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, + ref: combinedRef, + renderScrollComponent: memoList, + ...rest +diff --git a/react-native.d.ts b/react-native.d.ts +index 72d3f59..435a5fc 100644 +--- a/react-native.d.ts ++++ b/react-native.d.ts +@@ -284,6 +284,12 @@ interface LegendListSpecificProps { + * The adjustment is also rendered as real content padding so the browser scroll range includes it. + */ + contentInsetEndAdjustment?: number; ++ /** ++ * Width/height of a leading content inset applied natively outside the list's knowledge ++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). ++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. ++ */ ++ contentInsetStartAdjustment?: number; + /** + * Number of columns to render items in. + * @default 1 +diff --git a/react-native.js b/react-native.js +index 8d4ff89..18f0d62 100644 +--- a/react-native.js ++++ b/react-native.js +@@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal"); +@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { + } + + // src/core/clampScrollOffset.ts ++function getContentInsetStartAdjustment(ctx) { ++ const adjustment = ctx.state.props.contentInsetStartAdjustment; ++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + const state = ctx.state; + const contentSize = getContentSize(ctx); ++ const minOffset = -getContentInsetStartAdjustment(ctx); + let clampedOffset = offset; + if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { +- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); ++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); + const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; + const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; + const maxOffset = baseMaxOffset + extraEndOffset; + clampedOffset = Math.min(offset, maxOffset); + } +- clampedOffset = Math.max(0, clampedOffset); ++ clampedOffset = Math.max(minOffset, clampedOffset); + return clampedOffset; + } + +@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { + finishScrollTo(ctx); + } + } +-function scrollToFallbackOffset(ctx, offset) { ++function scrollToFallbackOffset(ctx, offset, animated) { + var _a3; + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, ++ animated: !!animated, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset + }); +@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { + }); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { + } + state.pendingMaintainScrollAtEnd = false; + if (shouldMaintainScrollAtEnd) { ++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; ++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; ++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { ++ return false; ++ } + const contentSize = getContentSize(ctx); ++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); + if (contentSize < state.scrollLength) { +- state.scroll = 0; ++ state.scroll = -maintainInsetStartAdjustment; ++ if (maintainInsetStartAdjustment > 0) { ++ return true; ++ } + } + if (!state.maintainingScrollAtEnd) { + const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; +@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { + y: 0 + }); + } else { +- scroller == null ? void 0 : scroller.scrollToEnd({ +- animated: maintainScrollAtEnd.animated +- }); ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0) { ++ scroller == null ? void 0 : scroller.scrollTo({ ++ animated: maintainScrollAtEnd.animated, ++ x: 0, ++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) ++ }); ++ } else { ++ scroller == null ? void 0 : scroller.scrollToEnd({ ++ animated: maintainScrollAtEnd.animated ++ }); ++ } + } + setTimeout( + () => { +@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { + return 0; + } +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const insetStartAdjustment = state.props.contentInsetStartAdjustment; ++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; ++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); + const clampDelta = maxScroll - state.scroll; + if (unresolvedAmount < 0) { + return Math.max(unresolvedAmount, Math.min(0, clampDelta)); +@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { + settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); + return true; + } +- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); + const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); + const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; + if (isAtExpectedNativeClamp) { +@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { + if (diff > 0) { + diff = Math.max(0, totalSize - state.scroll - state.scrollLength); + } else { +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + diff = 0; +@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { + } + + // src/core/initialScroll.ts ++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; ++var INSET_END_REVEAL_STABLE_FRAMES = 3; ++var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; ++function startInsetEndSettleWatchdog(ctx) { ++ const state = ctx.state; ++ if (state.insetEndSettleWatchdogActive) { ++ return; ++ } ++ 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 ++ // never hide content for long. ++ state.insetEndRevealHold = true; ++ let frames = 0; ++ let settledFrames = 0; ++ let revealStableFrames = 0; ++ const releaseRevealHold = () => { ++ if (state.insetEndRevealHold) { ++ state.insetEndRevealHold = false; ++ setInitialRenderState(ctx, {}); ++ } ++ }; ++ // Revealing flips adaptive rendering to "normal", which re-renders rows and ++ // can move the end target one more time — do that upgrade while still ++ // hidden (phase 1), then require stability again before revealing (phase 2) ++ // so the first visible frame is the settled one. ++ let revealPhase = 1; ++ const onRevealStability = () => { ++ var _a3; ++ if (!state.insetEndRevealHold) { ++ return; ++ } ++ if (revealPhase === 1) { ++ revealPhase = 2; ++ revealStableFrames = 0; ++ setAdaptiveRender(ctx, "normal"); ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ return; ++ } ++ releaseRevealHold(); ++ }; ++ const tick = () => { ++ // The user owns the scroll from their first drag — a re-pin here would ++ // pin the list under their finger. ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { ++ state.insetEndSettleWatchdogActive = false; ++ releaseRevealHold(); ++ return; ++ } ++ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { ++ releaseRevealHold(); ++ } ++ 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 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); ++ }; ++ requestAnimationFrame(tick); ++} + function dispatchInitialScroll(ctx, params) { + const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { ++ if (ctx.state.scrollLength <= 0) { ++ // The scroll length is unknown, so the resolved target is meaningless; ++ // the initial-scroll machinery re-advances after layout. ++ return; ++ } ++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { ++ // Content underflows the viewport and nothing has scrolled yet: the ++ // untouched native rest position (UIKit's adjustedContentInset) IS the ++ // end position. Dispatching a negative scroll here would race UIKit's ++ // inset application during screen attach, so just finish the session. ++ finishInitialScroll(ctx, { ++ resolvedOffset ++ }); ++ return; ++ } ++ ctx.state.didDispatchInsetInitialScroll = true; ++ if (target.viewPosition === 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const requestedIndex = target.index; + const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; + const itemSize = getItemSizeAtIndex(ctx, index); +@@ -2804,7 +2945,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); + if (!shouldKeepEndTargetAlive) { + if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { + clearPendingInitialScrollFooterLayout(ctx, { +@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + } + contentBelowAnchor += footerSize + stylePaddingBottom; + isReady = !hasUnknownTailSize; +- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; + } else if (anchorIndex >= 0) { + isReady = false; + } +@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true); + } + (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); ++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { ++ set$(ctx, "anchoredEndSpaceSize", nextSize); ++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); ++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { ++ updateScroll(ctx, state.scroll, true); ++ } + } + return nextSize; + } +@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, ++ contentInsetStartAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, +@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout: onLayoutProp, + onLoad, + onMomentumScrollEnd, ++ onScrollBeginDrag, + onRefresh, + onScroll: onScrollProp, + onStartReached, +@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + contentContainerAlignItems: contentContainerStyle.alignItems, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, ++ contentInsetStartAdjustment, + data: dataProp, + dataVersion, + drawDistance, +@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + return void 0; + } + const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); ++ if (getContentInsetStartAdjustment(ctx) > 0) { ++ // With a native leading inset (transparent header + automatic insets), ++ // any mount-time contentOffset races UIKit's adjustedContentInset ++ // application. The gated initial-scroll dispatch positions the list once ++ // real sizes are known instead. ++ return void 0; ++ } + return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; + }, [usesBootstrapInitialScroll]); + React2.useLayoutEffect(() => { +@@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onMomentumScrollEnd(event); + } + }, ++ onScrollBeginDrag: (event) => { ++ ctx.state.didUserDrag = true; ++ if (onScrollBeginDrag) { ++ onScrollBeginDrag(event); ++ } ++ }, + onScroll: (event) => onScroll(ctx, event) + }), + [] +@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout, + onLayoutFooter, + onMomentumScrollEnd: fns.onMomentumScrollEnd, ++ onScrollBeginDrag: fns.onScrollBeginDrag, + onScroll: onScrollHandler, + recycleItems, + refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { +diff --git a/react-native.mjs b/react-native.mjs +index 2e96ca7..6e8913e 100644 +--- a/react-native.mjs ++++ b/react-native.mjs +@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal"); +@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { + } + + // src/core/clampScrollOffset.ts ++function getContentInsetStartAdjustment(ctx) { ++ const adjustment = ctx.state.props.contentInsetStartAdjustment; ++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + const state = ctx.state; + const contentSize = getContentSize(ctx); ++ const minOffset = -getContentInsetStartAdjustment(ctx); + let clampedOffset = offset; + if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { +- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); ++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); + const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; + const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; + const maxOffset = baseMaxOffset + extraEndOffset; + clampedOffset = Math.min(offset, maxOffset); + } +- clampedOffset = Math.max(0, clampedOffset); ++ clampedOffset = Math.max(minOffset, clampedOffset); + return clampedOffset; + } + +@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { + finishScrollTo(ctx); + } + } +-function scrollToFallbackOffset(ctx, offset) { ++function scrollToFallbackOffset(ctx, offset, animated) { + var _a3; + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, ++ animated: !!animated, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset + }); +@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { + }); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { + } + state.pendingMaintainScrollAtEnd = false; + if (shouldMaintainScrollAtEnd) { ++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; ++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; ++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { ++ return false; ++ } + const contentSize = getContentSize(ctx); ++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); + if (contentSize < state.scrollLength) { +- state.scroll = 0; ++ state.scroll = -maintainInsetStartAdjustment; ++ if (maintainInsetStartAdjustment > 0) { ++ return true; ++ } + } + if (!state.maintainingScrollAtEnd) { + const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; +@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { + y: 0 + }); + } else { +- scroller == null ? void 0 : scroller.scrollToEnd({ +- animated: maintainScrollAtEnd.animated +- }); ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0) { ++ scroller == null ? void 0 : scroller.scrollTo({ ++ animated: maintainScrollAtEnd.animated, ++ x: 0, ++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) ++ }); ++ } else { ++ scroller == null ? void 0 : scroller.scrollToEnd({ ++ animated: maintainScrollAtEnd.animated ++ }); ++ } + } + setTimeout( + () => { +@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { + return 0; + } +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const insetStartAdjustment = state.props.contentInsetStartAdjustment; ++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; ++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); + const clampDelta = maxScroll - state.scroll; + if (unresolvedAmount < 0) { + return Math.max(unresolvedAmount, Math.min(0, clampDelta)); +@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { + settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); + return true; + } +- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); + const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); + const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; + if (isAtExpectedNativeClamp) { +@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { + if (diff > 0) { + diff = Math.max(0, totalSize - state.scroll - state.scrollLength); + } else { +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + diff = 0; +@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { + } + + // src/core/initialScroll.ts ++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; ++var INSET_END_REVEAL_STABLE_FRAMES = 3; ++var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; ++function startInsetEndSettleWatchdog(ctx) { ++ const state = ctx.state; ++ if (state.insetEndSettleWatchdogActive) { ++ return; ++ } ++ 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 ++ // never hide content for long. ++ state.insetEndRevealHold = true; ++ let frames = 0; ++ let settledFrames = 0; ++ let revealStableFrames = 0; ++ const releaseRevealHold = () => { ++ if (state.insetEndRevealHold) { ++ state.insetEndRevealHold = false; ++ setInitialRenderState(ctx, {}); ++ } ++ }; ++ // Revealing flips adaptive rendering to "normal", which re-renders rows and ++ // can move the end target one more time — do that upgrade while still ++ // hidden (phase 1), then require stability again before revealing (phase 2) ++ // so the first visible frame is the settled one. ++ let revealPhase = 1; ++ const onRevealStability = () => { ++ var _a3; ++ if (!state.insetEndRevealHold) { ++ return; ++ } ++ if (revealPhase === 1) { ++ revealPhase = 2; ++ revealStableFrames = 0; ++ setAdaptiveRender(ctx, "normal"); ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ return; ++ } ++ releaseRevealHold(); ++ }; ++ const tick = () => { ++ // The user owns the scroll from their first drag — a re-pin here would ++ // pin the list under their finger. ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { ++ state.insetEndSettleWatchdogActive = false; ++ releaseRevealHold(); ++ return; ++ } ++ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { ++ releaseRevealHold(); ++ } ++ 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 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); ++ }; ++ requestAnimationFrame(tick); ++} + function dispatchInitialScroll(ctx, params) { + const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { ++ if (ctx.state.scrollLength <= 0) { ++ // The scroll length is unknown, so the resolved target is meaningless; ++ // the initial-scroll machinery re-advances after layout. ++ return; ++ } ++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { ++ // Content underflows the viewport and nothing has scrolled yet: the ++ // untouched native rest position (UIKit's adjustedContentInset) IS the ++ // end position. Dispatching a negative scroll here would race UIKit's ++ // inset application during screen attach, so just finish the session. ++ finishInitialScroll(ctx, { ++ resolvedOffset ++ }); ++ return; ++ } ++ ctx.state.didDispatchInsetInitialScroll = true; ++ if (target.viewPosition === 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const requestedIndex = target.index; + const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; + const itemSize = getItemSizeAtIndex(ctx, index); +@@ -2783,7 +2924,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); + if (!shouldKeepEndTargetAlive) { + if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { + clearPendingInitialScrollFooterLayout(ctx, { +@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + } + contentBelowAnchor += footerSize + stylePaddingBottom; + isReady = !hasUnknownTailSize; +- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; + } else if (anchorIndex >= 0) { + isReady = false; + } +@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true); + } + (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); ++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { ++ set$(ctx, "anchoredEndSpaceSize", nextSize); ++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); ++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { ++ updateScroll(ctx, state.scroll, true); ++ } + } + return nextSize; + } +@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, ++ contentInsetStartAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, +@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout: onLayoutProp, + onLoad, + onMomentumScrollEnd, ++ onScrollBeginDrag, + onRefresh, + onScroll: onScrollProp, + onStartReached, +@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + contentContainerAlignItems: contentContainerStyle.alignItems, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, ++ contentInsetStartAdjustment, + data: dataProp, + dataVersion, + drawDistance, +@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + return void 0; + } + const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); ++ if (getContentInsetStartAdjustment(ctx) > 0) { ++ // With a native leading inset (transparent header + automatic insets), ++ // any mount-time contentOffset races UIKit's adjustedContentInset ++ // application. The gated initial-scroll dispatch positions the list once ++ // real sizes are known instead. ++ return void 0; ++ } + return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; + }, [usesBootstrapInitialScroll]); + useLayoutEffect(() => { +@@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onMomentumScrollEnd(event); + } + }, ++ onScrollBeginDrag: (event) => { ++ ctx.state.didUserDrag = true; ++ if (onScrollBeginDrag) { ++ onScrollBeginDrag(event); ++ } ++ }, + onScroll: (event) => onScroll(ctx, event) + }), + [] +@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout, + onLayoutFooter, + onMomentumScrollEnd: fns.onMomentumScrollEnd, ++ onScrollBeginDrag: fns.onScrollBeginDrag, + onScroll: onScrollHandler, + recycleItems, + refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { +diff --git a/reanimated.d.ts b/reanimated.d.ts +index 7e2d11f..d5b0d66 100644 +--- a/reanimated.d.ts ++++ b/reanimated.d.ts +@@ -285,6 +285,12 @@ interface LegendListSpecificProps { + * The adjustment is also rendered as real content padding so the browser scroll range includes it. + */ + contentInsetEndAdjustment?: number; ++ /** ++ * Width/height of a leading content inset applied natively outside the list's knowledge ++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). ++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. ++ */ ++ contentInsetStartAdjustment?: number; + /** + * Number of columns to render items in. + * @default 1 diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch new file mode 100644 index 000000000000..3dee935cda33 --- /dev/null +++ b/patches/react-native-keyboard-controller@1.21.13.patch @@ -0,0 +1,483 @@ +diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js +index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/index.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/index.js +@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, + blankSpace = ZERO_BLANK_SPACE, ++ adjustedInsetCompensation = 0, + applyWorkaroundForContentInsetHitTestBug = false, + onLayout: onLayoutProp, + onContentSizeChange: onContentSizeChangeProp, ++ onContentInsetChange, + onEndVisible, + ...rest + }, ref) => { +@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + freeze: freezeSV, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + }); + (0, _useExtraContentPadding.useExtraContentPadding)({ + scrollViewRef, + extraContentPadding, + keyboardPadding: padding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ + // a bug for you, please open an issue. + const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + ++ // Mirror the effective bottom padding (keyboard + composer + blank floor) ++ // to the consumer - a virtualized list needs it in its own scroll math or ++ // its end/maintain targets point at the under-the-keyboard resting offset. ++ (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => { ++ if (onContentInsetChange && current !== previous) { ++ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ ++ bottom: current ++ }); ++ } ++ }, [onContentInsetChange]); ++ + // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). + // Apps that render into the unsafe area can supply a negative + // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); + const onLayout = (0, _react.useCallback)(e => { + onLayoutInternal(e); + onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) { + freeze, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + } = options; + const padding = (0, _reactNativeReanimated.useSharedValue)(0); + const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); +@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) { + const visiblePadding = visibleFraction * blankSpace.value; + const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); + const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); +- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); ++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation; + + // persistent mode: when keyboard shrinks, clamp to valid range + if (keyboardLiftBehavior === "persistent" && effective < padding.value) { +@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { + const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); + padding.value = effective; + } +- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); + return { + padding, + currentHeight, +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +@@ -29,6 +29,7 @@ function useExtraContentPadding(options) { + extraContentPadding, + keyboardPadding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -68,8 +69,8 @@ function useExtraContentPadding(options) { + } + + // Compute effective delta considering blankSpace floor +- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous); +- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current); ++ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation; ++ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation; + const effectiveDelta = currentTotal - previousTotal; + if (effectiveDelta === 0) { + // blankSpace absorbed the change +@@ -92,6 +93,6 @@ function useExtraContentPadding(options) { + const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); + } +- }, [inverted, keyboardLiftBehavior]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); + } + //# sourceMappingURL=index.js.map +\ No newline at end of file +diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js +index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644 +--- a/lib/module/components/KeyboardChatScrollView/index.js ++++ b/lib/module/components/KeyboardChatScrollView/index.js +@@ -1,7 +1,7 @@ + function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } + import React, { forwardRef, useCallback, useMemo } from "react"; + import { StyleSheet } from "react-native"; +-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; ++import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; + import Reanimated from "react-native-reanimated"; + import useCombinedRef from "../hooks/useCombinedRef"; + import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; +@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, + blankSpace = ZERO_BLANK_SPACE, ++ adjustedInsetCompensation = 0, + applyWorkaroundForContentInsetHitTestBug = false, + onLayout: onLayoutProp, + onContentSizeChange: onContentSizeChangeProp, ++ onContentInsetChange, + onEndVisible, + ...rest + }, ref) => { +@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + freeze: freezeSV, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + }); + useExtraContentPadding({ + scrollViewRef, + extraContentPadding, + keyboardPadding: padding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + // a bug for you, please open an issue. + const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + ++ // Mirror the effective bottom padding (keyboard + composer + blank floor) ++ // to the consumer - a virtualized list needs it in its own scroll math or ++ // its end/maintain targets point at the under-the-keyboard resting offset. ++ useAnimatedReaction(() => totalPadding.value, (current, previous) => { ++ if (onContentInsetChange && current !== previous) { ++ runOnJS(onContentInsetChange)({ ++ bottom: current ++ }); ++ } ++ }, [onContentInsetChange]); ++ + // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). + // Apps that render into the unsafe area can supply a negative + // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = useDerivedValue(() => padding.value); + const onLayout = useCallback(e => { + onLayoutInternal(e); + onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 +--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js ++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) { + freeze, + offset, + blankSpace, +- extraContentPadding ++ extraContentPadding, ++ adjustedInsetCompensation + } = options; + const padding = useSharedValue(0); + const currentHeight = useSharedValue(0); +@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) { + const visiblePadding = visibleFraction * blankSpace.value; + const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); + const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); +- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); ++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation; + + // persistent mode: when keyboard shrinks, clamp to valid range + if (keyboardLiftBehavior === "persistent" && effective < padding.value) { +@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) { + const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); + padding.value = effective; + } +- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); + return { + padding, + currentHeight, +diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644 +--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js ++++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +@@ -23,6 +23,7 @@ function useExtraContentPadding(options) { + extraContentPadding, + keyboardPadding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -62,8 +63,8 @@ function useExtraContentPadding(options) { + } + + // Compute effective delta considering blankSpace floor +- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous); +- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current); ++ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation; ++ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation; + const effectiveDelta = currentTotal - previousTotal; + if (effectiveDelta === 0) { + // blankSpace absorbed the change +@@ -86,7 +87,7 @@ function useExtraContentPadding(options) { + const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); + } +- }, [inverted, keyboardLiftBehavior]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); + } + export { useExtraContentPadding }; + //# sourceMappingURL=index.js.map +\ No newline at end of file +diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts +index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts +@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = { + * Default is `undefined` (equivalent to `0` — no minimum floor). + */ + blankSpace?: SharedValue; ++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ ++ adjustedInsetCompensation?: number; + /** + * Fires whenever the effective content inset changes — the static `contentInset` + * prop combined with the dynamic keyboard-driven padding. +diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = { + blankSpace: SharedValue; + /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ + extraContentPadding: SharedValue; ++ /** Safe-area extra beyond raw contentInset. Offset math only. */ ++ adjustedInsetCompensation: number; + }; + type UseChatKeyboardReturn = { + /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ +diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts +index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts +@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = { + keyboardPadding: SharedValue; + /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ + blankSpace: SharedValue; ++ /** Safe-area extra beyond raw contentInset. Offset math only. */ ++ adjustedInsetCompensation: number; + /** Current vertical scroll offset. */ + scroll: SharedValue; + /** Visible viewport dimensions. */ +diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx +index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644 +--- a/src/components/KeyboardChatScrollView/index.tsx ++++ b/src/components/KeyboardChatScrollView/index.tsx +@@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; + import { StyleSheet } from "react-native"; + import { + makeMutable, ++ runOnJS, ++ useAnimatedReaction, + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, +@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< + offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, + blankSpace = ZERO_BLANK_SPACE, ++ adjustedInsetCompensation = 0, + applyWorkaroundForContentInsetHitTestBug = false, + onLayout: onLayoutProp, + onContentSizeChange: onContentSizeChangeProp, ++ onContentInsetChange, + onEndVisible, + ...rest + }, +@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< + offset, + blankSpace, + extraContentPadding, ++ adjustedInsetCompensation, + }); + + useExtraContentPadding({ +@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< + extraContentPadding, + keyboardPadding: padding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef< + ), + ); + +- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). +- // Apps that render into the unsafe area can supply a negative +- // scrollIndicatorInsets adjustment at the application layer. +- const indicatorPadding = useDerivedValue( +- () => padding.value + extraContentPadding.value, ++ // Mirror the effective bottom padding (keyboard + composer + blank floor) ++ // to the consumer — a virtualized list needs it in its own scroll math or ++ // its end/maintain targets point at the under-the-keyboard resting offset. ++ useAnimatedReaction( ++ () => totalPadding.value, ++ (current, previous) => { ++ if (onContentInsetChange && current !== previous) { ++ runOnJS(onContentInsetChange)({ bottom: current }); ++ } ++ }, ++ [onContentInsetChange], + ); + ++ // Scroll indicator inset = keyboard only (excludes extraContentPadding and ++ // blankSpace): with a floating composer the indicator track should run the ++ // full height of the scroll view, behind the composer, like iOS Messages. ++ // The keyboard still lifts it so it never disappears under the keyboard. ++ const indicatorPadding = useDerivedValue(() => padding.value); ++ + const onLayout = useCallback( + (e: LayoutChangeEvent) => { + onLayoutInternal(e); +diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts +index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 +--- a/src/components/KeyboardChatScrollView/types.ts ++++ b/src/components/KeyboardChatScrollView/types.ts +@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { + * Default is `undefined` (equivalent to `0` — no minimum floor). + */ + blankSpace?: SharedValue; ++ /** ++ * Extra bottom inset UIKit adds on top of the raw `contentInset` this ++ * component writes (e.g. the home-indicator safe area under ++ * `contentInsetAdjustmentBehavior="automatic"`). Used ONLY in scroll-offset ++ * math (max scroll / end pinning) — never written into `contentInset`. ++ * ++ * Default is `0`. ++ */ ++ adjustedInsetCompensation?: number; + /** + * Fires whenever the effective content inset changes — the static `contentInset` + * prop combined with the dynamic keyboard-driven padding. +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +@@ -43,6 +43,7 @@ function useChatKeyboard( + offset, + blankSpace, + extraContentPadding, ++ adjustedInsetCompensation, + } = options; + + const padding = useSharedValue(0); +@@ -104,10 +105,12 @@ function useChatKeyboard( + effective, + minimumPaddingAbsorbed, + ); +- const actualTotalPadding = Math.max( +- blankSpace.value, +- effective + extraContentPadding.value, +- ); ++ // UIKit adds adjustedInsetCompensation (safe area) on top of the raw ++ // inset; include it here so end/max-scroll targets match the real ++ // resting offsets. ++ const actualTotalPadding = ++ Math.max(blankSpace.value, effective + extraContentPadding.value) + ++ adjustedInsetCompensation; + + // persistent mode: when keyboard shrinks, clamp to valid range + if ( +@@ -242,7 +245,7 @@ function useChatKeyboard( + padding.value = effective; + }, + }, +- [inverted, keyboardLiftBehavior, offset, extraContentPadding], ++ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation], + ); + + return { +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { + blankSpace: SharedValue; + /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ + extraContentPadding: SharedValue; ++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ ++ adjustedInsetCompensation: number; + }; + + type UseChatKeyboardReturn = { +diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 +--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts ++++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { + keyboardPadding: SharedValue; + /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ + blankSpace: SharedValue; ++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ ++ adjustedInsetCompensation: number; + /** Current vertical scroll offset. */ + scroll: SharedValue; + /** Visible viewport dimensions. */ +@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + extraContentPadding, + keyboardPadding, + blankSpace, ++ adjustedInsetCompensation, + scroll, + layout, + size, +@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + } + + // Compute effective delta considering blankSpace floor +- const previousTotal = Math.max( +- blankSpace.value, +- keyboardPadding.value + previous, +- ); +- const currentTotal = Math.max( +- blankSpace.value, +- keyboardPadding.value + current, +- ); ++ const previousTotal = ++ Math.max(blankSpace.value, keyboardPadding.value + previous) + ++ adjustedInsetCompensation; ++ const currentTotal = ++ Math.max(blankSpace.value, keyboardPadding.value + current) + ++ adjustedInsetCompensation; + const effectiveDelta = currentTotal - previousTotal; + + if (effectiveDelta === 0) { +@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + scrollToTarget(target); + } + }, +- [inverted, keyboardLiftBehavior], ++ [inverted, keyboardLiftBehavior, adjustedInsetCompensation], + ); + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70f448dcac68..bad84ad9e1c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 path: patches/@ff-labs__fff-node@0.9.4.patch + '@legendapp/list@3.2.0': + hash: 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 + path: patches/@legendapp__list@3.2.0.patch '@pierre/diffs@1.3.0-beta.5': hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch @@ -91,6 +94,9 @@ patchedDependencies: react-native-gesture-handler@2.31.2: hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 path: patches/react-native-gesture-handler@2.31.2.patch + react-native-keyboard-controller@1.21.13: + hash: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + path: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 path: patches/react-native-nitro-modules@0.35.9.patch @@ -223,7 +229,7 @@ importers: version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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 @@ -376,7 +382,7 @@ importers: version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-keyboard-controller: specifier: 1.21.13 - version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -543,7 +549,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(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) @@ -12706,7 +12712,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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) @@ -12714,7 +12720,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(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) @@ -19038,7 +19044,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@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 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 423e48b99283..c0316ec6b8a6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,20 +5,6 @@ packages: - packages/* - scripts -# Install both Windows and Linux native binaries so the WSL (Linux) backend -# can load platform-gated optional deps (e.g. @yuuang/ffi-rs-linux-x64-gnu) -# out of the same node_modules the Windows desktop uses. -supportedArchitectures: - os: - - current - - linux - cpu: - - current - - x64 - libc: - - current - - glibc - catalog: "@clerk/backend": 3.8.4 "@clerk/clerk-js": 6.22.0 @@ -84,8 +70,6 @@ overrides: "@pierre/diffs>@shikijs/transformers": ^4.2.0 "@types/node": "catalog:" effect: "catalog:" - # Pin to the version our patch targets; expo-modules-core's ~56.0.10 range - # otherwise floats to newer releases on fresh resolves (ERR_PNPM_UNUSED_PATCH). expo-modules-jsi: 56.0.10 vite: "catalog:" yaml: "catalog:" @@ -105,11 +89,13 @@ patchedDependencies: "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch + "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch + react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch @@ -118,3 +104,8 @@ peerDependencyRules: - vite allowedVersions: vite: "*" + +supportedArchitectures: + cpu: [current, x64] + libc: [current, glibc] + os: [current, linux]