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]