From bfd9ca37d2004f6649d7c9f13b2217ecbc40ad54 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sat, 11 Jul 2026 12:04:49 -0400 Subject: [PATCH 01/16] fix(mobile): Backport iOS runtime and chat stability fixes Co-authored-by: codex --- apps/mobile/index.ts | 4 + .../ios/T3MarkdownTextShadowNode.h | 25 +- .../ios/T3MarkdownTextShadowNode.mm | 49 +- .../src/SelectableMarkdownText.ios.tsx | 11 +- .../src/SelectableMarkdownText.types.ts | 1 + .../features/threads/ThreadDetailScreen.tsx | 25 +- .../src/features/threads/ThreadFeed.tsx | 84 +++- .../features/threads/ThreadRouteScreen.tsx | 106 ++++- apps/mobile/src/lib/composerImages.test.ts | 32 +- apps/mobile/src/lib/composerImages.ts | 13 + apps/mobile/src/lib/crashLog.ts | 80 ++++ apps/mobile/src/lib/installCrashLog.ts | 3 + apps/mobile/src/lib/projectThreadStartTurn.ts | 4 +- .../src/native/SelectableMarkdownText.ios.tsx | 24 +- .../src/native/SelectableMarkdownText.tsx | 4 +- apps/mobile/src/native/StackHeader.tsx | 8 + apps/mobile/src/state/query.ts | 8 +- apps/mobile/src/state/threadQueryState.ts | 26 + apps/mobile/src/state/threads.test.ts | 40 ++ apps/mobile/src/state/threads.ts | 38 +- apps/mobile/src/state/use-thread-detail.ts | 12 +- .../src/state/use-thread-outbox-drain.ts | 3 +- .../src/errors/causeMessage.test.ts | 45 ++ .../client-runtime/src/errors/causeMessage.ts | 19 + packages/client-runtime/src/errors/index.ts | 1 + .../src/state/shell-sync.test.ts | 80 ++++ packages/client-runtime/src/state/shell.ts | 14 +- .../src/state/threads-sync.test.ts | 35 ++ packages/client-runtime/src/state/threads.ts | 10 +- patches/@legendapp__list@3.2.0.patch | 172 ++++--- ...act-navigation%2Fnative-stack@7.17.6.patch | 2 + ...t-native-keyboard-controller@1.21.13.patch | 447 +++++++++++++++--- pnpm-lock.yaml | 22 +- 33 files changed, 1240 insertions(+), 207 deletions(-) create mode 100644 apps/mobile/src/lib/crashLog.ts create mode 100644 apps/mobile/src/lib/installCrashLog.ts create mode 100644 apps/mobile/src/state/threadQueryState.ts create mode 100644 apps/mobile/src/state/threads.test.ts create mode 100644 packages/client-runtime/src/errors/causeMessage.test.ts create mode 100644 packages/client-runtime/src/errors/causeMessage.ts diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..8add3abd386 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,3 +1,7 @@ +// Installed via a side-effect import listed first so the fatal-error handler +// is in place before the rest of the app module graph evaluates. +import "./src/lib/installCrashLog"; + import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { featureFlags } from "react-native-screens"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63..737330db1d4 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + + bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default; }; struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + + bool operator==(const T3MarkdownTextAttachmentRange&) const = default; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> { const LayoutConstraints& layoutConstraints) const override; private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; + // Content must be derived from the current children whenever it is needed. + // Yoga can invoke layout() on a fresh clone without ever calling + // measureContent() on it (for example when both dimensions are already + // exact), so caching measure-time content in mutable members and publishing + // it from layout() lets state fall behind the children and drop text. + struct Content { + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + }; + + Content buildContent(const LayoutContext& layoutContext) const; + void updateStateIfNeeded(Content&& content); }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb9..7a02094a4d3 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -72,15 +72,8 @@ static void applyAttachments( } } -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { +T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent( + const LayoutContext& layoutContext) const { const auto &baseProps = getConcreteProps(); auto baseTextAttributes = TextAttributes::defaultTextAttributes(); @@ -207,14 +200,23 @@ static void applyAttachments( } } - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; + return Content{ + std::move(baseAttributedString), + std::move(paragraphStyleRanges), + std::move(attachmentRanges), + }; +} + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + const auto content = buildContent(layoutContext); NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); + [RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges); + applyAttachments(convertedAttributedString, content.attachmentRanges); const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width @@ -255,10 +257,21 @@ static void applyAttachments( void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { ensureUnsealed(); + updateStateIfNeeded(buildContent(layoutContext)); +} + +void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) { + const auto &stateData = getStateData(); + if (stateData.attributedString == content.attributedString && + stateData.paragraphStyleRanges == content.paragraphStyleRanges && + stateData.attachmentRanges == content.attachmentRanges) { + return; + } + setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, + std::move(content.attributedString), + std::move(content.paragraphStyleRanges), + std::move(content.attachmentRanges), }); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ad..5c8d5bc97c2 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -34,6 +34,7 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + fillWidth = false, preserveSoftBreaks = false, onLinkPress, marginTop = 0, @@ -63,7 +64,15 @@ export function SelectableMarkdownText({ // shrink-to-fit containers such as user-message bubbles. Yoga then gives // the native text node an unbounded second pass and the parent only clips // the resulting single-line width instead of reflowing it. - + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb6..c1287a3565f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly fillWidth?: boolean; readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad5660983..10cadcc7b32 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,7 +17,7 @@ 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 { Platform, View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent, type LayoutChangeEvent } 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"; @@ -176,6 +176,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray { + onComposerLayout(event); + const height = event.nativeEvent.layout.height; + setComposerOverlayHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, + [onComposerLayout], ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; @@ -408,6 +424,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread freeze={freeze} anchorMessageId={anchorMessageId} contentInsetEndAdjustment={contentInsetEndAdjustment} + contentInsetEndEstimate={composerOverlayHeight + THREAD_FEED_COMPOSER_GAP} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} @@ -430,7 +447,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* 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. */} - + ; readonly anchorMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; + /** Effective resting end inset: measured composer overlay plus feed gap. */ + readonly contentInsetEndEstimate: number; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly contentMaxWidth?: number; @@ -937,6 +940,7 @@ function renderFeedEntry( hasNativeSelectableMarkdownText() ? ( + {props.loading ? : null} {props.title} {props.detail} @@ -1265,6 +1271,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); + const underflowCorrectionFrameRef = useRef(null); + const previousContentUnderflowsViewportRef = useRef(false); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); @@ -1273,6 +1281,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.layoutVariant === "split" ? 0 : windowWidth, ); const [viewportHeight, setViewportHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1395,6 +1404,47 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + const handleContentSizeChange = useCallback((_width: number, height: number) => { + setContentHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, []); + const contentUnderflowsViewport = + contentHeight > 0 && + viewportHeight > 0 && + contentHeight + props.contentInsetEndEstimate < viewportHeight - CONTENT_FIT_EPSILON; + + useEffect(() => { + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + underflowCorrectionFrameRef.current = null; + } + + const previouslyUnderflowed = previousContentUnderflowsViewportRef.current; + previousContentUnderflowsViewportRef.current = contentUnderflowsViewport; + if (contentUnderflowsViewport) { + underflowCorrectionFrameRef.current = requestAnimationFrame(() => { + underflowCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToOffset({ + animated: false, + offset: -anchorTopInset, + }); + }); + return; + } + + // A disclosure transition owns its visible-content anchor, so consuming + // the underflow transition without an end scroll is intentional there. + if (previouslyUnderflowed && !disclosureToggleSettling) { + void props.listRef.current?.scrollToEnd({ animated: true }); + } + }, [ + anchorTopInset, + contentHeight, + contentUnderflowsViewport, + disclosureToggleSettling, + props.listRef, + viewportHeight, + ]); + useEffect(() => { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); @@ -1419,14 +1469,23 @@ 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 + // A hydrating thread reserves the filled identity so detail arrival does not + // replace the native list during the draft-to-thread transition. An already + // open empty thread still remounts when its first message arrives. That + // remount 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"}`; + const listMountState = + props.contentPresentation.kind === "loading" + ? "filled" + : props.feed.length === 0 + ? "empty" + : "filled"; + const listMountKey = `${props.threadId}:${listMountState}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1496,6 +1555,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + } }; }, []); @@ -1711,7 +1773,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || contentUnderflowsViewport ? false : { animated: true, @@ -1750,6 +1812,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { scrollToOverflowEnabled estimatedItemSize={180} initialScrollAtEnd + onContentSizeChange={handleContentSizeChange} onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ @@ -1772,6 +1835,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { /> ) : null} + {props.contentPresentation.kind === "loading" ? ( + + + + ) : null} setExpandedImage(null)} + presentationStyle="overFullScreen" swipeToCloseEnabled doubleTapToZoomEnabled /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e60f03bb8c3..d2c2df6411a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -31,7 +31,7 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailQuery } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -78,6 +78,11 @@ interface ThreadInspectorSelection { type NativeHeaderItems = ReadonlyArray>; +const THREAD_DETAIL_STALL_RETRY_DELAYS_MS = [8_000, 12_000] as const; +const THREAD_DETAIL_STALL_ERROR_DELAY_MS = 20_000; +const THREAD_DETAIL_STALL_ERROR = + "The conversation did not finish loading. Close and reopen the thread to retry."; + function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -144,7 +149,91 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { selectedThread === null ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetailQuery = useSelectedThreadDetailQuery(); + const selectedThreadDetailState = selectedThreadDetailQuery.state; + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + const detailRefreshAttemptsRef = useRef(new Map()); + const refreshSelectedThreadDetailRef = useRef(selectedThreadDetailQuery.refresh); + const [stalledDetailKey, setStalledDetailKey] = useState(null); + + useEffect(() => { + refreshSelectedThreadDetailRef.current = selectedThreadDetailQuery.refresh; + }, [selectedThreadDetailQuery.refresh]); + + useEffect( + () => () => { + if (routeThreadKey !== null) { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + } + }, + [routeThreadKey], + ); + + useEffect(() => { + if (routeThreadKey === null) { + return; + } + + if (selectedThreadKey !== routeThreadKey) { + return; + } + + if (selectedThreadDetail !== null || selectedThreadDetailState.status === "deleted") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (routeConnectionState !== "connected") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (stalledDetailKey === routeThreadKey) { + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + const scheduleRetry = () => { + const attempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + const delayMs = + THREAD_DETAIL_STALL_RETRY_DELAYS_MS[attempts] ?? THREAD_DETAIL_STALL_ERROR_DELAY_MS; + timer = setTimeout(() => { + if (cancelled) { + return; + } + const currentAttempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + if (currentAttempts !== attempts) { + return; + } + if (attempts >= THREAD_DETAIL_STALL_RETRY_DELAYS_MS.length) { + setStalledDetailKey(routeThreadKey); + return; + } + detailRefreshAttemptsRef.current.set(routeThreadKey, attempts + 1); + refreshSelectedThreadDetailRef.current(); + scheduleRetry(); + }, delayMs); + }; + + scheduleRetry(); + + return () => { + cancelled = true; + if (timer !== null) { + clearTimeout(timer); + } + }; + }, [ + routeThreadKey, + routeConnectionState, + selectedThreadKey, + selectedThreadDetail, + selectedThreadDetailState.status, + stalledDetailKey, + ]); if (environmentId === null || threadIdRaw === null) { return ; @@ -155,7 +244,13 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // loading placeholder while messages fetch, and the composer's connection // pill reports connecting/reconnecting/syncing status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { - return ; + return ( + + ); } const stillHydrating = @@ -172,7 +267,8 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { function ThreadRouteContent( props: ThreadRouteScreenProps & { - readonly selectedThreadDetailState: ReturnType; + readonly detailLoadError: string | null; + readonly selectedThreadDetailState: ReturnType["state"]; }, ) { const { @@ -735,7 +831,7 @@ function ThreadRouteContent( const contentPresentation = projectThreadContentPresentation({ hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), + detailError: Option.getOrNull(selectedThreadDetailState.error) ?? props.detailLoadError, detailDeleted: selectedThreadDetailState.status === "deleted", connectionState: routeConnectionState, }); diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..21f2edaf52f 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,7 +36,37 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + isOwnedPastedImageUri, + toUploadChatImageAttachments, +} from "./composerImages"; + +describe("toUploadChatImageAttachments", () => { + it("strips client draft id and previewUri for the startTurn wire shape", () => { + expect( + toUploadChatImageAttachments([ + { + id: "client-draft-id", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/preview.png", + }, + ]), + ).toEqual([ + { + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + }, + ]); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..8aa6d2a3fa8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,6 +10,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ +export function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): ReadonlyArray { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })); +} + const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; function estimateBase64ByteSize(base64: string): number { diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts new file mode 100644 index 00000000000..486b485b774 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,80 @@ +import { Directory, File, Paths } from "expo-file-system"; +import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const MAX_PERSISTED_CRASH_LOGS = 20; +const REPORTED_ON_LAUNCH_COUNT = 3; + +let crashSequence = 0; + +export function installCrashLogger(): void { + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils === undefined) { + return; + } + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + if (isFatal === true) { + try { + persistCrashRecord(error); + } catch { + // Never let the logger mask the original error. + } + } + previousHandler(error, isFatal); + }); + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function persistCrashRecord(error: unknown): void { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const cause = error instanceof Error ? error : null; + const record = { + capturedAt: new Date().toISOString(), + isFatal: true, + message: cause?.message ?? String(error), + name: cause?.name ?? null, + stack: cause?.stack ?? null, + }; + crashSequence += 1; + const file = new File(directory, `crash-${Date.now()}-${crashSequence}.json`); + file.create({ intermediates: true, overwrite: true }); + file.write(JSON.stringify(record, null, 2)); +} + +function reportAndPrunePreviousCrashes(): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + if (!directory.exists) { + return; + } + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File && entry.name.startsWith("crash-") && entry.name.endsWith(".json"), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { + try { + console.warn(`[crash-log] previous fatal JS error (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + for (const file of files.slice(0, Math.max(0, files.length - MAX_PERSISTED_CRASH_LOGS))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } + } catch { + // Reporting past crashes must never affect startup. + } +} diff --git a/apps/mobile/src/lib/installCrashLog.ts b/apps/mobile/src/lib/installCrashLog.ts new file mode 100644 index 00000000000..ac3d1b76558 --- /dev/null +++ b/apps/mobile/src/lib/installCrashLog.ts @@ -0,0 +1,3 @@ +import { installCrashLogger } from "./crashLog"; + +installCrashLogger(); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index e3c2f744ada..85523175a2f 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -8,7 +8,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -55,7 +55,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: spec.attachments, + attachments: toUploadChatImageAttachments(spec.attachments), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f3695..a614247a4ed 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -2,10 +2,13 @@ import { SelectableMarkdownText as T3SelectableMarkdownText, type SelectableMarkdownTextProps, } from "@t3tools/mobile-markdown-text/renderer"; +import { View } from "react-native"; import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, @@ -16,6 +19,21 @@ export function hasNativeSelectableMarkdownText(): boolean { return true; } -export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; +export function SelectableMarkdownText({ + fillWidth = false, + ...props +}: MobileSelectableMarkdownTextProps) { + const content = ( + + ); + + if (!fillWidth) { + return content; + } + + return {content}; } diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de4..e536048a241 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,6 +1,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..552f0e23cdc 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } from "react"; @@ -67,12 +68,19 @@ export function NativeStackScreenOptions(props: { readonly name?: string; }) { const navigation = useNativeStackNavigation(); + const lastAppliedOptionsRef = useRef(undefined); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); useLayoutEffect(() => { if (!navigation || !normalizedOptions) { return; } + // Avoid re-entering navigation state when the same options object is + // reapplied every layout (common when callers pass unstable object literals). + if (lastAppliedOptionsRef.current === normalizedOptions) { + return; + } + lastAppliedOptionsRef.current = normalizedOptions; navigation.setOptions(normalizedOptions); }, [navigation, normalizedOptions]); diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..e2adffbdfb7 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,5 +1,6 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import * as Cause from "effect/Cause"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -15,10 +16,7 @@ export interface EnvironmentQueryView { } function formatError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The environment request failed."; + return causeFailureMessage(cause, "The environment request failed."); } export function useEnvironmentQuery( diff --git a/apps/mobile/src/state/threadQueryState.ts b/apps/mobile/src/state/threadQueryState.ts new file mode 100644 index 00000000000..a919fe0db88 --- /dev/null +++ b/apps/mobile/src/state/threadQueryState.ts @@ -0,0 +1,26 @@ +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, +} from "@t3tools/client-runtime/state/threads"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; + +function formatThreadStateFailure(cause: Cause.Cause): string { + return causeFailureMessage(cause, "Could not load conversation."); +} + +export function environmentThreadStateFromAsyncResult( + result: AsyncResult.AsyncResult, +): EnvironmentThreadState { + const value = Option.getOrNull(AsyncResult.value(result)); + if (AsyncResult.isFailure(result)) { + return { + ...(value ?? EMPTY_ENVIRONMENT_THREAD_STATE), + error: Option.some(formatThreadStateFailure(result.cause)), + }; + } + + return value ?? EMPTY_ENVIRONMENT_THREAD_STATE; +} diff --git a/apps/mobile/src/state/threads.test.ts b/apps/mobile/src/state/threads.test.ts new file mode 100644 index 00000000000..7285a9fa2b8 --- /dev/null +++ b/apps/mobile/src/state/threads.test.ts @@ -0,0 +1,40 @@ +import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; + +describe("environmentThreadStateFromAsyncResult", () => { + it("returns the empty state while the first value is loading", () => { + expect(environmentThreadStateFromAsyncResult(AsyncResult.initial(true))).toEqual( + EMPTY_ENVIRONMENT_THREAD_STATE, + ); + }); + + it("surfaces a failure when no previous value exists", () => { + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail("thread sync failed")), + ); + + expect(Option.getOrNull(state.data)).toBeNull(); + expect(Option.getOrNull(state.error)).toBe("thread sync failed"); + }); + + it("preserves previous thread data while surfacing a refresh failure", () => { + const previousState = { + ...EMPTY_ENVIRONMENT_THREAD_STATE, + status: "live" as const, + }; + const previousSuccess = AsyncResult.success(previousState); + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail(new Error("refresh failed")), { + previousSuccess: Option.some(previousSuccess), + }), + ); + + expect(state.status).toBe("live"); + expect(Option.getOrNull(state.error)).toBe("refresh failed"); + }); +}); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 7f247123051..cf6c1c2fe7a 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, @@ -8,12 +8,12 @@ import { createThreadEnvironmentAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); @@ -29,17 +29,33 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ Atom.withLabel("mobile-environment-thread:empty"), ); -export function useEnvironmentThread( +export interface EnvironmentThreadQuery { + readonly state: EnvironmentThreadState; + readonly isPending: boolean; + readonly refresh: () => void; +} + +export function useEnvironmentThreadQuery( environmentId: EnvironmentId | null, threadId: ThreadId | null, -): EnvironmentThreadState { - const result = useAtomValue( +): EnvironmentThreadQuery { + const atom = environmentId !== null && threadId !== null ? environmentThreads.stateAtom(environmentId, threadId) - : EMPTY_THREAD_STATE_ATOM, - ); - return Option.getOrElse( - AsyncResult.value(result), - () => EMPTY_ENVIRONMENT_THREAD_STATE, - ) as EnvironmentThreadState; + : EMPTY_THREAD_STATE_ATOM; + const result = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + + return { + state: environmentThreadStateFromAsyncResult(result), + isPending: environmentId !== null && threadId !== null && result.waiting, + refresh, + }; +} + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + return useEnvironmentThreadQuery(environmentId, threadId).state; } diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 388b4d9afcb..19546f780ba 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -1,7 +1,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { useEnvironmentThread } from "./threads"; +import { useEnvironmentThread, useEnvironmentThreadQuery } from "./threads"; import { useThreadSelection } from "./use-thread-selection"; export interface ThreadDetailTarget { @@ -13,9 +13,17 @@ export function useThreadDetail(target: ThreadDetailTarget) { return useEnvironmentThread(target.environmentId, target.threadId); } +export function useThreadDetailQuery(target: ThreadDetailTarget) { + return useEnvironmentThreadQuery(target.environmentId, target.threadId); +} + export function useSelectedThreadDetailState() { + return useSelectedThreadDetailQuery().state; +} + +export function useSelectedThreadDetailQuery() { const { selectedThread } = useThreadSelection(); - return useThreadDetail({ + return useThreadDetailQuery({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f4de9c39492..bdeebd101de 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,6 +15,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { randomHex } from "../lib/uuid"; @@ -221,7 +222,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, diff --git a/packages/client-runtime/src/errors/causeMessage.test.ts b/packages/client-runtime/src/errors/causeMessage.test.ts new file mode 100644 index 00000000000..2900820816e --- /dev/null +++ b/packages/client-runtime/src/errors/causeMessage.test.ts @@ -0,0 +1,45 @@ +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; + +import { causeFailureMessage } from "./causeMessage.ts"; + +describe("causeFailureMessage", () => { + it("returns the message of an Error failure", () => { + expect(causeFailureMessage(Cause.fail(new Error("boom")), "fallback")).toBe("boom"); + }); + + it("returns a string failure verbatim", () => { + expect(causeFailureMessage(Cause.fail("nope"), "fallback")).toBe("nope"); + }); + + it("returns the message of a tagged error object", () => { + expect( + causeFailureMessage( + Cause.fail({ _tag: "RelayInternalError", message: "relay down" }), + "fallback", + ), + ).toBe("relay down"); + }); + + it("falls back for empty Error messages", () => { + expect(causeFailureMessage(Cause.fail(new Error(" ")), "fallback")).toBe("fallback"); + }); + + it("falls back for empty string failures", () => { + expect(causeFailureMessage(Cause.fail(""), "fallback")).toBe("fallback"); + }); + + it("falls back for objects without a string message", () => { + expect(causeFailureMessage(Cause.fail({ code: 500 }), "fallback")).toBe("fallback"); + expect(causeFailureMessage(Cause.fail({ message: 42 }), "fallback")).toBe("fallback"); + }); + + it("falls back for other primitive failures", () => { + expect(causeFailureMessage(Cause.fail(null), "fallback")).toBe("fallback"); + expect(causeFailureMessage(Cause.fail(42), "fallback")).toBe("fallback"); + }); + + it("returns the defect message for die causes", () => { + expect(causeFailureMessage(Cause.die(new Error("defect")), "fallback")).toBe("defect"); + }); +}); diff --git a/packages/client-runtime/src/errors/causeMessage.ts b/packages/client-runtime/src/errors/causeMessage.ts new file mode 100644 index 00000000000..41f8ae42fe5 --- /dev/null +++ b/packages/client-runtime/src/errors/causeMessage.ts @@ -0,0 +1,19 @@ +import * as Cause from "effect/Cause"; + +export function causeFailureMessage(cause: Cause.Cause, fallback: string): string { + const message = failureMessage(Cause.squash(cause)); + return message !== null && message.trim().length > 0 ? message : fallback; +} + +export function failureMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error !== null && "message" in error) { + return typeof error.message === "string" ? error.message : null; + } + return null; +} diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts index 7eb6244e5a7..5931161e10c 100644 --- a/packages/client-runtime/src/errors/index.ts +++ b/packages/client-runtime/src/errors/index.ts @@ -1,3 +1,4 @@ +export * from "./causeMessage.ts"; export * from "./errorTrace.ts"; export * from "./safeLog.ts"; export * from "./transport.ts"; diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b6eb4e9f710..a058f852184 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -137,6 +137,86 @@ describe("environment shell synchronization", () => { }), ); + it.effect("restores live status after reconnect when no new shell events arrive", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => 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(LIVE_SHELL_SNAPSHOT)), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + 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.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "synchronizing"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + const state = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); + + expect(Option.getOrThrow(state).status).toBe("live"); + expect(Option.getOrThrow(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 = { diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index faa70bc4f3a..9036ff5289e 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -99,15 +99,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: "synchronizing" as const, error: Option.none(), })); - const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" - ? current - : { - ...current, - status: "synchronizing" as const, - error: Option.none(), - }, - ); + const setReady = SubscriptionRef.update(state, (current) => ({ + ...current, + status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const), + error: Option.none(), + })); const setStreamError = (error: unknown) => Effect.logWarning("Could not synchronize the environment shell.").pipe( Effect.annotateLogs({ diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8e982723d22..1c76f0f171f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -483,4 +483,39 @@ describe("EnvironmentThreads", () => { expect((yield* Ref.get(harness.latest)).status).toBe("live"); }), ); + + it.effect("restores live status after reconnect when no new thread events arrive", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "synchronizing"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + const latest = yield* Ref.get(harness.latest); + expect(latest.status).toBe("live"); + expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); + }), + ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fd5b425fa2a..2acbb58326e 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; +import { causeFailureMessage } from "../errors/causeMessage.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; @@ -35,10 +36,7 @@ function statusWithoutLiveData(data: Option.Option): Enviro } function formatThreadError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "Could not synchronize the thread."; + return causeFailureMessage(cause, "Could not synchronize the thread."); } export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( @@ -101,11 +99,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make error: Option.none(), })); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "deleted" ? current : { ...current, - status: "synchronizing" as const, + status: Option.isSome(current.data) ? ("live" as const) : ("synchronizing" as const), error: Option.none(), }, ); diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch index 686ea249b7a..92701a64af3 100644 --- a/patches/@legendapp__list@3.2.0.patch +++ b/patches/@legendapp__list@3.2.0.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 5a115ea..2c65d31 100644 +index 5a115ea2b..2c65d3158 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { @@ -23,7 +23,7 @@ index 5a115ea..2c65d31 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 +index 736286a3f..fbf1814f7 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -50,7 +50,7 @@ index 736286a..8218172 100644 ); React.useLayoutEffect(() => { var _a; -@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -84,14 +84,19 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -62,7 +62,15 @@ index 736286a..8218172 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...rest + } = props; ++ // Keyboard scroll math must respect the list's adjusted top inset or short ++ // content gets pinned at offset 0 under a translucent header. ++ const contentInsetStartCompensation = typeof rest.contentInsetStartAdjustment === "number" && Number.isFinite(rest.contentInsetStartAdjustment) ? Math.max(0, rest.contentInsetStartAdjustment) : 0; + const refLegendList = React.useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const blankSpace = reactNativeReanimated.useSharedValue(0); +@@ -109,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( includeInEndInset: true, onSizeChanged: (size) => { var _a; @@ -80,15 +88,18 @@ index 736286a..8218172 100644 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( +@@ -124,8 +133,10 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, ++ contentInsetStartCompensation, extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + freeze, + keyboardLiftBehavior, +@@ -135,9 +146,11 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -96,7 +107,11 @@ index 736286a..8218172 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ++ contentInsetStartCompensation, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -149,6 +162,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -105,7 +120,7 @@ index 736286a..8218172 100644 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 +index c1dd27020..1df18da00 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { @@ -132,7 +147,7 @@ index c1dd270..cb0d142 100644 ); useLayoutEffect(() => { var _a; -@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -63,14 +63,19 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -144,7 +159,15 @@ index c1dd270..cb0d142 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...rest + } = props; ++ // Keyboard scroll math must respect the list's adjusted top inset or short ++ // content gets pinned at offset 0 under a translucent header. ++ const contentInsetStartCompensation = typeof rest.contentInsetStartAdjustment === "number" && Number.isFinite(rest.contentInsetStartAdjustment) ? Math.max(0, rest.contentInsetStartAdjustment) : 0; + const refLegendList = useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const blankSpace = useSharedValue(0); +@@ -88,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( includeInEndInset: true, onSizeChanged: (size) => { var _a; @@ -162,15 +185,18 @@ index c1dd270..cb0d142 100644 const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -103,8 +112,10 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, ++ contentInsetStartCompensation, extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + freeze, + keyboardLiftBehavior, +@@ -114,9 +125,11 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -178,7 +204,11 @@ index c1dd270..cb0d142 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ++ contentInsetStartCompensation, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -128,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -187,7 +217,7 @@ index c1dd270..cb0d142 100644 renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index 72d3f59..435a5fc 100644 +index 72d3f599b..435a5fcde 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -284,6 +284,12 @@ interface LegendListSpecificProps { @@ -204,10 +234,26 @@ index 72d3f59..435a5fc 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 8d4ff89..18f0d62 100644 +index 8d4ff89d9..37b48722b 100644 --- a/react-native.js +++ b/react-native.js -@@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { +@@ -986,13 +986,14 @@ function checkAtBottom(ctx) { + if (contentSize > 0 && queuedInitialLayout) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const distanceFromScrollEnd = contentSize - scroll - scrollLength; + const isContentLess = contentSize < scrollLength; + set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, + "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ isContentLess || distanceFromScrollEnd <= maintainScrollAtEndThreshold * scrollLength + ); + const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; + if (!shouldSkipThresholdChecks) { +@@ -1195,7 +1196,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +262,7 @@ index 8d4ff89..18f0d62 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal"); -@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1480,18 +1481,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +288,7 @@ index 8d4ff89..18f0d62 100644 return clampedOffset; } -@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1626,10 +1632,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,7 +301,7 @@ index 8d4ff89..18f0d62 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1676,7 +1682,10 @@ function checkFinishedScrollFallback(ctx) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -267,7 +313,7 @@ index 8d4ff89..18f0d62 100644 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1737,9 +1746,18 @@ function doMaintainScrollAtEnd(ctx) { } state.pendingMaintainScrollAtEnd = false; if (shouldMaintainScrollAtEnd) { @@ -287,7 +333,7 @@ index 8d4ff89..18f0d62 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1759,9 +1777,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +355,7 @@ index 8d4ff89..18f0d62 100644 } setTimeout( () => { -@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1888,7 +1915,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -320,7 +366,7 @@ index 8d4ff89..18f0d62 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1950,7 +1979,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -329,7 +375,7 @@ index 8d4ff89..18f0d62 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -2083,7 +2112,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -338,7 +384,7 @@ index 8d4ff89..18f0d62 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { +@@ -2374,8 +2403,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -460,7 +506,7 @@ index 8d4ff89..18f0d62 100644 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) { +@@ -2804,7 +2946,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -471,7 +517,7 @@ index 8d4ff89..18f0d62 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4646,7 +4790,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -481,7 +527,7 @@ index 8d4ff89..18f0d62 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4664,6 +4809,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 }); @@ -494,7 +540,7 @@ index 8d4ff89..18f0d62 100644 } return nextSize; } -@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6462,6 +6613,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -502,7 +548,7 @@ index 8d4ff89..18f0d62 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6492,6 +6644,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -510,7 +556,7 @@ index 8d4ff89..18f0d62 100644 onRefresh, onScroll: onScrollProp, onStartReached, -@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6710,6 +6863,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -518,7 +564,7 @@ index 8d4ff89..18f0d62 100644 data: dataProp, dataVersion, drawDistance, -@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6789,6 +6943,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -532,7 +578,7 @@ index 8d4ff89..18f0d62 100644 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 +@@ -6995,6 +7156,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onMomentumScrollEnd(event); } }, @@ -545,7 +591,7 @@ index 8d4ff89..18f0d62 100644 onScroll: (event) => onScroll(ctx, event) }), [] -@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7019,6 +7186,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -554,10 +600,26 @@ index 8d4ff89..18f0d62 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 2e96ca7..6e8913e 100644 +index 2e96ca740..10ea1d456 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { +@@ -965,13 +965,14 @@ function checkAtBottom(ctx) { + if (contentSize > 0 && queuedInitialLayout) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const distanceFromScrollEnd = contentSize - scroll - scrollLength; + const isContentLess = contentSize < scrollLength; + set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, + "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ isContentLess || distanceFromScrollEnd <= maintainScrollAtEndThreshold * scrollLength + ); + const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; + if (!shouldSkipThresholdChecks) { +@@ -1174,7 +1175,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -566,7 +628,7 @@ index 2e96ca7..6e8913e 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal"); -@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1459,18 +1460,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -592,7 +654,7 @@ index 2e96ca7..6e8913e 100644 return clampedOffset; } -@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1605,10 +1611,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -605,7 +667,7 @@ index 2e96ca7..6e8913e 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1655,7 +1661,10 @@ function checkFinishedScrollFallback(ctx) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -617,7 +679,7 @@ index 2e96ca7..6e8913e 100644 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1716,9 +1725,18 @@ function doMaintainScrollAtEnd(ctx) { } state.pendingMaintainScrollAtEnd = false; if (shouldMaintainScrollAtEnd) { @@ -637,7 +699,7 @@ index 2e96ca7..6e8913e 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1738,9 +1756,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -659,7 +721,7 @@ index 2e96ca7..6e8913e 100644 } setTimeout( () => { -@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1867,7 +1894,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -670,7 +732,7 @@ index 2e96ca7..6e8913e 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1929,7 +1958,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -679,7 +741,7 @@ index 2e96ca7..6e8913e 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -2062,7 +2091,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -688,7 +750,7 @@ index 2e96ca7..6e8913e 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { +@@ -2353,8 +2382,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -810,7 +872,7 @@ index 2e96ca7..6e8913e 100644 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) { +@@ -2783,7 +2925,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -821,7 +883,7 @@ index 2e96ca7..6e8913e 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4625,7 +4769,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -831,7 +893,7 @@ index 2e96ca7..6e8913e 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4643,6 +4788,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 }); @@ -844,7 +906,7 @@ index 2e96ca7..6e8913e 100644 } return nextSize; } -@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6441,6 +6592,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -852,7 +914,7 @@ index 2e96ca7..6e8913e 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6471,6 +6623,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -860,7 +922,7 @@ index 2e96ca7..6e8913e 100644 onRefresh, onScroll: onScrollProp, onStartReached, -@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6689,6 +6842,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -868,7 +930,7 @@ index 2e96ca7..6e8913e 100644 data: dataProp, dataVersion, drawDistance, -@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6768,6 +6922,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -882,7 +944,7 @@ index 2e96ca7..6e8913e 100644 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 +@@ -6974,6 +7135,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onMomentumScrollEnd(event); } }, @@ -895,7 +957,7 @@ index 2e96ca7..6e8913e 100644 onScroll: (event) => onScroll(ctx, event) }), [] -@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6998,6 +7165,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -904,7 +966,7 @@ index 2e96ca7..6e8913e 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts -index 7e2d11f..d5b0d66 100644 +index 7e2d11ffd..d5b0d66d4 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -285,6 +285,12 @@ interface LegendListSpecificProps { diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index cd2b86fb76c..1ec4d978529 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -1,3 +1,5 @@ +diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js +index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644 --- a/lib/module/views/useHeaderConfigProps.js +++ b/lib/module/views/useHeaderConfigProps.js @@ -19,6 +19,12 @@ diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch index 3dee935cda3..a8a505282a4 100644 --- a/patches/react-native-keyboard-controller@1.21.13.patch +++ b/patches/react-native-keyboard-controller@1.21.13.patch @@ -1,12 +1,48 @@ +diff --git a/ios/views/ClippingScrollViewDecoratorViewManager.mm b/ios/views/ClippingScrollViewDecoratorViewManager.mm +index d960900fa..26101809e 100644 +--- a/ios/views/ClippingScrollViewDecoratorViewManager.mm ++++ b/ios/views/ClippingScrollViewDecoratorViewManager.mm +@@ -78,6 +78,30 @@ static void KCApplyNoopScrollRectToVisible(UIScrollView *scrollView) + method_getTypeEncoding(original)); + } + ++ // Applying a new raw contentInset makes UIKit re-clamp contentOffset against ++ // the raw (not adjusted) insets, which snaps chat lists resting at a negative ++ // offset under a translucent header back to 0. The JS side owns offset ++ // management here, so preserve the offset across inset applications. ++ Method originalSetInset = class_getInstanceMethod(originalClass, @selector(setContentInset:)); ++ if (originalSetInset) { ++ void (*callOriginalSetInset)(id, SEL, UIEdgeInsets) = ++ (void (*)(id, SEL, UIEdgeInsets))method_getImplementation(originalSetInset); ++ IMP preserveOffsetImp = imp_implementationWithBlock( ++ ^(__unsafe_unretained UIScrollView *self, UIEdgeInsets inset) { ++ CGPoint before = self.contentOffset; ++ callOriginalSetInset(self, @selector(setContentInset:), inset); ++ if (!CGPointEqualToPoint(before, self.contentOffset) && !self.isDragging && ++ !self.isDecelerating) { ++ self.contentOffset = before; ++ } ++ }); ++ class_addMethod( ++ subclass, ++ @selector(setContentInset:), ++ preserveOffsetImp, ++ method_getTypeEncoding(originalSetInset)); ++ } ++ + objc_registerClassPair(subclass); + } + diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 +index db8cfb1d2..d4c99886b 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)(({ +@@ -26,9 +26,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ contentInsetStartCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -14,13 +50,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 onEndVisible, ...rest }, ref) => { -@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -50,13 +53,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ freeze: freezeSV, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation }); (0, _useExtraContentPadding.useExtraContentPadding)({ scrollViewRef, @@ -28,10 +65,11 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation, scroll, layout, size, -@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -82,10 +89,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))); @@ -54,21 +92,52 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 const onLayout = (0, _react.useCallback)(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +index 4d33b3fe4..ea710c1ca 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight, + * ``` + */ + exports.clampedScrollTarget = clampedScrollTarget; +-const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => { ++const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInset = 0) => { + "worklet"; + + const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; +@@ -214,8 +214,13 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, + const maxScroll = Math.max(contentHeight - layoutHeight, 0); + return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); + } +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ // Non-inverted lists under an adjusted top inset (translucent header / ++ // automatic safe-area) rest at a NEGATIVE offset (-startInset). Flooring at ++ // 0 would force short content up under the header and strand it there, ++ // because the offset can never return below 0 through this math. ++ const minScroll = -startInset; ++ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); ++ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); + }; + exports.computeIOSContentOffset = computeIOSContentOffset; + //# sourceMappingURL=helpers.js.map +\ No newline at end of file diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 +index 2073da84b..0c9ad761e 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) { +@@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation = 0 } = options; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -66,7 +68,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); @@ -77,28 +146,62 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e // persistent mode: when keyboard shrinks, clamp to valid range if (keyboardLiftBehavior === "persistent" && effective < padding.value) { -@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -76,8 +78,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -91,8 +94,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -118,7 +122,7 @@ function useChatKeyboard(scrollViewRef, options) { + contentOffsetY.value = scroll.value; + return; + } +- contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, contentInsetStartCompensation); + }, + onMove: () => { + "worklet"; +@@ -134,7 +138,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]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, contentInsetStartCompensation]); 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 +index 0d50bdfeb..d20279bcf 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -29,6 +29,7 @@ function useExtraContentPadding(options) { +@@ -29,6 +29,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, scroll, layout, size, -@@ -68,8 +69,8 @@ function useExtraContentPadding(options) { +@@ -68,8 +70,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -109,17 +212,26 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da 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); +@@ -88,10 +90,13 @@ function useExtraContentPadding(options) { + const target = Math.max(scroll.value - effectiveDelta, -currentTotal); + scrollToTarget(target); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); ++ // Respect the adjusted top inset: short content rests at a negative ++ // offset (-startInset), and the scrollable range never extends past it. ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); ++ const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), minScroll); scrollToTarget(target); } - }, [inverted, keyboardLiftBehavior]); -+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, contentInsetStartCompensation]); } //# 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 +index 612dd8bd9..7daa4ede5 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js @@ -1,7 +1,7 @@ @@ -131,11 +243,12 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 import Reanimated from "react-native-reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -19,9 +19,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ contentInsetStartCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -143,13 +256,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 onEndVisible, ...rest }, ref) => { -@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -43,13 +46,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ freeze: freezeSV, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation }); useExtraContentPadding({ scrollViewRef, @@ -157,10 +271,11 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation, scroll, layout, size, -@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -75,10 +82,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))); @@ -183,21 +298,51 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 const onLayout = useCallback(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +index 295e2219b..aa5d7b165 100644 +--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js ++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH + * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 + * ``` + */ +-export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => { ++export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInset = 0) => { + "worklet"; + + const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; +@@ -201,7 +201,12 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH + const maxScroll = Math.max(contentHeight - layoutHeight, 0); + return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); + } +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ // Non-inverted lists under an adjusted top inset (translucent header / ++ // automatic safe-area) rest at a NEGATIVE offset (-startInset). Flooring at ++ // 0 would force short content up under the header and strand it there, ++ // because the offset can never return below 0 through this math. ++ const minScroll = -startInset; ++ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); ++ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); + }; + //# sourceMappingURL=helpers.js.map +\ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 +index 52943c3a7..139fa4dd5 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) { +@@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation = 0 } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -59,7 +61,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); @@ -206,28 +351,62 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669 // persistent mode: when keyboard shrinks, clamp to valid range if (keyboardLiftBehavior === "persistent" && effective < padding.value) { -@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -69,8 +71,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -84,8 +87,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -111,7 +115,7 @@ function useChatKeyboard(scrollViewRef, options) { + contentOffsetY.value = scroll.value; + return; + } +- contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, contentInsetStartCompensation); + }, + onMove: () => { + "worklet"; +@@ -127,7 +131,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]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, contentInsetStartCompensation]); 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 +index 1afa50987..8c1e4c02c 100644 --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -23,6 +23,7 @@ function useExtraContentPadding(options) { +@@ -23,6 +23,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, scroll, layout, size, -@@ -62,8 +63,8 @@ function useExtraContentPadding(options) { +@@ -62,8 +64,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -238,57 +417,88 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3 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); +@@ -82,11 +84,14 @@ function useExtraContentPadding(options) { + const target = Math.max(scroll.value - effectiveDelta, -currentTotal); + scrollToTarget(target); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); ++ // Respect the adjusted top inset: short content rests at a negative ++ // offset (-startInset), and the scrollable range never extends past it. ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); ++ const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), minScroll); scrollToTarget(target); } - }, [inverted, keyboardLiftBehavior]); -+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, contentInsetStartCompensation]); } 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 +index a036b431f..49a6cad04 100644 --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = { +@@ -86,6 +86,18 @@ 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; ++ /** ++ * Adjusted TOP inset UIKit applies to this scroll view (safe area / ++ * translucent header under `contentInsetAdjustmentBehavior="automatic"`). ++ * Non-inverted lists rest at `-contentInsetStartCompensation`, so all ++ * scroll-offset clamps floor there instead of 0. Used ONLY in scroll-offset ++ * math — never written into `contentInset`. ++ * ++ * Default is `0`. ++ */ ++ contentInsetStartCompensation?: 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/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +index 2b51cc473..8e4b08345 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH + * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 + * ``` + */ +-export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; ++export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInset?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 +index aff9b5a8d..cf6d63741 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 = { +@@ -9,6 +9,9 @@ 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; ++ contentInsetStartCompensation?: 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 +index ec73f7054..85ba62f04 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 = { +@@ -8,6 +8,9 @@ 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; ++ contentInsetStartCompensation?: 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 +index 03f5f74e9..bc67b7b9c 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx @@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; @@ -300,11 +510,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d useAnimatedRef, useAnimatedStyle, useDerivedValue, -@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< +@@ -35,9 +37,12 @@ const KeyboardChatScrollView = forwardRef< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ contentInsetStartCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -312,23 +523,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d onEndVisible, ...rest }, -@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< +@@ -64,6 +69,8 @@ const KeyboardChatScrollView = forwardRef< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, ++ contentInsetStartCompensation, }); useExtraContentPadding({ -@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< +@@ -71,6 +78,8 @@ const KeyboardChatScrollView = forwardRef< extraContentPadding, keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation, scroll, layout, size, -@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef< +@@ -102,13 +111,25 @@ const KeyboardChatScrollView = forwardRef< ), ); @@ -360,10 +573,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d (e: LayoutChangeEvent) => { onLayoutInternal(e); diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts -index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 +index dd222b57b..1a4a308d4 100644 --- a/src/components/KeyboardChatScrollView/types.ts +++ b/src/components/KeyboardChatScrollView/types.ts -@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { +@@ -90,6 +90,25 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; @@ -376,22 +589,74 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb + * Default is `0`. + */ + adjustedInsetCompensation?: number; ++ /** ++ * Adjusted TOP inset UIKit applies to this scroll view (safe area / ++ * translucent header under `contentInsetAdjustmentBehavior="automatic"`). ++ * Non-inverted lists rest at `-contentInsetStartCompensation`, so all ++ * scroll-offset clamps floor there instead of 0. Used ONLY in scroll-offset ++ * math — never written into `contentInset`. ++ * ++ * Default is `0`. ++ */ ++ contentInsetStartCompensation?: 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/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +index 37eacd108..0e47c74a8 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +@@ -232,6 +232,7 @@ export const clampedScrollTarget = ( + * @param layoutHeight - Visible height of the scroll view. + * @param inverted - Whether the list is inverted. + * @param totalPaddingForMaxScroll - Total padding to use for maxScroll calculation. When provided, used instead of keyboardHeight for the scrollable range. Defaults to keyboardHeight. ++ * @param startInset - Adjusted top inset; non-inverted offsets floor at `-startInset` instead of 0. + * @returns The absolute contentOffset.y to set. + * @example + * ```ts +@@ -245,6 +246,7 @@ export const computeIOSContentOffset = ( + layoutHeight: number, + inverted: boolean, + totalPaddingForMaxScroll?: number, ++ startInset: number = 0, + ): number => { + "worklet"; + +@@ -262,7 +264,18 @@ export const computeIOSContentOffset = ( + ); + } + +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); ++ // Non-inverted lists under an adjusted top inset (translucent header / ++ // automatic safe-area) rest at a NEGATIVE offset (-startInset). Flooring at ++ // 0 would force short content up under the header and strand it there, ++ // because the offset can never return below 0 through this math. ++ const minScroll = -startInset; ++ const maxScroll = Math.max( ++ contentHeight - layoutHeight + paddingForMax, ++ minScroll, ++ ); + +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ return Math.min( ++ Math.max(keyboardHeight + relativeScroll, minScroll), ++ maxScroll, ++ ); + }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 +index 560df54ba..ef8b4ec8e 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -@@ -43,6 +43,7 @@ function useChatKeyboard( +@@ -43,6 +43,8 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, } = options; const padding = useSharedValue(0); -@@ -104,10 +105,12 @@ function useChatKeyboard( +@@ -104,10 +106,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -408,50 +673,95 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670 // persistent mode: when keyboard shrinks, clamp to valid range if ( -@@ -242,7 +245,7 @@ function useChatKeyboard( +@@ -128,13 +132,14 @@ function useChatKeyboard( + Math.min(scroll.value, maxScroll), + ); + } else { ++ const minScroll = -contentInsetStartCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + actualTotalPadding, +- 0, ++ minScroll, + ); + + contentOffsetY.value = Math.max( +- 0, ++ minScroll, + Math.min(scroll.value, maxScroll), + ); + } +@@ -163,13 +168,14 @@ function useChatKeyboard( + Math.min(scroll.value, maxScroll), + ); + } else { ++ const minScroll = -contentInsetStartCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + actualTotalPadding, +- 0, ++ minScroll, + ); + + contentOffsetY.value = Math.max( +- 0, ++ minScroll, + Math.min(scroll.value, maxScroll), + ); + } +@@ -219,6 +225,7 @@ function useChatKeyboard( + layout.value.height, + inverted, + actualTotalPadding, ++ contentInsetStartCompensation, + ); + }, + onMove: () => { +@@ -242,7 +249,7 @@ function useChatKeyboard( padding.value = effective; }, }, - [inverted, keyboardLiftBehavior, offset, extraContentPadding], -+ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation], ++ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, contentInsetStartCompensation], ); return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 +index 02abf5cd9..cd18b205d 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,9 @@ 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; ++ contentInsetStartCompensation?: number; }; type UseChatKeyboardReturn = { diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 +index 833acbe78..143baf951 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { +@@ -16,6 +16,9 @@ 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; ++ contentInsetStartCompensation?: number; /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ -@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -49,6 +52,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, scroll, layout, size, -@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -97,14 +102,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { } // Compute effective delta considering blankSpace floor @@ -472,12 +782,29 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { -@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -137,16 +140,22 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + + scrollToTarget(target); + } else { ++ // Respect the adjusted top inset: short content rests at a negative ++ // offset (-startInset), and the scrollable range never extends past it. ++ const minScroll = -contentInsetStartCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + currentTotal, +- 0, ++ minScroll, ++ ); ++ const target = Math.max( ++ Math.min(scroll.value + effectiveDelta, maxScroll), ++ minScroll, + ); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); } }, - [inverted, keyboardLiftBehavior], -+ [inverted, keyboardLiftBehavior, adjustedInsetCompensation], ++ [inverted, keyboardLiftBehavior, adjustedInsetCompensation, contentInsetStartCompensation], ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee737f9e750..ed866e9ff74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,14 +70,14 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 + '@legendapp/list@3.2.0': 29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae - '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca + '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 - react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + react-native-keyboard-controller@1.21.13: 001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 @@ -206,7 +206,7 @@ importers: version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': specifier: 3.2.0 - 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -227,7 +227,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -374,7 +374,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.13 - 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -532,7 +532,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(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) @@ -12880,7 +12880,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12888,7 +12888,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(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) @@ -14150,7 +14150,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -19694,7 +19694,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.13(patch_hash=001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 3e7ed41e78e8a499e9dba1abcf52ec55dba9d986 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 11:16:28 -0400 Subject: [PATCH 02/16] fix(mobile): Account for header in thread fit checks --- apps/mobile/src/features/threads/ThreadFeed.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b6502a034ae..87df2566025 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1407,10 +1407,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const handleContentSizeChange = useCallback((_width: number, height: number) => { setContentHeight((current) => (Math.abs(current - height) > 1 ? height : current)); }, []); + // UIKit's automatic top inset reduces the visible list area without adding + // to contentHeight. Non-automatic layouts render their header spacer inside + // the list, so their contentHeight already accounts for it. + const usableViewportHeight = viewportHeight - (usesNativeAutomaticInsets ? anchorTopInset : 0); const contentUnderflowsViewport = contentHeight > 0 && viewportHeight > 0 && - contentHeight + props.contentInsetEndEstimate < viewportHeight - CONTENT_FIT_EPSILON; + contentHeight + props.contentInsetEndEstimate < usableViewportHeight - CONTENT_FIT_EPSILON; useEffect(() => { if (underflowCorrectionFrameRef.current !== null) { From c62877cfe9a44e365605572515e55ce547c515bf Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 11:53:53 -0400 Subject: [PATCH 03/16] fix(mobile): Stabilize screenshot attachment layout --- .../src/features/threads/ThreadFeed.tsx | 71 +++++++++++++++---- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 87df2566025..6509b035a55 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -41,7 +41,6 @@ import { useWindowDimensions, View, } from "react-native"; -import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { @@ -155,18 +154,26 @@ function MessageAttachmentImage(props: { attachmentId: props.attachmentId, }); - if (uri === null) { - return ( - - - - ); - } - return ( - props.onPressImage(uri)}> - - + + {uri === null ? ( + + ) : ( + props.onPressImage(uri)} + style={({ pressed }) => ({ height: "100%", opacity: pressed ? 0.7 : 1, width: "100%" })} + > + + + )} + ); } @@ -1271,7 +1278,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); + const initialEndCorrectionFrameRef = useRef(null); const underflowCorrectionFrameRef = useRef(null); + const initialEndCorrectionKeyRef = useRef(null); const previousContentUnderflowsViewportRef = useRef(false); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); @@ -1497,6 +1506,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + useEffect(() => { + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + initialEndCorrectionFrameRef.current = null; + } + + if ( + initialEndCorrectionKeyRef.current === listMountKey || + props.contentPresentation.kind !== "ready" || + contentHeight <= 0 || + viewportHeight <= 0 + ) { + return; + } + + initialEndCorrectionKeyRef.current = listMountKey; + if (contentUnderflowsViewport) { + return; + } + + initialEndCorrectionFrameRef.current = requestAnimationFrame(() => { + initialEndCorrectionFrameRef.current = requestAnimationFrame(() => { + initialEndCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToEnd({ animated: false }); + }); + }); + }, [ + contentHeight, + contentUnderflowsViewport, + listMountKey, + props.contentPresentation.kind, + props.listRef, + viewportHeight, + ]); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( @@ -1562,6 +1606,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (underflowCorrectionFrameRef.current !== null) { cancelAnimationFrame(underflowCorrectionFrameRef.current); } + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + } }; }, []); From ca998a430f560f2c61e3c79eea6afb4aceb30f0b Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 18:53:51 -0400 Subject: [PATCH 04/16] fix(mobile): Harden iOS crash capture and stack option updates Capture RCTFatal message and stack to Documents via a native handler so Release Hermes aborts leave last-crash.json without a device console. Harden the JS ErrorUtils path with a minimal write-first record, durable fsync writes, breadcrumb max-wait flush, and an Expo AppDelegate plugin. Skip NativeStackScreenOptions setOptions when content is unchanged so Thread catch-up re-renders do not loop through PreventRemoveProvider and hit maximum update depth. --- apps/mobile/app.config.ts | 1 + .../t3-native-controls/ios/T3CrashLog.swift | 164 ++++++++++ .../ios/T3NativeControlsModule.swift | 12 + apps/mobile/plugins/withIosCrashLog.cjs | 126 ++++++++ apps/mobile/src/App.tsx | 15 +- .../features/threads/ThreadRouteScreen.tsx | 89 +++--- apps/mobile/src/lib/breadcrumbPersist.ts | 102 +++++++ apps/mobile/src/lib/breadcrumbs.test.ts | 38 +++ apps/mobile/src/lib/breadcrumbs.ts | 55 ++++ apps/mobile/src/lib/crashLog.test.ts | 58 ++++ apps/mobile/src/lib/crashLog.ts | 287 ++++++++++++++++-- apps/mobile/src/lib/crashLogRecord.ts | 97 ++++++ .../src/lib/navigationBreadcrumb.test.ts | 30 ++ apps/mobile/src/lib/navigationBreadcrumb.ts | 27 ++ apps/mobile/src/native/StackHeader.tsx | 86 +++++- .../nativeStackOptionsSignature.test.ts | 74 +++++ .../src/native/nativeStackOptionsSignature.ts | 52 ++++ .../src/state/use-thread-outbox-drain.ts | 13 + 18 files changed, 1250 insertions(+), 76 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift create mode 100644 apps/mobile/plugins/withIosCrashLog.cjs create mode 100644 apps/mobile/src/lib/breadcrumbPersist.ts create mode 100644 apps/mobile/src/lib/breadcrumbs.test.ts create mode 100644 apps/mobile/src/lib/breadcrumbs.ts create mode 100644 apps/mobile/src/lib/crashLog.test.ts create mode 100644 apps/mobile/src/lib/crashLogRecord.ts create mode 100644 apps/mobile/src/lib/navigationBreadcrumb.test.ts create mode 100644 apps/mobile/src/lib/navigationBreadcrumb.ts create mode 100644 apps/mobile/src/native/nativeStackOptionsSignature.test.ts create mode 100644 apps/mobile/src/native/nativeStackOptionsSignature.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ef80e4b8212..f9bf88f45ce 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -253,6 +253,7 @@ const config: ExpoConfig = { // target (which must exist before the compile phase can be attached). ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), "./plugins/withIosSceneLifecycle.cjs", + "./plugins/withIosCrashLog.cjs", "./plugins/withAndroidCleartextTraffic.cjs", "./plugins/withAndroidGradleHeap.cjs", "./plugins/withAndroidModernPopupMenu.cjs", diff --git a/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift new file mode 100644 index 00000000000..0bbed06a394 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift @@ -0,0 +1,164 @@ +import Foundation + +/// Durable crash persistence for Documents/crash-logs. +/// +/// The Expo module uses this for JS `writeSyncText`. AppDelegate installs +/// `RCTSetFatalHandler` and calls `persistNativeFatal` so reportFatal still +/// leaves a last-crash.json when the JS ErrorUtils path never flushes. +public enum T3CrashLog { + public static let directoryName = "crash-logs" + public static let lastCrashFileName = "last-crash.json" + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + /// Shared fsync write used by the Expo module and native fatal hooks. + @discardableResult + public static func writeSyncText(relativePath: String, contents: String) -> Bool { + do { + let docs = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let url = docs.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = Data(contents.utf8) + // Non-atomic: under abort we prefer a partial file over losing the rename. + try data.write(to: url, options: []) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + if #available(iOS 13.0, *) { + try handle.synchronize() + } + return true + } catch { + return false + } + } + + /// Persist a native RCTFatal / RCTFatalException payload. + public static func persistNativeFatal( + message: String, + name: String, + stack: String?, + extra: String?, + source: String + ) { + let truncatedMessage = truncate(message, max: 8_000) + let truncatedStack = stack.map { truncate($0, max: 48_000) } + let capturedAt = isoFormatter.string(from: Date()) + let millis = Int(Date().timeIntervalSince1970 * 1000) + + var record: [String: Any] = [ + "breadcrumbs": [] as [Any], + "capturedAt": capturedAt, + "handlerInvocation": 0, + "isFatal": true, + "message": truncatedMessage, + "name": name, + "source": source, + ] + if let truncatedStack, !truncatedStack.isEmpty { + record["stack"] = truncatedStack + } + if let extra, !extra.isEmpty { + record["extraData"] = truncate(extra, max: 8_000) + } + + guard let data = try? JSONSerialization.data(withJSONObject: record, options: []), + let contents = String(data: data, encoding: .utf8) + else { + let escaped = truncatedMessage + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + let fallback = + "{\"capturedAt\":\"\(capturedAt)\",\"isFatal\":true,\"message\":\"\(escaped)\",\"name\":\"\(name)\",\"source\":\"\(source)\",\"handlerInvocation\":0,\"breadcrumbs\":[]}" + _ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: fallback) + _ = writeSyncText( + relativePath: "\(directoryName)/crash-native-\(millis)-0.json", + contents: fallback + ) + return + } + + _ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: contents) + _ = writeSyncText( + relativePath: "\(directoryName)/crash-native-\(millis)-0.json", + contents: contents + ) + } + + public static func formatJSStack(_ value: Any?) -> String? { + guard let value else { + return nil + } + if let text = value as? String { + return text + } + if let frames = value as? [[String: Any]] { + let lines = frames.prefix(80).map { frame -> String in + let method = frame["methodName"] as? String ?? "?" + let file = frame["file"] as? String ?? "?" + let line = stringifyFrameNumber(frame["lineNumber"]) + let column = stringifyFrameNumber(frame["column"]) + return " at \(method) (\(file):\(line):\(column))" + } + return lines.joined(separator: "\n") + } + return String(describing: value) + } + + public static func formatExceptionStack(_ exception: NSException) -> String { + let addresses = exception.callStackSymbols + if addresses.isEmpty { + return exception.callStackReturnAddresses.map { String(describing: $0) }.joined(separator: "\n") + } + return addresses.prefix(80).joined(separator: "\n") + } + + public static func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let text = value as? String { + return text + } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: []), + let text = String(data: data, encoding: .utf8) + { + return text + } + return String(describing: value) + } + + private static func truncate(_ text: String, max: Int) -> String { + guard text.count > max else { + return text + } + let end = text.index(text.startIndex, offsetBy: max) + return String(text[.. String { + if let number = value as? NSNumber { + return number.stringValue + } + if let intValue = value as? Int { + return String(intValue) + } + if let text = value as? String { + return text + } + return "?" + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 33e1dc9086c..f39a3b82fc5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,9 +1,21 @@ import ExpoModulesCore +import Foundation public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + // Durable last-chance write for fatal JS records. Expo FileSystem write can + // lose the race with RCTFatal abort; this fsyncs to Documents. + Function("writeSyncText") { (relativePath: String, contents: String) -> Bool in + T3CrashLog.writeSyncText(relativePath: relativePath, contents: contents) + } + + // AppDelegate installs the native RCTFatal hooks; this is a no-op ack for JS. + Function("installFatalHandler") { () -> Bool in + true + } + View(T3HeaderButtonView.self) { Prop("label") { (view: T3HeaderButtonView, label: String) in view.setLabel(label) diff --git a/apps/mobile/plugins/withIosCrashLog.cjs b/apps/mobile/plugins/withIosCrashLog.cjs new file mode 100644 index 00000000000..72afa09fd49 --- /dev/null +++ b/apps/mobile/plugins/withIosCrashLog.cjs @@ -0,0 +1,126 @@ +const { withAppDelegate } = require("expo/config-plugins"); + +const IMPORT_LINE = "internal import T3NativeControls"; +const INSTALL_CALL = "Self.installNativeFatalHandlersIfNeeded()"; +const METHOD_MARKER = "installNativeFatalHandlersIfNeeded"; +const FLAG_MARKER = "fatalHandlersInstalled"; + +const FATAL_HANDLER_METHOD = ` + private static var fatalHandlersInstalled = false + + /// Hooks RN's fatal path so reportFatal leaves last-crash.json even when the + /// JS ErrorUtils logger never flushes (the common Release Hermes failure mode). + private static func installNativeFatalHandlersIfNeeded() { + if fatalHandlersInstalled { + return + } + fatalHandlersInstalled = true + + let previousFatal = RCTGetFatalHandler() + let previousException = RCTGetFatalExceptionHandler() + + RCTSetFatalHandler { error in + if let error { + let nsError = error as NSError + T3CrashLog.persistNativeFatal( + message: nsError.localizedDescription, + name: "RCTFatal", + stack: T3CrashLog.formatJSStack(nsError.userInfo[RCTJSStackTraceKey]), + extra: T3CrashLog.stringValue(nsError.userInfo[RCTJSExtraDataKey]), + source: "rct-fatal" + ) + } + if let previousFatal { + previousFatal(error) + } else if let error { + let nsError = error as NSError + let description = nsError.localizedDescription + let name = "\\(RCTFatalExceptionName): \\(description)" + let stack = nsError.userInfo[RCTJSStackTraceKey] as? [[String: Any]] + let reason = RCTFormatError(description, stack, 175) + var userInfo = nsError.userInfo + userInfo["RCTUntruncatedMessageKey"] = RCTFormatError(description, stack, 0) + NSException(name: NSExceptionName(name), reason: reason, userInfo: userInfo).raise() + } + } + + RCTSetFatalExceptionHandler { exception in + if let exception { + T3CrashLog.persistNativeFatal( + message: exception.reason ?? exception.name.rawValue, + name: exception.name.rawValue, + stack: T3CrashLog.formatExceptionStack(exception), + extra: nil, + source: "rct-fatal-exception" + ) + } + if let previousException { + previousException(exception) + } else { + exception?.raise() + } + } + } +`; + +function ensureImport(contents) { + if (contents.includes("import T3NativeControls")) { + return contents; + } + if (contents.includes("import ReactAppDependencyProvider")) { + return contents.replace( + "import ReactAppDependencyProvider", + `import ReactAppDependencyProvider\n${IMPORT_LINE}`, + ); + } + if (contents.includes("import React\n")) { + return contents.replace("import React\n", `import React\n${IMPORT_LINE}\n`); + } + return `${IMPORT_LINE}\n${contents}`; +} + +function ensureInstallCall(contents) { + if (contents.includes(INSTALL_CALL)) { + return contents; + } + const replaced = contents.replace( + /(didFinishLaunchingWithOptions[^{]*\{\n)/, + `$1 // t3code: capture RCTFatal message/stack before JS can die.\n ${INSTALL_CALL}\n\n`, + ); + if (replaced === contents) { + throw new Error("withIosCrashLog: could not find didFinishLaunchingWithOptions body"); + } + return replaced; +} + +function ensureFatalHandlerMethod(contents) { + if (contents.includes(METHOD_MARKER) && contents.includes(FLAG_MARKER)) { + return contents; + } + if (contents.includes("// Linking API")) { + return contents.replace("// Linking API", `${FATAL_HANDLER_METHOD}\n // Linking API`); + } + // Fallback: insert before ReactNativeDelegate class. + if (contents.includes("class ReactNativeDelegate")) { + return contents.replace( + "class ReactNativeDelegate", + `${FATAL_HANDLER_METHOD}\n}\n\nclass ReactNativeDelegate`, + ); + } + throw new Error("withIosCrashLog: could not find insertion point for fatal handler method"); +} + +module.exports = function withIosCrashLog(config) { + return withAppDelegate(config, (nextConfig) => { + if (nextConfig.modResults.language !== "swift") { + throw new Error("The iOS crash log plugin requires a Swift AppDelegate."); + } + + let contents = nextConfig.modResults.contents; + contents = ensureImport(contents); + contents = ensureInstallCall(contents); + contents = ensureFatalHandlerMethod(contents); + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index c76d3010081..3757839f085 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,12 +1,17 @@ import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; +import { + createStaticNavigation, + DarkTheme, + DefaultTheme, + type NavigationState, +} from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; @@ -16,6 +21,7 @@ import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; +import { recordNavigationBreadcrumb } from "./lib/navigationBreadcrumb"; import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; @@ -39,6 +45,10 @@ export default function App() { SplashScreen.hide(); }, []); + const onNavigationStateChange = useCallback((state: NavigationState | undefined) => { + recordNavigationBreadcrumb(state); + }, []); + return ( @@ -60,6 +70,7 @@ export default function App() { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d2c2df6411a..a1311ea1118 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -22,6 +22,7 @@ import { type AndroidHeaderAction, } from "../../components/AndroidScreenHeader"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { addBreadcrumb } from "../../lib/breadcrumbs"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { connectionTone } from "../connection/connectionTone"; @@ -563,6 +564,11 @@ function ThreadRouteContent( ) { return; } + addBreadcrumb("thread.stop", { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + turnId: selectedThread.session.activeTurnId ?? null, + }); return interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { @@ -889,44 +895,57 @@ function ThreadRouteContent( ); + const threadScreenOptions = useMemo( + () => ({ + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", + headerTitle: selectedThread.title, + headerTitleStyle: usesNativeHeaderGlass + ? ({ + fontSize: 17, + fontWeight: "800" as const, + } as const) + : undefined, + title: selectedThread.title, + headerBackVisible: !layout.usesSplitView, + // Compact uses the NATIVE back button when a previous route exists; + // deep links / cold starts get an explicit Home button instead. + // Split view always uses its custom left items. + unstable_headerLeftItems: + Platform.OS === "ios" + ? layout.usesSplitView + ? () => splitLeftHeaderItems + : canGoBack + ? undefined + : () => compactHomeHeaderItems + : undefined, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: + Platform.OS === "ios" + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + : undefined, + unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + }), + [ + canGoBack, + compactHomeHeaderItems, + compactRightHeaderItems, + headerSubtitle, + layout.usesSplitView, + selectedThread.title, + splitLeftHeaderItems, + threadCenterHeaderItems, + usesNativeHeaderGlass, + ], + ); + return ( <> {activeInspectorRenderer ? : null} - splitLeftHeaderItems - : canGoBack - ? undefined - : () => compactHomeHeaderItems - : undefined, - // Search lives in the persistent sidebar, so the split header keeps - // the git controls on the RIGHT (no center items — center space is - // reserved for future breadcrumbs/status). - unstable_headerRightItems: - Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) - : undefined, - unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, - }} - /> + {Platform.OS === "android" ? ( | null = null; +let maxWaitTimer: ReturnType | null = null; +let installed = false; + +export function installBreadcrumbPersistence(): void { + if (installed) { + return; + } + installed = true; + setBreadcrumbPersistHook(() => { + scheduleBreadcrumbFlush(); + }); +} + +/** Immediate flush for fatal paths; ignore errors. */ +export function flushBreadcrumbsSync(): void { + clearFlushTimers(); + writeBreadcrumbsToDisk(); +} + +function clearFlushTimers(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (maxWaitTimer !== null) { + clearTimeout(maxWaitTimer); + maxWaitTimer = null; + } +} + +function scheduleBreadcrumbFlush(): void { + // Trailing debounce: reset on each breadcrumb. + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + // Quiet period elapsed; drop max-wait so the next burst starts a new cap. + if (maxWaitTimer !== null) { + clearTimeout(maxWaitTimer); + maxWaitTimer = null; + } + writeBreadcrumbsToDisk(); + }, FLUSH_DEBOUNCE_MS); + + // Max-wait: do not reset while activity continues, so continuous bursts still + // flush about every FLUSH_MAX_WAIT_MS (needed when a native kill skips sync flush). + if (maxWaitTimer === null) { + maxWaitTimer = setTimeout(() => { + maxWaitTimer = null; + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + writeBreadcrumbsToDisk(); + }, FLUSH_MAX_WAIT_MS); + } +} + +function writeBreadcrumbsToDisk(): void { + const payload = JSON.stringify({ + breadcrumbs: getBreadcrumbs(), + updatedAt: new Date().toISOString(), + }); + const relativePath = `${CRASH_LOG_DIRECTORY}/${LAST_BREADCRUMBS_FILE}`; + + try { + const native = requireNativeModule("T3NativeControls") as { + writeSyncText?: (relativePath: string, contents: string) => boolean; + }; + if (typeof native.writeSyncText === "function") { + native.writeSyncText(relativePath, payload); + } + } catch { + // fall through to Expo FS + } + + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, LAST_BREADCRUMBS_FILE); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(payload); + } catch { + // Best-effort only. + } +} diff --git a/apps/mobile/src/lib/breadcrumbs.test.ts b/apps/mobile/src/lib/breadcrumbs.test.ts new file mode 100644 index 00000000000..7a0f9894391 --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { addBreadcrumb, breadcrumbCount, clearBreadcrumbs, getBreadcrumbs } from "./breadcrumbs"; + +describe("breadcrumbs", () => { + it("records entries in order and exposes a copy", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Home" }); + addBreadcrumb("outbox.dispatch", { messageId: "m1" }); + + expect(getBreadcrumbs()).toEqual([ + expect.objectContaining({ type: "nav", data: { path: "Home" } }), + expect.objectContaining({ type: "outbox.dispatch", data: { messageId: "m1" } }), + ]); + expect(breadcrumbCount()).toBe(2); + }); + + it("caps ring size and drops the oldest entries", () => { + clearBreadcrumbs(); + for (let index = 0; index < 100; index += 1) { + addBreadcrumb("tick", { index }); + } + + expect(breadcrumbCount()).toBe(80); + expect(getBreadcrumbs()[0]?.data?.index).toBe(20); + expect(getBreadcrumbs().at(-1)?.data?.index).toBe(99); + }); + + it("truncates long string values", () => { + clearBreadcrumbs(); + addBreadcrumb("msg", { text: "x".repeat(250) }); + + const text = getBreadcrumbs()[0]?.data?.text; + expect(typeof text).toBe("string"); + expect(String(text).length).toBeLessThanOrEqual(201); + expect(String(text).endsWith("…")).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/breadcrumbs.ts b/apps/mobile/src/lib/breadcrumbs.ts new file mode 100644 index 00000000000..7dd1c2b9cba --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbs.ts @@ -0,0 +1,55 @@ +/** Compact trail kept in memory and flushed with fatal/error crash records. */ + +export type BreadcrumbData = Readonly>; + +export type Breadcrumb = { + readonly t: string; + readonly type: string; + readonly data?: BreadcrumbData; +}; + +const MAX_BREADCRUMBS = 80; + +const ring: Breadcrumb[] = []; +let onBreadcrumbAdded: (() => void) | null = null; + +/** Optional disk flusher installed by crashLog / breadcrumbPersist. */ +export function setBreadcrumbPersistHook(hook: (() => void) | null): void { + onBreadcrumbAdded = hook; +} + +export function addBreadcrumb(type: string, data?: BreadcrumbData): void { + const entry: Breadcrumb = + data === undefined + ? { t: new Date().toISOString(), type } + : { t: new Date().toISOString(), type, data: sanitizeBreadcrumbData(data) }; + ring.push(entry); + if (ring.length > MAX_BREADCRUMBS) { + ring.splice(0, ring.length - MAX_BREADCRUMBS); + } + onBreadcrumbAdded?.(); +} + +export function getBreadcrumbs(): ReadonlyArray { + return ring.slice(); +} + +export function clearBreadcrumbs(): void { + ring.length = 0; +} + +export function breadcrumbCount(): number { + return ring.length; +} + +function sanitizeBreadcrumbData(data: BreadcrumbData): BreadcrumbData { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === "string") { + out[key] = value.length > 200 ? `${value.slice(0, 200)}…` : value; + } else { + out[key] = value; + } + } + return out; +} diff --git a/apps/mobile/src/lib/crashLog.test.ts b/apps/mobile/src/lib/crashLog.test.ts new file mode 100644 index 00000000000..219815d8655 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { addBreadcrumb, clearBreadcrumbs } from "./breadcrumbs"; +import { buildCrashRecord, buildMinimalCrashRecord, shouldPersistNonFatal } from "./crashLogRecord"; + +describe("crashLog records", () => { + it("captures message, stack, and breadcrumbs for fatals", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Thread" }); + addBreadcrumb("outbox.dispatch", { messageId: "m1" }); + + const error = new Error("boom"); + const record = buildCrashRecord(error, true, 7); + + expect(record.isFatal).toBe(true); + expect(record.message).toBe("boom"); + expect(record.name).toBe("Error"); + expect(record.handlerInvocation).toBe(7); + expect(record.source).toBe("error-utils"); + expect(record.stack).toContain("boom"); + expect(record.breadcrumbs.map((entry) => entry.type)).toEqual(["nav", "outbox.dispatch"]); + }); + + it("builds a minimal record without breadcrumbs for the first write", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Thread" }); + + const record = buildMinimalCrashRecord(new Error("early"), true, 1); + expect(record.breadcrumbs).toEqual([]); + expect(record.message).toBe("early"); + expect(record.source).toBe("error-utils"); + expect(record.isFatal).toBe(true); + }); + + it("stringifies non-Error values", () => { + clearBreadcrumbs(); + const record = buildCrashRecord("string-throw", false, 1); + expect(record.message).toBe("string-throw"); + expect(record.name).toBeNull(); + expect(record.stack).toBeNull(); + expect(record.isFatal).toBe(false); + }); + + it("truncates huge messages and stacks", () => { + const huge = "x".repeat(20_000); + const error = new Error(huge); + error.stack = "y".repeat(60_000); + const record = buildMinimalCrashRecord(error, true, 1); + expect(record.message.length).toBeLessThanOrEqual(8_001); + expect(record.stack?.length).toBeLessThanOrEqual(48_001); + }); + + it("only persists non-fatals that look like real Errors with stacks", () => { + expect(shouldPersistNonFatal(new Error("x"))).toBe(true); + expect(shouldPersistNonFatal("x")).toBe(false); + expect(shouldPersistNonFatal({ message: "x" })).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts index 486b485b774..d1d84e27243 100644 --- a/apps/mobile/src/lib/crashLog.ts +++ b/apps/mobile/src/lib/crashLog.ts @@ -1,28 +1,68 @@ import { Directory, File, Paths } from "expo-file-system"; +import { requireNativeModule } from "expo"; import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; +import { getBreadcrumbs } from "./breadcrumbs"; +import { flushBreadcrumbsSync, installBreadcrumbPersistence } from "./breadcrumbPersist"; +import { + buildCrashRecord, + buildMinimalCrashRecord, + shouldPersistNonFatal, + type CrashLogRecord, +} from "./crashLogRecord"; + const CRASH_LOG_DIRECTORY = "crash-logs"; -const MAX_PERSISTED_CRASH_LOGS = 20; -const REPORTED_ON_LAUNCH_COUNT = 3; +const LAST_CRASH_FILE = "last-crash.json"; +const LAST_BREADCRUMBS_FILE = "last-breadcrumbs.json"; +const MAX_PERSISTED_FATAL_LOGS = 20; +const MAX_PERSISTED_NONFATAL_LOGS = 10; +const REPORTED_ON_LAUNCH_COUNT = 5; let crashSequence = 0; +let handlerInvocationCount = 0; +let installed = false; +let cachedNative: NativeCrashLogModule | null | undefined; + +type NativeCrashLogModule = { + installFatalHandler?: () => boolean; + writeSyncText?: (relativePath: string, contents: string) => boolean; +}; + +export type { CrashLogRecord } from "./crashLogRecord"; +export { buildCrashRecord, buildMinimalCrashRecord, shouldPersistNonFatal } from "./crashLogRecord"; export function installCrashLogger(): void { - const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; - if (errorUtils === undefined) { + if (installed) { return; } - const previousHandler = errorUtils.getGlobalHandler(); - errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { - if (isFatal === true) { + installed = true; + + installBreadcrumbPersistence(); + // Re-assert native RCTFatal hooks after JS boot (covers expo-updates temp replace). + tryNativeInstallFatalHandler(); + + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils !== undefined) { + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + handlerInvocationCount += 1; + const fatal = isFatal === true; + const shouldPersist = fatal || shouldPersistNonFatal(error); + if (shouldPersist) { + // Write first, before any work that can throw (console, breadcrumbs, rich JSON). + persistCrashRecordBestEffort(error, fatal, handlerInvocationCount); + } try { - persistCrashRecord(error); + emitConsoleMarker(error, fatal, handlerInvocationCount, shouldPersist); } catch { // Never let the logger mask the original error. } - } - previousHandler(error, isFatal); - }); + previousHandler(error, isFatal); + }); + } + + installUnhandledRejectionHandler(); + // Deferred so install (which runs before the app module graph) does no // file IO on the startup path. setTimeout(() => { @@ -30,25 +70,201 @@ export function installCrashLogger(): void { }, 0); } -function persistCrashRecord(error: unknown): void { - const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); +function emitConsoleMarker( + error: unknown, + isFatal: boolean, + handlerInvocation: number, + willPersist: boolean, +): void { const cause = error instanceof Error ? error : null; - const record = { - capturedAt: new Date().toISOString(), - isFatal: true, + const marker = { + breadcrumbs: getBreadcrumbs().slice(-12), + handlerInvocation, + isFatal, message: cause?.message ?? String(error), name: cause?.name ?? null, - stack: cause?.stack ?? null, + persist: willPersist, + stack: cause?.stack?.split("\n").slice(0, 12) ?? null, }; + console.error("[crash-log] handler", JSON.stringify(marker)); +} + +/** + * Minimal record first (message/stack only), then enrich with breadcrumbs. + * Native RCTFatal hook is the backstop if this never runs. + */ +function persistCrashRecordBestEffort( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): void { crashSequence += 1; - const file = new File(directory, `crash-${Date.now()}-${crashSequence}.json`); - file.create({ intermediates: true, overwrite: true }); - file.write(JSON.stringify(record, null, 2)); + const sequence = crashSequence; + const prefix = isFatal ? "crash" : "error"; + const stampedName = `${prefix}-${Date.now()}-${sequence}.json`; + + // 1) Tiny payload that should succeed even under memory pressure. + try { + const minimal = buildMinimalCrashRecord(error, isFatal, handlerInvocation); + const encoded = stableStringify(minimal); + if (encoded !== null) { + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${LAST_CRASH_FILE}`, encoded); + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${stampedName}`, encoded); + } + } catch { + // continue to richer attempt + } + + // 2) Breadcrumbs + full record (best effort). + try { + flushBreadcrumbsSync(); + const full = buildCrashRecord(error, isFatal, handlerInvocation); + const encoded = stableStringify(full); + if (encoded === null) { + return; + } + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${LAST_CRASH_FILE}`, encoded); + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${stampedName}`, encoded); + writeExpoFsRecord(stampedName, encoded); + } catch { + // Native write may still have succeeded. + } +} + +function writeExpoFsRecord(stampedName: string, encoded: string): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const stamped = new File(directory, stampedName); + stamped.create({ intermediates: true, overwrite: true }); + stamped.write(encoded); + const last = new File(directory, LAST_CRASH_FILE); + if (!last.exists) { + last.create({ intermediates: true, overwrite: true }); + } + last.write(encoded); + } catch { + // Native write may still have succeeded. + } +} + +function stableStringify(value: unknown): string | null { + try { + return JSON.stringify(value); + } catch { + try { + const fallback: CrashLogRecord = { + breadcrumbs: [], + capturedAt: new Date().toISOString(), + handlerInvocation: 0, + isFatal: true, + message: "crash-log-stringify-failed", + name: "Error", + source: "error-utils", + stack: null, + }; + return JSON.stringify(fallback); + } catch { + return null; + } + } +} + +function getNativeModule(): NativeCrashLogModule | null { + if (cachedNative !== undefined) { + return cachedNative; + } + try { + cachedNative = requireNativeModule("T3NativeControls") as NativeCrashLogModule; + return cachedNative; + } catch { + cachedNative = null; + return null; + } +} + +function tryNativeInstallFatalHandler(): void { + try { + const native = getNativeModule(); + if (typeof native?.installFatalHandler === "function") { + native.installFatalHandler(); + } + } catch { + // Optional; AppDelegate installs the primary hook. + } +} + +function tryNativeSyncWrite(relativePath: string, contents: string): boolean { + try { + const native = getNativeModule(); + if (typeof native?.writeSyncText !== "function") { + return false; + } + return native.writeSyncText(relativePath, contents) === true; + } catch { + return false; + } +} + +function installUnhandledRejectionHandler(): void { + const target = globalThis as typeof globalThis & { + onunhandledrejection?: ((event: { reason?: unknown }) => void) | null; + addEventListener?: (type: string, listener: (event: { reason?: unknown }) => void) => void; + }; + + const onRejection = (reason: unknown): void => { + handlerInvocationCount += 1; + try { + const error = + reason instanceof Error + ? reason + : new Error(typeof reason === "string" ? reason : String(reason)); + persistCrashRecordBestEffort(error, false, handlerInvocationCount); + emitConsoleMarker(error, false, handlerInvocationCount, true); + } catch { + // Ignore logger failures. + } + }; + + if (typeof target.addEventListener === "function") { + target.addEventListener("unhandledrejection", (event) => { + onRejection(event.reason); + }); + return; + } + + const previous = target.onunhandledrejection; + target.onunhandledrejection = (event) => { + onRejection(event?.reason); + if (typeof previous === "function") { + previous.call(target, event); + } + }; } function reportAndPrunePreviousCrashes(): void { try { + try { + const last = new File(new Directory(Paths.document, CRASH_LOG_DIRECTORY), LAST_CRASH_FILE); + if (last.exists) { + console.warn("[crash-log] last-crash.json:", last.textSync()); + } + } catch { + // continue + } + + try { + const crumbs = new File( + new Directory(Paths.document, CRASH_LOG_DIRECTORY), + LAST_BREADCRUMBS_FILE, + ); + if (crumbs.exists) { + console.warn("[crash-log] last-breadcrumbs.json:", crumbs.textSync()); + } + } catch { + // optional + } + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); if (!directory.exists) { return; @@ -57,24 +273,35 @@ function reportAndPrunePreviousCrashes(): void { .list() .filter( (entry): entry is File => - entry instanceof File && entry.name.startsWith("crash-") && entry.name.endsWith(".json"), + entry instanceof File && + (entry.name.startsWith("crash-") || entry.name.startsWith("error-")) && + entry.name.endsWith(".json"), ) .sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { try { - console.warn(`[crash-log] previous fatal JS error (${file.name}):`, file.textSync()); + console.warn(`[crash-log] previous record (${file.name}):`, file.textSync()); } catch { // Skip unreadable records. } } - for (const file of files.slice(0, Math.max(0, files.length - MAX_PERSISTED_CRASH_LOGS))) { - try { - file.delete(); - } catch { - // Leave undeletable records for the next prune. - } - } + + const fatals = files.filter((file) => file.name.startsWith("crash-")); + const nonFatals = files.filter((file) => file.name.startsWith("error-")); + pruneOldest(fatals, MAX_PERSISTED_FATAL_LOGS); + pruneOldest(nonFatals, MAX_PERSISTED_NONFATAL_LOGS); } catch { // Reporting past crashes must never affect startup. } } + +function pruneOldest(files: ReadonlyArray, keep: number): void { + for (const file of files.slice(0, Math.max(0, files.length - keep))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } +} diff --git a/apps/mobile/src/lib/crashLogRecord.ts b/apps/mobile/src/lib/crashLogRecord.ts new file mode 100644 index 00000000000..b4ca72f200d --- /dev/null +++ b/apps/mobile/src/lib/crashLogRecord.ts @@ -0,0 +1,97 @@ +import { getBreadcrumbs, type Breadcrumb } from "./breadcrumbs"; + +export type CrashLogRecord = { + readonly breadcrumbs: ReadonlyArray; + readonly capturedAt: string; + readonly handlerInvocation: number; + readonly isFatal: boolean; + readonly message: string; + readonly name: string | null; + readonly source?: string; + readonly stack: string | null; +}; + +const MAX_MESSAGE_CHARS = 8_000; +const MAX_STACK_CHARS = 48_000; + +export function buildMinimalCrashRecord( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): CrashLogRecord { + return { + breadcrumbs: [], + capturedAt: new Date().toISOString(), + handlerInvocation, + isFatal, + message: truncate(safeMessage(error), MAX_MESSAGE_CHARS), + name: safeName(error), + source: "error-utils", + stack: truncateNullable(safeStack(error), MAX_STACK_CHARS), + }; +} + +export function buildCrashRecord( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): CrashLogRecord { + return { + breadcrumbs: getBreadcrumbs(), + capturedAt: new Date().toISOString(), + handlerInvocation, + isFatal, + message: truncate(safeMessage(error), MAX_MESSAGE_CHARS), + name: safeName(error), + source: "error-utils", + stack: truncateNullable(safeStack(error), MAX_STACK_CHARS), + }; +} + +export function shouldPersistNonFatal(error: unknown): boolean { + return ( + error instanceof Error && + typeof error.stack === "string" && + error.stack.length > 0 && + error.message.length > 0 + ); +} + +function safeMessage(error: unknown): string { + if (error instanceof Error) { + return error.message.length > 0 ? error.message : error.name || "Error"; + } + if (typeof error === "string") { + return error; + } + try { + return String(error); + } catch { + return "unknown-error"; + } +} + +function safeName(error: unknown): string | null { + if (error instanceof Error) { + return error.name || null; + } + return null; +} + +function safeStack(error: unknown): string | null { + if (error instanceof Error && typeof error.stack === "string") { + return error.stack; + } + return null; +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function truncateNullable(text: string | null, max: number): string | null { + if (text === null) { + return null; + } + return truncate(text, max); +} diff --git a/apps/mobile/src/lib/navigationBreadcrumb.test.ts b/apps/mobile/src/lib/navigationBreadcrumb.test.ts new file mode 100644 index 00000000000..ffa35664f71 --- /dev/null +++ b/apps/mobile/src/lib/navigationBreadcrumb.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { routePathFromNavigationState } from "./navigationBreadcrumb"; + +describe("routePathFromNavigationState", () => { + it("joins nested active routes", () => { + expect( + routePathFromNavigationState({ + index: 0, + routes: [ + { + key: "root", + name: "Root", + state: { + index: 1, + routes: [ + { key: "home", name: "Home" }, + { key: "thread", name: "Thread" }, + ], + }, + }, + ], + }), + ).toBe("Root/Thread"); + }); + + it("returns empty for missing state", () => { + expect(routePathFromNavigationState(undefined)).toBe(""); + }); +}); diff --git a/apps/mobile/src/lib/navigationBreadcrumb.ts b/apps/mobile/src/lib/navigationBreadcrumb.ts new file mode 100644 index 00000000000..6b23e7b4073 --- /dev/null +++ b/apps/mobile/src/lib/navigationBreadcrumb.ts @@ -0,0 +1,27 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; + +import { addBreadcrumb } from "./breadcrumbs"; + +type NavState = NavigationState | PartialState | undefined; + +/** Best-effort route path for crash breadcrumbs (e.g. Root/Thread). */ +export function routePathFromNavigationState(state: NavState): string { + if (state === undefined || !("routes" in state) || state.routes === undefined) { + return ""; + } + const index = "index" in state && typeof state.index === "number" ? state.index : 0; + const route = state.routes[index]; + if (route === undefined) { + return ""; + } + const nested = routePathFromNavigationState(route.state as NavState); + return nested.length > 0 ? `${route.name}/${nested}` : String(route.name); +} + +export function recordNavigationBreadcrumb(state: NavState): void { + const path = routePathFromNavigationState(state); + if (path.length === 0) { + return; + } + addBreadcrumb("nav", { path }); +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 552f0e23cdc..bc26f38ec32 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -17,12 +17,15 @@ import { } from "react"; import type { ColorValue } from "react-native"; +import { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; + export { nativeHeaderScrollEdgeEffects, nativeTopScrollEdgeEffect, type NativeHeaderScrollEdgeEffects, type NativeTopScrollEdgeEffect, } from "./scrollEdgeEffects"; +export { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; export type AppNativeStackNavigationOptions = Omit< NativeStackNavigationOptions, @@ -68,21 +71,86 @@ export function NativeStackScreenOptions(props: { readonly name?: string; }) { const navigation = useNativeStackNavigation(); - const lastAppliedOptionsRef = useRef(undefined); - const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); + const optionsRef = useRef(props.options); + optionsRef.current = props.options; + const lastSignatureRef = useRef(null); + const signature = buildNativeStackOptionsSignature(props.options); + + // Stable factories so navigation keeps one function identity while always + // reading the latest items from optionsRef. + const leftItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerLeftItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); + const rightItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerRightItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); + const centerItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerCenterItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); + const toolbarItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerToolbarItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); useLayoutEffect(() => { - if (!navigation || !normalizedOptions) { + if (!navigation || props.options === undefined) { + return; + } + if (lastSignatureRef.current === signature) { return; } - // Avoid re-entering navigation state when the same options object is - // reapplied every layout (common when callers pass unstable object literals). - if (lastAppliedOptionsRef.current === normalizedOptions) { + lastSignatureRef.current = signature; + + const normalized = normalizeScreenOptions(props.options); + if (!normalized) { return; } - lastAppliedOptionsRef.current = normalizedOptions; - navigation.setOptions(normalizedOptions); - }, [navigation, normalizedOptions]); + + const nextOptions = { ...normalized } as NativeStackNavigationOptions & { + unstable_headerCenterItems?: unknown; + unstable_headerLeftItems?: unknown; + unstable_headerRightItems?: unknown; + unstable_headerToolbarItems?: unknown; + }; + + if (typeof props.options.unstable_headerLeftItems === "function") { + nextOptions.unstable_headerLeftItems = leftItemsFactory; + } + if (typeof props.options.unstable_headerRightItems === "function") { + nextOptions.unstable_headerRightItems = rightItemsFactory; + } + if (typeof props.options.unstable_headerCenterItems === "function") { + nextOptions.unstable_headerCenterItems = centerItemsFactory; + } + if (typeof props.options.unstable_headerToolbarItems === "function") { + nextOptions.unstable_headerToolbarItems = toolbarItemsFactory; + } + + navigation.setOptions(nextOptions); + }, [ + centerItemsFactory, + leftItemsFactory, + navigation, + props.options, + rightItemsFactory, + signature, + toolbarItemsFactory, + ]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/mobile/src/native/nativeStackOptionsSignature.test.ts b/apps/mobile/src/native/nativeStackOptionsSignature.test.ts new file mode 100644 index 00000000000..31032361878 --- /dev/null +++ b/apps/mobile/src/native/nativeStackOptionsSignature.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; + +describe("buildNativeStackOptionsSignature", () => { + it("treats fresh object/function identities with the same content as equal", () => { + const items = [ + { + identifier: "thread-right-git", + label: "Git", + type: "button", + }, + ]; + + const first = buildNativeStackOptionsSignature({ + headerTitle: "Catch-up thread", + title: "Catch-up thread", + headerBackVisible: true, + unstable_headerRightItems: () => items, + unstable_headerSubtitle: "proj · env", + }); + const second = buildNativeStackOptionsSignature({ + headerTitle: "Catch-up thread", + title: "Catch-up thread", + headerBackVisible: true, + unstable_headerRightItems: () => items, + unstable_headerSubtitle: "proj · env", + }); + + expect(first).toBe(second); + expect(first.length).toBeGreaterThan(0); + }); + + it("changes when title or item content changes", () => { + const base = buildNativeStackOptionsSignature({ + headerTitle: "A", + unstable_headerRightItems: () => [{ identifier: "a", type: "button" }], + }); + const renamed = buildNativeStackOptionsSignature({ + headerTitle: "B", + unstable_headerRightItems: () => [{ identifier: "a", type: "button" }], + }); + const reitemed = buildNativeStackOptionsSignature({ + headerTitle: "A", + unstable_headerRightItems: () => [{ identifier: "b", type: "button" }], + }); + + expect(renamed).not.toBe(base); + expect(reitemed).not.toBe(base); + }); + + it("ignores function-only differences inside header items", () => { + const withPressA = buildNativeStackOptionsSignature({ + unstable_headerRightItems: () => [ + { + identifier: "x", + onPress: () => "a", + type: "button", + }, + ], + }); + const withPressB = buildNativeStackOptionsSignature({ + unstable_headerRightItems: () => [ + { + identifier: "x", + onPress: () => "b", + type: "button", + }, + ], + }); + + expect(withPressA).toBe(withPressB); + }); +}); diff --git a/apps/mobile/src/native/nativeStackOptionsSignature.ts b/apps/mobile/src/native/nativeStackOptionsSignature.ts new file mode 100644 index 00000000000..e39ec462e86 --- /dev/null +++ b/apps/mobile/src/native/nativeStackOptionsSignature.ts @@ -0,0 +1,52 @@ +/** + * Structural signature for native stack screen options so callers can skip + * setOptions when they pass a fresh object/function identity with the same + * content. Unstable setOptions loops re-enter PreventRemoveProvider and crash + * Release builds with "Maximum update depth exceeded". + */ +export function buildNativeStackOptionsSignature(options: unknown): string { + if (options === undefined || options === null || typeof options !== "object") { + return ""; + } + const record = options as Record; + return stableJsonStringify({ + headerBackVisible: record.headerBackVisible ?? null, + headerSearchBarOptions: record.headerSearchBarOptions ?? null, + headerTintColor: record.headerTintColor === undefined ? null : String(record.headerTintColor), + headerTitle: record.headerTitle ?? null, + headerTitleStyle: record.headerTitleStyle ?? null, + title: record.title ?? null, + unstable_headerCenterItems: invokeHeaderItemsFactory(record.unstable_headerCenterItems), + unstable_headerLeftItems: invokeHeaderItemsFactory(record.unstable_headerLeftItems), + unstable_headerRightItems: invokeHeaderItemsFactory(record.unstable_headerRightItems), + unstable_headerSubtitle: record.unstable_headerSubtitle ?? null, + unstable_headerToolbarItems: invokeHeaderItemsFactory(record.unstable_headerToolbarItems), + }); +} + +function invokeHeaderItemsFactory(value: unknown): unknown { + if (typeof value !== "function") { + return value ?? null; + } + try { + return (value as () => unknown)(); + } catch { + return "[header-items-threw]"; + } +} + +function stableJsonStringify(value: unknown): string { + try { + return JSON.stringify(value, (_key, entry) => { + if (typeof entry === "function") { + return "[fn]"; + } + if (typeof entry === "symbol") { + return String(entry); + } + return entry; + }); + } catch { + return "[unserializable]"; + } +} diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index bdeebd101de..1e368b91744 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,6 +15,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { addBreadcrumb } from "../lib/breadcrumbs"; import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; @@ -336,6 +337,13 @@ export function useThreadOutboxDrain(): void { } beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + addBreadcrumb("outbox.dispatch", { + action: deliveryAction, + creation: creation !== undefined, + environmentId: nextQueuedMessage.environmentId, + messageId: nextQueuedMessage.messageId, + threadId: nextQueuedMessage.threadId, + }); const removeQueuedMessage = (warning: string) => removeThreadOutboxMessage(nextQueuedMessage).then( () => true, @@ -361,6 +369,11 @@ export function useThreadOutboxDrain(): void { : Promise.resolve(false); void delivery .then((sent) => { + addBreadcrumb("outbox.result", { + messageId: nextQueuedMessage.messageId, + sent, + threadId: nextQueuedMessage.threadId, + }); if (sent) { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); From f347d3e05aad99b560b74496c908aaf4520c28c0 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 18:58:10 -0400 Subject: [PATCH 05/16] fix(mobile): Satisfy SwiftLint opening_brace in T3CrashLog CI Mobile Native Static Analysis failed on a multi-line if where the opening brace was on the following line. --- apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift index 0bbed06a394..aa6eca69707 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift @@ -134,8 +134,7 @@ public enum T3CrashLog { } if JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value, options: []), - let text = String(data: data, encoding: .utf8) - { + let text = String(data: data, encoding: .utf8) { return text } return String(describing: value) From de8b9f6304ac37df1c2af32d9618e8267fbd1afe Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 21:27:30 -0400 Subject: [PATCH 06/16] fix(client-runtime): Heal shell archive membership on reconnect Warm-cache afterSequence resume can skip a dropped thread.archived delta after later events advance snapshotSequence, leaving archived threads on the home list. Always HTTP-heal (or force a full socket snapshot) before resuming live shell deltas, and reject stale full snapshots from the stream. --- .../src/state/shell-sync.test.ts | 207 +++++++++++++++++- packages/client-runtime/src/state/shell.ts | 91 +++++--- 2 files changed, 258 insertions(+), 40 deletions(-) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index a058f852184..972f8123960 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -217,12 +217,35 @@ describe("environment shell synchronization", () => { }), ); - it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => + it.effect("heals warm cache membership from HTTP before resuming afterSequence", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], - threads: [], + threads: [ + { + id: "thread-1" as never, + projectId: "project-1" as never, + title: "Stale active", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + } as never, + ], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + // Server already archived the thread, but a high-sequence warm cache still + // lists it as active because the archive delta was dropped earlier. + const httpSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 4, + projects: [], + threads: [ + { + ...(cachedSnapshot.threads[0] as object), + archivedAt: "2026-06-02T00:00:00.000Z", + } as never, + ], updatedAt: "2026-06-06T00:00:00.000Z", }; const events = yield* Queue.unbounded(); @@ -263,22 +286,192 @@ describe("environment shell synchronization", () => { }); const snapshotLoader = ShellSnapshotLoader.of({ load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), + SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( + Effect.as(Option.some(httpSnapshot)), + ), }); - yield* makeEnvironmentShellState().pipe( + const shellState = 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, ); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + Option.isSome(state.snapshot) && state.snapshot.value.threads[0]?.archivedAt !== null, + ), + Stream.runHead, + ); + + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(4); + const snapshot = Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot); + expect(snapshot.threads[0]?.archivedAt).toBe("2026-06-02T00:00:00.000Z"); + }), + ); + + it.effect("forces a full socket snapshot when HTTP heal fails with a warm cache", () => + Effect.gen(function* () { + const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 50, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const events = yield* Queue.unbounded(); + const capturedAfterSequence = yield* SubscriptionRef.make( + "missing", + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => + Stream.unwrap( + SubscriptionRef.set( + capturedAfterSequence, + input.afterSequence === undefined ? undefined : 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, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => Effect.succeed(Option.none()), + }); + yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + yield* SubscriptionRef.changes(capturedAfterSequence).pipe( + Stream.filter((value) => value !== "missing"), + Stream.runHead, + ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); + // Cache alone must not drive afterSequence; socket should send a full snapshot. + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBeUndefined(); + }), + ); + + it.effect("rejects a stale full snapshot after a newer archive upsert", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => 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.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + 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), + ); + + const liveSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 2, + projects: [], + threads: [ + { + id: "thread-1" as never, + projectId: "project-1" as never, + title: "Live", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + } as never, + ], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + yield* Queue.offer(events, { kind: "snapshot", snapshot: liveSnapshot }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + const archivedThread = { + ...(liveSnapshot.threads[0] as object), + archivedAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + } as never; + yield* Queue.offer(events, { + kind: "thread-upserted", + sequence: 3, + thread: archivedThread, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 3, + ), + Stream.runHead, + ); + + // Older full snapshot that still lists the thread as active must not win. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: liveSnapshot, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + const state = yield* SubscriptionRef.get(shellState); + const snapshot = Option.getOrThrow(state.snapshot); + expect(snapshot.snapshotSequence).toBe(3); + expect(snapshot.threads[0]?.archivedAt).toBe("2026-06-03T00:00:00.000Z"); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 9036ff5289e..128fb3003c8 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -121,11 +121,21 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( item: OrchestrationShellStreamItem, + options?: { readonly authoritative?: boolean }, ) { const current = yield* SubscriptionRef.get(state); const nextSnapshot = item.kind === "snapshot" - ? item.snapshot + ? Option.match(current.snapshot, { + // Reject older full snapshots from the live stream so a slow + // enrichment refresh cannot reintroduce archived threads. HTTP + // reconnect heals use authoritative:true and always apply. + onSome: (snapshot) => + !options?.authoritative && item.snapshot.snapshotSequence < snapshot.snapshotSequence + ? null + : item.snapshot, + onNone: () => item.snapshot, + }) : Option.match(current.snapshot, { onNone: () => null, onSome: (snapshot) => @@ -147,41 +157,56 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") 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 }); + // Paint immediately from disk cache when available, then always heal from + // the HTTP shell snapshot before (and on every) live session. + // + // Why HTTP heal is required even with a warm cache: + // If an archive delta is dropped, later unrelated shell events still + // advance snapshotSequence. Resuming only via afterSequence then skips + // the archive forever and keeps the thread on the home list. + if (Option.isSome(cachedSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: cachedSnapshot.value }); } - const subscribeInput = Option.match(base, { - onNone: () => ({}), - onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }), - }); + yield* SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: () => + Stream.unwrap( + Effect.gen(function* () { + // Never resume afterSequence from disk cache alone. A dropped + // archive delta plus later events advances snapshotSequence + // past the archive, so delta-only resume keeps the thread on + // the home list forever. Require a server heal first: + // HTTP full snapshot, or a socket-embedded full snapshot. + let healedFromServer = false; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isSome(prepared)) { + const httpSnapshot = yield* snapshotLoader.load(prepared.value); + if (Option.isSome(httpSnapshot)) { + yield* applyItem( + { kind: "snapshot", snapshot: httpSnapshot.value }, + { authoritative: true }, + ); + healedFromServer = true; + } + } - yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { - onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), - }).pipe(Stream.runForEach(applyItem)); + const live = yield* SubscriptionRef.get(state); + const subscribeInput = + healedFromServer && Option.isSome(live.snapshot) + ? { afterSequence: live.snapshot.value.snapshotSequence } + : {}; + return subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { + onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), + }); + }), + ), + }), + ), + Stream.runForEach((item) => applyItem(item)), + ); }), ); yield* SubscriptionRef.changes(supervisor.state).pipe( From 666bf0a8647f73b389a13c3b071f6a17ad2eb7b3 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 22:18:48 -0400 Subject: [PATCH 07/16] fix(client-runtime): Apply socket shell heal authoritatively When HTTP shell heal fails, the full socket snapshot is the membership source of truth. Accept that first snapshot even if its sequence is behind the warm disk cache so ghost active threads cannot stick. --- .../src/state/shell-sync.test.ts | 40 ++++++++++++++++++- packages/client-runtime/src/state/shell.ts | 21 +++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 972f8123960..0a60d47a955 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -317,10 +317,19 @@ describe("environment shell synchronization", () => { it.effect("forces a full socket snapshot when HTTP heal fails with a warm cache", () => Effect.gen(function* () { + const staleThread = { + id: "thread-stale" as never, + projectId: "project-1" as never, + title: "Ghost active", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + } as never; const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 50, projects: [], - threads: [], + threads: [staleThread], updatedAt: "2026-06-06T00:00:00.000Z", }; const events = yield* Queue.unbounded(); @@ -364,7 +373,7 @@ describe("environment shell synchronization", () => { const snapshotLoader = ShellSnapshotLoader.of({ load: () => Effect.succeed(Option.none()), }); - yield* makeEnvironmentShellState().pipe( + const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), @@ -377,6 +386,33 @@ describe("environment shell synchronization", () => { // Cache alone must not drive afterSequence; socket should send a full snapshot. expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBeUndefined(); + + // Server membership is correct but sequence is behind the warm cache. + // The socket heal must still apply (authoritative) or ghost threads remain. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { + snapshotSequence: 40, + projects: [], + threads: [ + { + ...(staleThread as object), + archivedAt: "2026-06-02T00:00:00.000Z", + } as never, + ], + updatedAt: "2026-06-06T00:00:00.000Z", + }, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + Option.isSome(state.snapshot) && state.snapshot.value.threads[0]?.archivedAt !== null, + ), + Stream.runHead, + ); + const snapshot = Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot); + expect(snapshot.snapshotSequence).toBe(40); + expect(snapshot.threads[0]?.archivedAt).toBe("2026-06-02T00:00:00.000Z"); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 128fb3003c8..f724dd7e3bd 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -9,6 +9,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -67,6 +68,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: shellStatusForSnapshot(cachedSnapshot), error: Option.none(), }); + // When HTTP heal fails we subscribe without afterSequence so the server + // embeds a full snapshot. That first snapshot must apply even if its sequence + // is behind the warm disk cache (authoritative server membership). + const acceptNextSocketSnapshotAuthoritatively = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -198,6 +203,9 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") healedFromServer && Option.isSome(live.snapshot) ? { afterSequence: live.snapshot.value.snapshotSequence } : {}; + if (!healedFromServer) { + yield* Ref.set(acceptNextSocketSnapshotAuthoritatively, true); + } return subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), }); @@ -205,7 +213,18 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), }), ), - Stream.runForEach((item) => applyItem(item)), + Stream.runForEach((item) => + Effect.gen(function* () { + if (item.kind === "snapshot") { + const acceptAuthoritative = yield* Ref.get(acceptNextSocketSnapshotAuthoritatively); + if (acceptAuthoritative) { + yield* Ref.set(acceptNextSocketSnapshotAuthoritatively, false); + return yield* applyItem(item, { authoritative: true }); + } + } + return yield* applyItem(item); + }), + ), ); }), ); From 292609f40e581edb39759625670f0c70a7a1e1d2 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 22:27:23 -0400 Subject: [PATCH 08/16] fix(client-runtime): Clear socket-authoritative flag after HTTP heal A prior session can arm acceptNextSocketSnapshotAuthoritatively when HTTP heal fails and then disconnect before the socket snapshot arrives. Clear the flag on a successful HTTP heal so a later reconnect does not treat stale enrichment snapshots as authoritative. --- .../src/state/shell-sync.test.ts | 127 ++++++++++++++++++ packages/client-runtime/src/state/shell.ts | 4 + 2 files changed, 131 insertions(+) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0a60d47a955..6860750812d 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -416,6 +417,132 @@ describe("environment shell synchronization", () => { }), ); + it.effect("clears socket-authoritative flag after a successful HTTP heal on reconnect", () => + Effect.gen(function* () { + const staleThread = { + id: "thread-stale" as never, + projectId: "project-1" as never, + title: "Ghost active", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + } as never; + const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 50, + projects: [], + threads: [staleThread], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const httpHealed: OrchestrationShellSnapshot = { + snapshotSequence: 60, + projects: [], + threads: [ + { + ...(staleThread as object), + archivedAt: "2026-06-02T00:00:00.000Z", + } as never, + ], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const events = yield* Queue.unbounded(); + const subscribeCount = yield* Ref.make(0); + const capturedAfterSequence = yield* SubscriptionRef.make( + "missing", + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => + Stream.unwrap( + Ref.update(subscribeCount, (count) => count + 1).pipe( + Effect.andThen( + SubscriptionRef.set( + capturedAfterSequence, + input.afterSequence === undefined ? undefined : input.afterSequence, + ), + ), + 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, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // First session: HTTP fails (sets socket-authoritative). Second: HTTP heals. + const loaderCalls = yield* Ref.make(0); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => + Ref.update(loaderCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Ref.get(loaderCalls)), + Effect.map((count) => (count === 1 ? Option.none() : Option.some(httpHealed))), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + // First subscribe: HTTP failed, no afterSequence, flag armed. + yield* SubscriptionRef.changes(capturedAfterSequence).pipe( + Stream.filter((value) => value !== "missing"), + Stream.runHead, + ); + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBeUndefined(); + expect(yield* Ref.get(subscribeCount)).toBe(1); + + // Disconnect without delivering a socket snapshot, then reconnect. + yield* SubscriptionRef.set(activeSession, Option.none()); + yield* SubscriptionRef.set(capturedAfterSequence, "missing"); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + yield* SubscriptionRef.changes(capturedAfterSequence).pipe( + Stream.filter((value) => value !== "missing"), + Stream.runHead, + ); + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(60); + expect(yield* Ref.get(loaderCalls)).toBe(2); + + // Stale enrichment-style snapshot must not override the HTTP heal. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { + snapshotSequence: 40, + projects: [], + threads: [staleThread], + updatedAt: "2026-06-06T00:00:00.000Z", + }, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + const snapshot = Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot); + expect(snapshot.snapshotSequence).toBe(60); + expect(snapshot.threads[0]?.archivedAt).toBe("2026-06-02T00:00:00.000Z"); + }), + ); + it.effect("rejects a stale full snapshot after a newer archive upsert", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index f724dd7e3bd..e68c3ebf667 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -195,6 +195,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") { authoritative: true }, ); healedFromServer = true; + // Clear any leftover flag from a prior session that + // failed HTTP heal and disconnected before its socket + // snapshot arrived. + yield* Ref.set(acceptNextSocketSnapshotAuthoritatively, false); } } From 07c6c32c1788b91d903cef5a171342712e6c46d2 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 22:40:53 -0400 Subject: [PATCH 09/16] test(server): Harden CursorAdapter log wait for CI load waitForJsonLogMatch only yieldNow'd between attempts, so under busy CI it could return before the ACP mock agent wrote session/cancel. Use a real wall-clock delay (not TestClock sleep) and fail explicitly on timeout. --- .../src/provider/Layers/CursorAdapter.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 8e5c004b459..97009f6a948 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -108,7 +108,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { return raw; } } catch {} - await Effect.runPromise(Effect.yieldNow); + await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error(`Timed out waiting for file content at ${filePath}`); } @@ -124,9 +124,18 @@ function waitForJsonLogMatch( if (requests.some(predicate)) { return requests; } - yield* Effect.yieldNow; + // Wall-clock wait (not Effect.sleep): these tests run under TestClock, and + // ACP mock agent log writes are real process I/O. yieldNow alone can + // exhaust attempts under CI load before the file is updated. + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))); } - return yield* Effect.promise(() => readJsonLines(filePath)); + const requests = yield* Effect.promise(() => readJsonLines(filePath)); + if (!requests.some(predicate)) { + return yield* Effect.fail( + new Error(`Timed out waiting for matching JSON log entry at ${filePath}`), + ); + } + return requests; }); } From 7b2a0a3d6a3cdf96c0566e0eca7f1c80c6b145b0 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 22:46:00 -0400 Subject: [PATCH 10/16] test(server): Use wall-clock waits in CursorAdapter log polling CI Check treats setTimeout as an Effect lint error. Keep wall-clock delays via a diagnostics-scoped helper, fail with a tagged timeout, and avoid TestClock-bound Effect.sleep so ACP mock log polls still work under load. --- .../src/provider/Layers/CursorAdapter.test.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 97009f6a948..8cda6323707 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -100,6 +100,20 @@ async function readJsonLines(filePath: string) { .map((line) => JSON.parse(line) as Record); } +// Wall-clock delay for real process I/O under TestClock (Effect.sleep is virtual). +// @effect-diagnostics globalTimers:off +const wallClockDelay = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); + +class WaitForJsonLogTimeout extends Schema.TaggedErrorClass()( + "WaitForJsonLogTimeout", + { filePath: Schema.String }, +) { + override get message(): string { + return `Timed out waiting for matching JSON log entry at ${this.filePath}`; + } +} + async function waitForFileContent(filePath: string, attempts = 40) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { @@ -108,7 +122,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { return raw; } } catch {} - await new Promise((resolve) => setTimeout(resolve, 25)); + await Effect.runPromise(wallClockDelay(25)); } throw new Error(`Timed out waiting for file content at ${filePath}`); } @@ -124,16 +138,13 @@ function waitForJsonLogMatch( if (requests.some(predicate)) { return requests; } - // Wall-clock wait (not Effect.sleep): these tests run under TestClock, and - // ACP mock agent log writes are real process I/O. yieldNow alone can - // exhaust attempts under CI load before the file is updated. - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))); + // Real process I/O under TestClock: yieldNow alone can exhaust attempts + // under CI load before the ACP mock agent log is updated. + yield* wallClockDelay(25); } const requests = yield* Effect.promise(() => readJsonLines(filePath)); if (!requests.some(predicate)) { - return yield* Effect.fail( - new Error(`Timed out waiting for matching JSON log entry at ${filePath}`), - ); + return yield* new WaitForJsonLogTimeout({ filePath }); } return requests; }); From ed1a5f269c44b5cec852e9b2441b313ff2fcf000 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 12 Jul 2026 22:59:12 -0400 Subject: [PATCH 11/16] fix(mobile): Keep threadScreenOptions useMemo above early returns Move the header options memo before the missing environmentId, threadId, or selectedThread guards so hook order stays stable if those become null on a re-render of a mounted ThreadRouteContent instance. --- .../features/threads/ThreadRouteScreen.tsx | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index a1311ea1118..65f51be33f5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -827,6 +827,56 @@ function ThreadRouteContent( [navigation], ); + // Keep hooks above early returns so hook order stays stable if route params + // or selectedThread go null on a re-render of this mounted instance. + const selectedThreadTitle = selectedThread?.title ?? ""; + const threadScreenOptions = useMemo( + () => ({ + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", + headerTitle: selectedThreadTitle, + headerTitleStyle: usesNativeHeaderGlass + ? ({ + fontSize: 17, + fontWeight: "800" as const, + } as const) + : undefined, + title: selectedThreadTitle, + headerBackVisible: !layout.usesSplitView, + // Compact uses the NATIVE back button when a previous route exists; + // deep links / cold starts get an explicit Home button instead. + // Split view always uses its custom left items. + unstable_headerLeftItems: + Platform.OS === "ios" + ? layout.usesSplitView + ? () => splitLeftHeaderItems + : canGoBack + ? undefined + : () => compactHomeHeaderItems + : undefined, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: + Platform.OS === "ios" + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + : undefined, + unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + }), + [ + canGoBack, + compactHomeHeaderItems, + compactRightHeaderItems, + headerSubtitle, + layout.usesSplitView, + selectedThreadTitle, + splitLeftHeaderItems, + threadCenterHeaderItems, + usesNativeHeaderGlass, + ], + ); + if (!environmentId || !threadId) { return ; } @@ -895,53 +945,6 @@ function ThreadRouteContent( ); - const threadScreenOptions = useMemo( - () => ({ - // Android draws its own in-flow header (AndroidScreenHeader below); - // the native stack header stays iOS-only. - headerShown: Platform.OS !== "android", - headerTitle: selectedThread.title, - headerTitleStyle: usesNativeHeaderGlass - ? ({ - fontSize: 17, - fontWeight: "800" as const, - } as const) - : undefined, - title: selectedThread.title, - headerBackVisible: !layout.usesSplitView, - // Compact uses the NATIVE back button when a previous route exists; - // deep links / cold starts get an explicit Home button instead. - // Split view always uses its custom left items. - unstable_headerLeftItems: - Platform.OS === "ios" - ? layout.usesSplitView - ? () => splitLeftHeaderItems - : canGoBack - ? undefined - : () => compactHomeHeaderItems - : undefined, - // Search lives in the persistent sidebar, so the split header keeps - // the git controls on the RIGHT (no center items — center space is - // reserved for future breadcrumbs/status). - unstable_headerRightItems: - Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) - : undefined, - unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, - }), - [ - canGoBack, - compactHomeHeaderItems, - compactRightHeaderItems, - headerSubtitle, - layout.usesSplitView, - selectedThread.title, - splitLeftHeaderItems, - threadCenterHeaderItems, - usesNativeHeaderGlass, - ], - ); - return ( <> {activeInspectorRenderer ? : null} From 4311bebf496fc905fb90f938bd1106e83b472a8d Mon Sep 17 00:00:00 2001 From: Twistedgrim Date: Mon, 13 Jul 2026 16:05:52 -0500 Subject: [PATCH 12/16] Confirm resumed thread subscriptions (#2) ## Summary - Add a synchronized stream item after a resumed thread subscription has attached its live buffer. - Keep a warm cached thread synchronizing through reconnect until that item arrives. ## Notes Squash-merge of twistedgrim's PR onto ios-fixes-main. Follow-up gate commit will land separately with capability negotiation and marker-after-catch-up ordering. --- apps/server/src/server.test.ts | 24 ++++++ apps/server/src/ws.ts | 9 ++- .../src/state/threads-sync.test.ts | 73 +++++++++++-------- packages/client-runtime/src/state/threads.ts | 13 +++- packages/contracts/src/orchestration.ts | 5 ++ 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..5ff0cf61351 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5607,6 +5607,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("confirms a resumed thread subscription when no replay events exist", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const firstItem = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + afterSequence: 0, + }).pipe(Stream.runHead), + ), + ); + + assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..93caf240ff2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1104,7 +1104,14 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); + // A warm client already has the full snapshot and might have no + // replay events to apply. Emit a lightweight marker once its live + // subscription is attached so it can leave the syncing state + // without retransmitting the full thread body. + return Stream.concat( + Stream.make({ kind: "synchronized" as const }), + Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)), + ); }), ); } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 1c76f0f171f..aa5e7ef124a 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -77,6 +77,10 @@ const BASE_THREAD: OrchestrationThread = { type TestThreadInput = OrchestrationThreadStreamItem | Error; +function synchronized(): OrchestrationThreadStreamItem { + return { kind: "synchronized" }; +} + function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, @@ -484,38 +488,47 @@ describe("EnvironmentThreads", () => { }), ); - it.effect("restores live status after reconnect when no new thread events arrive", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ cached: BASE_THREAD }); - yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + it.effect( + "waits for stream synchronization after reconnect when no new thread events arrive", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "synchronizing"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } - yield* SubscriptionRef.set(harness.supervisorState, { - desired: true, - network: "online", - phase: "connecting", - stage: "synchronizing", - attempt: 2, - generation: 1, - lastFailure: null, - retryAt: null, - }); - yield* awaitThreadState(harness.observed, (value) => value.status === "synchronizing"); + expect((yield* Ref.get(harness.latest)).status).toBe("synchronizing"); - yield* SubscriptionRef.set(harness.supervisorState, { - desired: true, - network: "online", - phase: "connected", - stage: null, - attempt: 2, - generation: 2, - lastFailure: null, - retryAt: null, - }); - yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + yield* Queue.offer(harness.inputs, synchronized()); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); - const latest = yield* Ref.get(harness.latest); - expect(latest.status).toBe("live"); - expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); - }), + const latest = yield* Ref.get(harness.latest); + expect(latest.status).toBe("live"); + expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); + }), ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 2acbb58326e..5e7719bde91 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -99,11 +99,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make error: Option.none(), })); const setReady = SubscriptionRef.update(state, (current) => - current.status === "deleted" + current.status === "live" || current.status === "deleted" ? current : { ...current, - status: Option.isSome(current.data) ? ("live" as const) : ("synchronizing" as const), + status: "synchronizing" as const, error: Option.none(), }, ); @@ -154,6 +154,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( item: OrchestrationThreadStreamItem, ) { + if (item.kind === "synchronized") { + yield* SubscriptionRef.update(state, (current) => + Option.isSome(current.data) && current.status !== "deleted" + ? { ...current, status: "live" as const, error: Option.none() } + : current, + ); + return; + } + if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..66896307e9b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1135,6 +1135,11 @@ export const OrchestrationEvent = Schema.Union([ export type OrchestrationEvent = typeof OrchestrationEvent.Type; export const OrchestrationThreadStreamItem = Schema.Union([ + // Confirms that a resumed detail subscription is live even when no events + // were emitted after the client's cached snapshot sequence. + Schema.Struct({ + kind: Schema.Literal("synchronized"), + }), Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: OrchestrationThreadDetailSnapshot, From 73c2c482b6de77da1324bce3831984c59d4f0403 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 13 Jul 2026 16:06:22 -0400 Subject: [PATCH 13/16] fix(orchestration): Gate thread resume sync marker after catch-up Emit the completion marker only on subscribeThread when clients opt in, after catch-up replay, and keep warm threads synchronizing until it arrives. Advertise support via ServerConfig so old servers still use the ready fallback and old clients never see an unknown stream kind. --- apps/server/src/server.test.ts | 30 ++++ apps/server/src/ws.ts | 22 ++- .../src/state/threads-sync.test.ts | 169 +++++++++++++++++- packages/client-runtime/src/state/threads.ts | 86 +++++++-- packages/contracts/src/orchestration.ts | 8 + packages/contracts/src/server.ts | 6 + 6 files changed, 289 insertions(+), 32 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5ff0cf61351..56024aa736b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5623,6 +5623,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { client[ORCHESTRATION_WS_METHODS.subscribeThread]({ threadId: defaultThreadId, afterSequence: 0, + requestCompletionMarker: true, }).pipe(Stream.runHead), ), ); @@ -5631,6 +5632,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("omits the thread completion marker when the client does not request it", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + // Empty catch-up without a requested marker never emits a stream head. + // Live clock so the race timeout advances while the WS live buffer stays open. + const outcome = yield* Effect.race( + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + afterSequence: 0, + }).pipe(Stream.runHead, Effect.as("item" as const)), + ), + ), + Effect.sleep("200 millis").pipe(Effect.as("timeout" as const)), + ); + + assert.equal(outcome, "timeout"); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 93caf240ff2..699bf939bf4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -935,6 +935,7 @@ const makeWsRpcLayer = ( otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + threadResumeCompletionMarker: true, }; }); @@ -1104,14 +1105,7 @@ const makeWsRpcLayer = ( }), ), ); - // A warm client already has the full snapshot and might have no - // replay events to apply. Emit a lightweight marker once its live - // subscription is attached so it can leave the syncing state - // without retransmitting the full thread body. - return Stream.concat( - Stream.make({ kind: "synchronized" as const }), - Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)), - ); + return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); }), ); } @@ -1211,7 +1205,17 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); + // Opt-in completion marker after catch-up so warm clients can leave + // syncing without a full body retransmit, including when replay is empty. + // Emit only when requested so older clients never see an unknown kind. + const afterCatchUp = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.make({ kind: "synchronized" as const }), + Stream.fromQueue(liveBuffer), + ) + : Stream.fromQueue(liveBuffer); + return Stream.concat(catchUpStream, afterCatchUp); }), ); } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index aa5e7ef124a..d93316cdd83 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -81,10 +81,17 @@ function synchronized(): OrchestrationThreadStreamItem { return { kind: "synchronized" }; } -function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { +function testSession( + client: WsRpcProtocolClient, + options?: { readonly threadResumeCompletionMarker?: boolean }, +): RpcSession.RpcSession { + const initialConfig = + options?.threadResumeCompletionMarker === true + ? Effect.succeed({ threadResumeCompletionMarker: true } as never) + : Effect.succeed({} as never); return { client, - initialConfig: Effect.never, + initialConfig, ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -105,6 +112,7 @@ function awaitThreadState( const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { readonly cached?: OrchestrationThread; readonly httpSnapshot?: Option.Option; + readonly threadResumeCompletionMarker?: boolean; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); @@ -113,6 +121,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const subscriptionCount = yield* Ref.make(0); const loaderCalls = yield* Ref.make(0); const lastSubscribeAfterSequence = yield* Ref.make(undefined); + const lastRequestCompletionMarker = yield* Ref.make(undefined); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); const supervisorState = yield* SubscriptionRef.make( @@ -125,16 +134,27 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), ); const client = { - [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)), + Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)), Effect.as(streamFrom(inputs)), ), ), } as unknown as WsRpcProtocolClient; const supervisorSession = yield* SubscriptionRef.make>( - Option.some(testSession(client)), + Option.some( + testSession( + client, + options?.threadResumeCompletionMarker === true + ? { threadResumeCompletionMarker: true } + : undefined, + ), + ), ); const prepared = yield* SubscriptionRef.make>( Option.some(PREPARED), @@ -200,11 +220,22 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o subscriptionCount, loaderCalls, lastSubscribeAfterSequence, + lastRequestCompletionMarker, supervisorState, supervisorSession, savedThreads, removedThreads, - replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))), + replaceSession: SubscriptionRef.set( + supervisorSession, + Option.some( + testSession( + client, + options?.threadResumeCompletionMarker === true + ? { threadResumeCompletionMarker: true } + : undefined, + ), + ), + ), }; }); @@ -489,11 +520,12 @@ describe("EnvironmentThreads", () => { ); it.effect( - "waits for stream synchronization after reconnect when no new thread events arrive", + "restores live status after reconnect when the server does not advertise completion markers", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBeUndefined(); yield* SubscriptionRef.set(harness.supervisorState, { desired: true, @@ -517,10 +549,48 @@ describe("EnvironmentThreads", () => { lastFailure: null, retryAt: null, }); - for (let index = 0; index < 10; index += 1) { + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + const latest = yield* Ref.get(harness.latest); + expect(latest.status).toBe("live"); + expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); + }), + ); + + it.effect( + "waits for the stream marker after reconnect when the server advertises completion markers", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_THREAD, + threadResumeCompletionMarker: true, + }); + // Marker mode keeps the warm base as synchronizing until the stream confirms. + yield* awaitThreadState( + harness.observed, + (value) => value.status === "synchronizing" && Option.isSome(value.data), + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 1) { + break; + } yield* Effect.yieldNow; } + expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true); + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } expect((yield* Ref.get(harness.latest)).status).toBe("synchronizing"); yield* Queue.offer(harness.inputs, synchronized()); @@ -531,4 +601,89 @@ describe("EnvironmentThreads", () => { expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); }), ); + + it.effect("keeps catch-up updates synchronizing until the completion marker arrives", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_THREAD, + threadResumeCompletionMarker: true, + }); + yield* awaitThreadState( + harness.observed, + (value) => value.status === "synchronizing" && Option.isSome(value.data), + ); + + yield* Queue.offer( + harness.inputs, + titleUpdated("Catch-up title", CACHED_SNAPSHOT_SEQUENCE + 1), + ); + yield* awaitThreadState( + harness.observed, + (value) => + value.status === "synchronizing" && + Option.isSome(value.data) && + value.data.value.title === "Catch-up title", + ); + + yield* Queue.offer(harness.inputs, synchronized()); + yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Catch-up title", + ); + }), + ); + + it.effect( + "recovers to live via the completion marker after an in-band expected failure retry", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_THREAD, + threadResumeCompletionMarker: true, + }); + yield* awaitThreadState( + harness.observed, + (value) => value.status === "synchronizing" && Option.isSome(value.data), + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 1) { + break; + } + yield* Effect.yieldNow; + } + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + + yield* Queue.offer(harness.inputs, new Error("transient replay failure")); + yield* awaitThreadState( + harness.observed, + (value) => value.status === "cached" && Option.isSome(value.error), + ); + + yield* TestClock.adjust("250 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.subscriptionCount)).toBeGreaterThanOrEqual(2); + + yield* Queue.offer(harness.inputs, synchronized()); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + expect(Option.isNone((yield* Ref.get(harness.latest)).error)).toBe(true); + }), + ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 5e7719bde91..f96c7ebead4 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -10,6 +10,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -70,6 +71,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const persistence = yield* Queue.sliding(1); + // When the server advertises threadResumeCompletionMarker and we request it on + // resume, keep status synchronizing until the stream marker arrives (including + // through catch-up events). Legacy servers never set this path. + const awaitingCompletion = yield* Ref.make(false); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( snapshot: OrchestrationThreadDetailSnapshot, @@ -98,32 +103,54 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "synchronizing" as const, error: Option.none(), })); - const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" - ? current - : { + const setReady = Effect.gen(function* () { + const waiting = yield* Ref.get(awaitingCompletion); + yield* SubscriptionRef.update(state, (current) => { + if (current.status === "deleted") { + return current; + } + if (waiting) { + return { ...current, status: "synchronizing" as const, error: Option.none(), - }, - ); - const setDisconnected = SubscriptionRef.update(state, (current) => ({ - ...current, - status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - })); - const setStreamError = (cause: Cause.Cause) => - SubscriptionRef.update(state, (current) => ({ + }; + } + return { + ...current, + status: Option.isSome(current.data) ? ("live" as const) : ("synchronizing" as const), + error: Option.none(), + }; + }); + }); + const setDisconnected = Effect.gen(function* () { + yield* Ref.set(awaitingCompletion, false); + yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - error: Option.some(formatThreadError(cause)), })); + }); + const setStreamError = (cause: Cause.Cause) => + Effect.gen(function* () { + // Resubscribe will re-enter marker wait when this stream is in marker mode. + const waiting = yield* Ref.get(awaitingCompletion); + if (waiting) { + yield* Ref.set(awaitingCompletion, true); + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + error: Option.some(formatThreadError(cause)), + })); + }); const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, ) { + const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.set(state, { data: Option.some(thread), - status: "live", + status: waiting ? ("synchronizing" as const) : ("live" as const), error: Option.none(), }); // Persist the thread together with the sequence it reflects so the next warm @@ -133,6 +160,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { + yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", @@ -155,6 +183,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make item: OrchestrationThreadStreamItem, ) { if (item.kind === "synchronized") { + yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => Option.isSome(current.data) && current.status !== "deleted" ? { ...current, status: "live" as const, error: Option.none() } @@ -232,17 +261,42 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : Option.none(); }); + const session = yield* SubscriptionRef.get(supervisor.session); + const serverSupportsCompletionMarker = Option.isSome(session) + ? yield* session.value.initialConfig.pipe( + Effect.map((config) => config.threadResumeCompletionMarker === true), + Effect.orElseSucceed(() => false), + ) + : false; + const requestCompletionMarker = serverSupportsCompletionMarker && Option.isSome(base); + if (requestCompletionMarker) { + yield* Ref.set(awaitingCompletion, true); + } + if (Option.isSome(base)) { yield* applyItem({ kind: "snapshot", snapshot: base.value }); } const subscribeInput = Option.match(base, { onNone: () => ({ threadId }), - onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }), + onSome: (snapshot) => + requestCompletionMarker + ? { + threadId, + afterSequence: snapshot.snapshotSequence, + requestCompletionMarker: true as const, + } + : { threadId, afterSequence: snapshot.snapshotSequence }, }); yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { - onExpectedFailure: setStreamError, + onExpectedFailure: (cause) => + Effect.gen(function* () { + if (requestCompletionMarker) { + yield* Ref.set(awaitingCompletion, true); + } + yield* setStreamError(cause); + }), retryExpectedFailureAfter: "250 millis", }).pipe(Stream.runForEach(applyItem)); }), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 66896307e9b..20c33939527 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -474,6 +474,14 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * sequence on the client). */ afterSequence: Schema.optionalKey(NonNegativeInt), + /** + * When true and `afterSequence` is set, the server emits `{ kind: "synchronized" }` + * after catch-up replay (including when replay is empty) so the client can leave + * the syncing state without a full snapshot retransmit. Only send when + * `ServerConfig.threadResumeCompletionMarker` is true; older servers ignore + * unknown keys and never emit the marker. + */ + requestCompletionMarker: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..37ad1412137 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -417,6 +417,12 @@ export const ServerConfig = Schema.Struct({ availableEditors: Schema.Array(EditorId), observability: ServerObservability, settings: ServerSettings, + /** + * When true, `orchestration.subscribeThread` resume (`afterSequence`) can emit + * a `{ kind: "synchronized" }` completion marker if the client requests it. + * Absent or false means legacy servers: clients must not wait for the marker. + */ + threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type; From 936f67f50a69e61664c60a848000a203318f5e22 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 13 Jul 2026 16:54:20 -0400 Subject: [PATCH 14/16] fix(client-runtime): Hold shell list until server heal on reconnect Do not paint a warm disk shell snapshot before the HTTP or socket heal. Stale active membership was flashing a larger Home list, then shrinking when the authoritative heal arrived. Disk remains offline/heal fallback. --- .../src/state/shell-sync.test.ts | 87 +++++++++++++++++++ packages/client-runtime/src/state/shell.ts | 62 ++++++++++--- 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 6860750812d..821d9868f9b 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -5,6 +5,7 @@ import { type OrchestrationShellStreamItem, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -316,6 +317,92 @@ describe("environment shell synchronization", () => { }), ); + it.effect("does not paint stale disk membership before the HTTP heal arrives", () => + Effect.gen(function* () { + const staleActive = { + id: "thread-1" as never, + projectId: "project-1" as never, + title: "Stale active", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + } as never; + const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 5, + projects: [], + threads: [staleActive], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const httpSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 6, + projects: [], + threads: [ + { + ...(staleActive as object), + archivedAt: "2026-06-02T00:00:00.000Z", + } as never, + ], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const httpReady = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => 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, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => Deferred.await(httpReady).pipe(Effect.as(Option.some(httpSnapshot))), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + // While HTTP heal is in flight, Home must not show the inflated disk list. + for (let index = 0; index < 20; index += 1) { + yield* Effect.yieldNow; + } + expect(Option.isNone((yield* SubscriptionRef.get(shellState)).snapshot)).toBe(true); + + yield* Deferred.succeed(httpReady, undefined); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => + Option.isSome(state.snapshot) && state.snapshot.value.threads[0]?.archivedAt !== null, + ), + Stream.runHead, + ); + const snapshot = Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot); + expect(snapshot.threads[0]?.archivedAt).toBe("2026-06-02T00:00:00.000Z"); + }), + ); + it.effect("forces a full socket snapshot when HTTP heal fails with a warm cache", () => Effect.gen(function* () { const staleThread = { diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index e68c3ebf667..7eb3d447619 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -63,15 +63,22 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), ), ); + // Do not paint disk into the visible snapshot up front. A stale warm cache can + // list more active threads than the server (dropped archive deltas); showing it + // before the HTTP/socket heal balloons Home, then the heal shrinks the list. + // Disk is kept for offline / heal-failure fallback only. const state = yield* SubscriptionRef.make({ - snapshot: cachedSnapshot, - status: shellStatusForSnapshot(cachedSnapshot), + snapshot: Option.none(), + status: "synchronizing", error: Option.none(), }); // When HTTP heal fails we subscribe without afterSequence so the server // embeds a full snapshot. That first snapshot must apply even if its sequence // is behind the warm disk cache (authoritative server membership). const acceptNextSocketSnapshotAuthoritatively = yield* Ref.make(false); + // True after an authoritative server snapshot (HTTP or socket heal) or any + // live delta. While true, non-authoritative disk must not replace the list. + const hasServerBackedSnapshot = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -126,9 +133,17 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( item: OrchestrationShellStreamItem, - options?: { readonly authoritative?: boolean }, + options?: { readonly authoritative?: boolean; readonly fromDisk?: boolean }, ) { const current = yield* SubscriptionRef.get(state); + // Hold the last server-backed (or live) list while reconciling. Disk must + // not overwrite it or Home balloons with stale active membership. + if (options?.fromDisk === true) { + const serverBacked = yield* Ref.get(hasServerBackedSnapshot); + if (serverBacked || Option.isSome(current.snapshot)) { + return; + } + } const nextSnapshot = item.kind === "snapshot" ? Option.match(current.snapshot, { @@ -152,6 +167,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return; } + // Disk fallback is provisional only. Authoritative heals and live deltas + // mark the list as server-backed so later disk paints cannot overwrite it. + if (options?.fromDisk !== true) { + yield* Ref.set(hasServerBackedSnapshot, true); + } + yield* SubscriptionRef.set(state, { snapshot: Option.some(nextSnapshot), status: "live", @@ -160,23 +181,29 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Queue.offer(persistence, nextSnapshot); }); + const applyDiskFallback = Effect.gen(function* () { + if (Option.isNone(cachedSnapshot)) { + return; + } + yield* applyItem({ kind: "snapshot", snapshot: cachedSnapshot.value }, { fromDisk: true }); + }); + yield* Effect.forkScoped( Effect.gen(function* () { - // Paint immediately from disk cache when available, then always heal from - // the HTTP shell snapshot before (and on every) live session. - // // Why HTTP heal is required even with a warm cache: // If an archive delta is dropped, later unrelated shell events still // advance snapshotSequence. Resuming only via afterSequence then skips // the archive forever and keeps the thread on the home list. - if (Option.isSome(cachedSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: cachedSnapshot.value }); - } - + // + // Do not paint disk before that heal when online: stale active membership + // would flash a larger Home list, then shrink when the heal arrives. + // Disk is applied only if the server heal fails and we have nothing to show. yield* SubscriptionRef.changes(supervisor.session).pipe( Stream.switchMap( Option.match({ - onNone: () => Stream.empty, + onNone: () => + // Offline / no session: surface disk if we never got a server list. + Stream.fromEffect(applyDiskFallback).pipe(Stream.drain), onSome: () => Stream.unwrap( Effect.gen(function* () { @@ -185,6 +212,9 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // past the archive, so delta-only resume keeps the thread on // the home list forever. Require a server heal first: // HTTP full snapshot, or a socket-embedded full snapshot. + // + // While reconciling, keep the previous server-backed snapshot + // visible (setSynchronizing only flips status). let healedFromServer = false; const prepared = yield* SubscriptionRef.get(supervisor.prepared); if (Option.isSome(prepared)) { @@ -202,14 +232,18 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } } + if (!healedFromServer) { + // Prefer holding an existing list over painting disk. Disk + // only fills an empty Home when the socket must carry the heal. + yield* applyDiskFallback; + yield* Ref.set(acceptNextSocketSnapshotAuthoritatively, true); + } + const live = yield* SubscriptionRef.get(state); const subscribeInput = healedFromServer && Option.isSome(live.snapshot) ? { afterSequence: live.snapshot.value.snapshotSequence } : {}; - if (!healedFromServer) { - yield* Ref.set(acceptNextSocketSnapshotAuthoritatively, true); - } return subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), }); From 6aabd972b4217851d961f8fdfd84cb990289e7c0 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 13 Jul 2026 18:54:29 -0400 Subject: [PATCH 15/16] fix(client-runtime): Delay shell disk paint and re-arm sync marker Keep Home empty while session is still None during online connect setup; only apply disk fallback from true disconnect. Persist markerMode so thread resume re-arms awaitingCompletion after ordinary reconnects. --- .../src/state/shell-sync.test.ts | 24 +++++- packages/client-runtime/src/state/shell.ts | 74 ++++++++++--------- .../src/state/threads-sync.test.ts | 54 ++++++++++++-- packages/client-runtime/src/state/threads.ts | 32 ++++---- 4 files changed, 126 insertions(+), 58 deletions(-) diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 821d9868f9b..5897159c875 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -17,6 +17,7 @@ import { AVAILABLE_CONNECTION_STATE, PrimaryConnectionTarget, type PreparedConnection, + type SupervisorConnectionState, } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; @@ -350,9 +351,20 @@ describe("environment shell synchronization", () => { const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), } as unknown as WsRpcProtocolClient; - const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + // Normal online connect starts with session:None while the lease opens. + // Disk must not paint during that window either. + const supervisorState = yield* SubscriptionRef.make({ + desired: true, + network: "online", + phase: "connecting", + stage: "opening", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); const activeSession = yield* SubscriptionRef.make>( - Option.some(session(client)), + Option.none(), ); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, @@ -384,12 +396,18 @@ describe("environment shell synchronization", () => { Effect.provideService(ShellSnapshotLoader, snapshotLoader), ); - // While HTTP heal is in flight, Home must not show the inflated disk list. for (let index = 0; index < 20; index += 1) { yield* Effect.yieldNow; } expect(Option.isNone((yield* SubscriptionRef.get(shellState)).snapshot)).toBe(true); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + for (let index = 0; index < 20; index += 1) { + yield* Effect.yieldNow; + } + // Still no disk paint while HTTP heal is in flight. + expect(Option.isNone((yield* SubscriptionRef.get(shellState)).snapshot)).toBe(true); + yield* Deferred.succeed(httpReady, undefined); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter( diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 7eb3d447619..70d4e9f3362 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -102,35 +102,6 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") Effect.forkScoped, ); - const setDisconnected = SubscriptionRef.update(state, (current) => ({ - ...current, - status: shellStatusForSnapshot(current.snapshot), - })); - const setSynchronizing = SubscriptionRef.update(state, (current) => ({ - ...current, - status: "synchronizing" as const, - error: Option.none(), - })); - const setReady = SubscriptionRef.update(state, (current) => ({ - ...current, - status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const), - error: Option.none(), - })); - const setStreamError = (error: unknown) => - Effect.logWarning("Could not synchronize the environment shell.").pipe( - Effect.annotateLogs({ - environmentId, - ...safeErrorLogAttributes(error), - }), - Effect.andThen( - SubscriptionRef.update(state, (current) => ({ - ...current, - status: shellStatusForSnapshot(current.snapshot), - error: Option.some(SHELL_SYNCHRONIZATION_ERROR_MESSAGE), - })), - ), - ); - const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( item: OrchestrationShellStreamItem, options?: { readonly authoritative?: boolean; readonly fromDisk?: boolean }, @@ -188,6 +159,40 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* applyItem({ kind: "snapshot", snapshot: cachedSnapshot.value }, { fromDisk: true }); }); + const setDisconnected = Effect.gen(function* () { + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + })); + // Truly offline/unavailable only: session is often None while connecting, + // so disk must not run off session absence alone. + yield* applyDiskFallback; + }); + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => ({ + ...current, + status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const), + error: Option.none(), + })); + const setStreamError = (error: unknown) => + Effect.logWarning("Could not synchronize the environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.andThen( + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + error: Option.some(SHELL_SYNCHRONIZATION_ERROR_MESSAGE), + })), + ), + ); + yield* Effect.forkScoped( Effect.gen(function* () { // Why HTTP heal is required even with a warm cache: @@ -195,15 +200,14 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // advance snapshotSequence. Resuming only via afterSequence then skips // the archive forever and keeps the thread on the home list. // - // Do not paint disk before that heal when online: stale active membership - // would flash a larger Home list, then shrink when the heal arrives. - // Disk is applied only if the server heal fails and we have nothing to show. + // Do not paint disk when session is still None during online connect + // setup (lease not ready yet). Disk is only applied from setDisconnected + // or when a live session fails HTTP heal and needs a provisional list + // before the socket snapshot. yield* SubscriptionRef.changes(supervisor.session).pipe( Stream.switchMap( Option.match({ - onNone: () => - // Offline / no session: surface disk if we never got a server list. - Stream.fromEffect(applyDiskFallback).pipe(Stream.drain), + onNone: () => Stream.empty, onSome: () => Stream.unwrap( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index d93316cdd83..266e5f48cc6 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -578,27 +578,69 @@ describe("EnvironmentThreads", () => { } expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true); + yield* Queue.offer(harness.inputs, synchronized()); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + // Ordinary transport reconnect: disconnect clears awaitingCompletion; the + // synchronizing phase must re-arm marker wait before setReady runs. yield* SubscriptionRef.set(harness.supervisorState, { desired: true, network: "online", - phase: "connected", + phase: "backoff", stage: null, - attempt: 1, + attempt: 2, generation: 1, lastFailure: null, retryAt: null, }); + yield* awaitThreadState(harness.observed, (value) => value.status === "cached"); + yield* SubscriptionRef.set(harness.supervisorSession, Option.none()); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "synchronizing"); + yield* harness.replaceSession; + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); for (let index = 0; index < 10; index += 1) { yield* Effect.yieldNow; } expect((yield* Ref.get(harness.latest)).status).toBe("synchronizing"); + yield* Queue.offer( + harness.inputs, + titleUpdated("Post-reconnect catch-up", CACHED_SNAPSHOT_SEQUENCE + 1), + ); + yield* awaitThreadState( + harness.observed, + (value) => + value.status === "synchronizing" && + Option.isSome(value.data) && + value.data.value.title === "Post-reconnect catch-up", + ); + yield* Queue.offer(harness.inputs, synchronized()); yield* awaitThreadState(harness.observed, (value) => value.status === "live"); - - const latest = yield* Ref.get(harness.latest); - expect(latest.status).toBe("live"); - expect(Option.getOrThrow(latest.data)).toEqual(BASE_THREAD); + expect(Option.getOrThrow((yield* Ref.get(harness.latest)).data).title).toBe( + "Post-reconnect catch-up", + ); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index f96c7ebead4..791480d8fda 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -74,6 +74,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // When the server advertises threadResumeCompletionMarker and we request it on // resume, keep status synchronizing until the stream marker arrives (including // through catch-up events). Legacy servers never set this path. + // markerMode stays true for the life of this thread state once we opt in; + // awaitingCompletion is re-armed on each reconnect generation. + const markerMode = yield* Ref.make(false); const awaitingCompletion = yield* Ref.make(false); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -98,11 +101,18 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => ({ - ...current, - status: "synchronizing" as const, - error: Option.none(), - })); + const setSynchronizing = Effect.gen(function* () { + // Ordinary reconnect clears awaitingCompletion on disconnect; re-arm here so + // setReady / catch-up setThread keep synchronizing until the new marker. + if (yield* Ref.get(markerMode)) { + yield* Ref.set(awaitingCompletion, true); + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + }); const setReady = Effect.gen(function* () { const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.update(state, (current) => { @@ -133,8 +143,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setStreamError = (cause: Cause.Cause) => Effect.gen(function* () { // Resubscribe will re-enter marker wait when this stream is in marker mode. - const waiting = yield* Ref.get(awaitingCompletion); - if (waiting) { + if (yield* Ref.get(markerMode)) { yield* Ref.set(awaitingCompletion, true); } yield* SubscriptionRef.update(state, (current) => ({ @@ -270,6 +279,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : false; const requestCompletionMarker = serverSupportsCompletionMarker && Option.isSome(base); if (requestCompletionMarker) { + yield* Ref.set(markerMode, true); yield* Ref.set(awaitingCompletion, true); } @@ -290,13 +300,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }); yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { - onExpectedFailure: (cause) => - Effect.gen(function* () { - if (requestCompletionMarker) { - yield* Ref.set(awaitingCompletion, true); - } - yield* setStreamError(cause); - }), + onExpectedFailure: (cause) => setStreamError(cause), retryExpectedFailureAfter: "250 millis", }).pipe(Stream.runForEach(applyItem)); }), From 924ad10bc7eff8f866065552cf49c3414c77850f Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 13 Jul 2026 20:00:21 -0400 Subject: [PATCH 16/16] fix(mobile): Keep chat content above composer when keyboard opens on Android Anchor the chat scroll position to the end of the scroll range when the keyboard opens with the list resting at the bottom, reading the live composer padding per frame. This survives the event-ordering race where composer expansion lands before the keyboard's onStart (quick tap after opening a thread), which zeroed out the previous delta-based compensation and left the last message hidden behind the input. Also re-assert the settled scroll target after the keyboard animation ends and guard that correction with shouldShiftContent. --- ...t-native-keyboard-controller@1.21.13.patch | 688 ++++++++++++++++++ pnpm-lock.yaml | 6 +- 2 files changed, 691 insertions(+), 3 deletions(-) diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch index a8a505282a4..b3e3e2a7ad6 100644 --- a/patches/react-native-keyboard-controller@1.21.13.patch +++ b/patches/react-native-keyboard-controller@1.21.13.patch @@ -808,3 +808,691 @@ index 833acbe78..143baf951 100644 ); } +diff --git a/lib/commonjs/components/KeyboardAvoidingView/index.js b/lib/commonjs/components/KeyboardAvoidingView/index.js +--- a/lib/commonjs/components/KeyboardAvoidingView/index.js ++++ b/lib/commonjs/components/KeyboardAvoidingView/index.js +@@ -48,6 +48,7 @@ + }, ref) => { + const initialFrame = (0, _reactNativeReanimated.useSharedValue)(null); + const internalRef = _react.default.useRef(null); ++ const lastLayoutRef = _react.default.useRef(null); + const frame = (0, _reactNativeReanimated.useDerivedValue)(() => initialFrame.value || defaultLayout); + const { + translate, +@@ -76,29 +77,48 @@ + initialFrame.value = layout; + } + }, [behavior]); ++ const measureWithAbsolutePosition = (0, _react.useCallback)(layout => { ++ const tag = (0, _findNodeHandle.findNodeHandle)(internalRef.current); ++ if (tag !== null) { ++ // Use native `viewPositionInWindow` to get true screen-absolute coordinates. ++ // This fixes current RN bugs: ++ // - https://github.com/facebook/react-native/pull/56062 ++ // - https://github.com/facebook/react-native/pull/56056 ++ return _bindings.KeyboardControllerNative.viewPositionInWindow(tag).then(position => { ++ (0, _reactNativeReanimated.runOnUI)(onLayoutWorklet)({ ++ ...layout, ++ x: position.x, ++ y: position.y ++ }); ++ }).catch(() => { ++ (0, _reactNativeReanimated.runOnUI)(onLayoutWorklet)(layout); ++ }); ++ } ++ return (0, _reactNativeReanimated.runOnUI)(onLayoutWorklet)(layout); ++ }, [onLayoutWorklet]); + const onLayout = (0, _react.useCallback)(e => { + onLayoutProps === null || onLayoutProps === void 0 || onLayoutProps(e); + const layout = e.nativeEvent.layout; ++ lastLayoutRef.current = layout; + if (automaticOffset) { +- const tag = (0, _findNodeHandle.findNodeHandle)(internalRef.current); +- if (tag !== null) { +- // Use native `viewPositionInWindow` to get true screen-absolute coordinates. +- // This fixes current RN bugs: +- // - https://github.com/facebook/react-native/pull/56062 +- // - https://github.com/facebook/react-native/pull/56056 +- return _bindings.KeyboardControllerNative.viewPositionInWindow(tag).then(position => { +- (0, _reactNativeReanimated.runOnUI)(onLayoutWorklet)({ +- ...layout, +- x: position.x, +- y: position.y +- }); +- }).catch(() => { +- (0, _reactNativeReanimated.runOnUI)(onLayoutWorklet)(layout); +- }); +- } ++ return measureWithAbsolutePosition(layout); + } + return (0, _reactNativeReanimated.runOnUI)(onLayoutWorklet)(layout); +- }, [onLayoutProps, automaticOffset]); ++ }, [onLayoutProps, automaticOffset, measureWithAbsolutePosition]); ++ (0, _react.useEffect)(() => { ++ if (!automaticOffset) { ++ return; ++ } ++ // The mount-time measurement can run mid navigation transition and cache a ++ // stale absolute position, so re-measure when the keyboard starts opening. ++ const subscription = _bindings.KeyboardEvents.addListener("keyboardWillShow", () => { ++ const layout = lastLayoutRef.current; ++ if (layout) { ++ measureWithAbsolutePosition(layout); ++ } ++ }); ++ return () => subscription.remove(); ++ }, [automaticOffset, measureWithAbsolutePosition]); + const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => { + if (!enabled) { + return {}; +diff --git a/lib/module/components/KeyboardAvoidingView/index.js b/lib/module/components/KeyboardAvoidingView/index.js +--- a/lib/module/components/KeyboardAvoidingView/index.js ++++ b/lib/module/components/KeyboardAvoidingView/index.js +@@ -1,8 +1,8 @@ + 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 React, { forwardRef, useCallback, useEffect, useMemo } from "react"; + import { View } from "react-native"; + import Reanimated, { interpolate, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue } from "react-native-reanimated"; +-import { KeyboardControllerNative } from "../../bindings"; ++import { KeyboardControllerNative, KeyboardEvents } from "../../bindings"; + import { useWindowDimensions } from "../../hooks"; + import { findNodeHandle } from "../../utils/findNodeHandle"; + import useCombinedRef from "../hooks/useCombinedRef"; +@@ -40,6 +40,7 @@ + }, ref) => { + const initialFrame = useSharedValue(null); + const internalRef = React.useRef(null); ++ const lastLayoutRef = React.useRef(null); + const frame = useDerivedValue(() => initialFrame.value || defaultLayout); + const { + translate, +@@ -68,29 +69,48 @@ + initialFrame.value = layout; + } + }, [behavior]); ++ const measureWithAbsolutePosition = useCallback(layout => { ++ const tag = findNodeHandle(internalRef.current); ++ if (tag !== null) { ++ // Use native `viewPositionInWindow` to get true screen-absolute coordinates. ++ // This fixes current RN bugs: ++ // - https://github.com/facebook/react-native/pull/56062 ++ // - https://github.com/facebook/react-native/pull/56056 ++ return KeyboardControllerNative.viewPositionInWindow(tag).then(position => { ++ runOnUI(onLayoutWorklet)({ ++ ...layout, ++ x: position.x, ++ y: position.y ++ }); ++ }).catch(() => { ++ runOnUI(onLayoutWorklet)(layout); ++ }); ++ } ++ return runOnUI(onLayoutWorklet)(layout); ++ }, [onLayoutWorklet]); + const onLayout = useCallback(e => { + onLayoutProps === null || onLayoutProps === void 0 || onLayoutProps(e); + const layout = e.nativeEvent.layout; ++ lastLayoutRef.current = layout; + if (automaticOffset) { +- const tag = findNodeHandle(internalRef.current); +- if (tag !== null) { +- // Use native `viewPositionInWindow` to get true screen-absolute coordinates. +- // This fixes current RN bugs: +- // - https://github.com/facebook/react-native/pull/56062 +- // - https://github.com/facebook/react-native/pull/56056 +- return KeyboardControllerNative.viewPositionInWindow(tag).then(position => { +- runOnUI(onLayoutWorklet)({ +- ...layout, +- x: position.x, +- y: position.y +- }); +- }).catch(() => { +- runOnUI(onLayoutWorklet)(layout); +- }); +- } ++ return measureWithAbsolutePosition(layout); + } + return runOnUI(onLayoutWorklet)(layout); +- }, [onLayoutProps, automaticOffset]); ++ }, [onLayoutProps, automaticOffset, measureWithAbsolutePosition]); ++ useEffect(() => { ++ if (!automaticOffset) { ++ return; ++ } ++ // The mount-time measurement can run mid navigation transition and cache a ++ // stale absolute position, so re-measure when the keyboard starts opening. ++ const subscription = KeyboardEvents.addListener("keyboardWillShow", () => { ++ const layout = lastLayoutRef.current; ++ if (layout) { ++ measureWithAbsolutePosition(layout); ++ } ++ }); ++ return () => subscription.remove(); ++ }, [automaticOffset, measureWithAbsolutePosition]); + const animatedStyle = useAnimatedStyle(() => { + if (!enabled) { + return {}; +diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx +--- a/src/components/KeyboardAvoidingView/index.tsx ++++ b/src/components/KeyboardAvoidingView/index.tsx +@@ -1,4 +1,4 @@ +-import React, { forwardRef, useCallback, useMemo } from "react"; ++import React, { forwardRef, useCallback, useEffect, useMemo } from "react"; + import { View } from "react-native"; + import Reanimated, { + interpolate, +@@ -8,7 +8,7 @@ + useSharedValue, + } from "react-native-reanimated"; + +-import { KeyboardControllerNative } from "../../bindings"; ++import { KeyboardControllerNative, KeyboardEvents } from "../../bindings"; + import { useWindowDimensions } from "../../hooks"; + import { findNodeHandle } from "../../utils/findNodeHandle"; + import useCombinedRef from "../hooks/useCombinedRef"; +@@ -93,6 +93,7 @@ + ) => { + const initialFrame = useSharedValue(null); + const internalRef = React.useRef(null); ++ const lastLayoutRef = React.useRef(null); + const frame = useDerivedValue(() => initialFrame.value || defaultLayout); + + const { translate, padding } = useTranslateAnimation(); +@@ -131,6 +132,32 @@ + }, + [behavior], + ); ++ const measureWithAbsolutePosition = useCallback( ++ (layout: LayoutRectangle) => { ++ const tag = findNodeHandle(internalRef.current); ++ ++ if (tag !== null) { ++ // Use native `viewPositionInWindow` to get true screen-absolute coordinates. ++ // This fixes current RN bugs: ++ // - https://github.com/facebook/react-native/pull/56062 ++ // - https://github.com/facebook/react-native/pull/56056 ++ return KeyboardControllerNative.viewPositionInWindow(tag) ++ .then((position) => { ++ runOnUI(onLayoutWorklet)({ ++ ...layout, ++ x: position.x, ++ y: position.y, ++ }); ++ }) ++ .catch(() => { ++ runOnUI(onLayoutWorklet)(layout); ++ }); ++ } ++ ++ return runOnUI(onLayoutWorklet)(layout); ++ }, ++ [onLayoutWorklet], ++ ); + const onLayout = useCallback>( + (e) => { + onLayoutProps?.(e); +@@ -137,33 +164,36 @@ + + const layout = e.nativeEvent.layout; + +- if (automaticOffset) { +- const tag = findNodeHandle(internalRef.current); ++ lastLayoutRef.current = layout; + +- if (tag !== null) { +- // Use native `viewPositionInWindow` to get true screen-absolute coordinates. +- // This fixes current RN bugs: +- // - https://github.com/facebook/react-native/pull/56062 +- // - https://github.com/facebook/react-native/pull/56056 +- return KeyboardControllerNative.viewPositionInWindow(tag) +- .then((position) => { +- runOnUI(onLayoutWorklet)({ +- ...layout, +- x: position.x, +- y: position.y, +- }); +- }) +- .catch(() => { +- runOnUI(onLayoutWorklet)(layout); +- }); +- } ++ if (automaticOffset) { ++ return measureWithAbsolutePosition(layout); + } + + return runOnUI(onLayoutWorklet)(layout); + }, +- [onLayoutProps, automaticOffset], ++ [onLayoutProps, automaticOffset, measureWithAbsolutePosition], + ); + ++ useEffect(() => { ++ if (!automaticOffset) { ++ return; ++ } ++ ++ // The mount-time measurement can run mid navigation transition and cache ++ // a stale absolute position, so re-measure when the keyboard starts ++ // opening. ++ const subscription = KeyboardEvents.addListener("keyboardWillShow", () => { ++ const layout = lastLayoutRef.current; ++ ++ if (layout) { ++ measureWithAbsolutePosition(layout); ++ } ++ }); ++ ++ return () => subscription.remove(); ++ }, [automaticOffset, measureWithAbsolutePosition]); ++ + const animatedStyle = useAnimatedStyle(() => { + if (!enabled) { + return {}; +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts +@@ -50,6 +50,8 @@ + const closing = useSharedValue(false); + const minimumPaddingFractionOnOpen = useSharedValue(0); + const actualOpenShift = useSharedValue(0); ++ const extraPaddingOnStart = useSharedValue(0); ++ const anchorToEnd = useSharedValue(false); + const { + layout, + size, +@@ -138,6 +140,15 @@ + minimumPaddingFractionOnOpen.value = visibleFraction >= 1 ? 1 : 0; + padding.value = effective; + offsetBeforeScroll.value = scroll.value; ++ extraPaddingOnStart.value = extraContentPadding.value; ++ // Rest position at the very end of the scroll range (all bottom ++ // inset visible). Composer growth can race this capture (its ++ // corrective scroll is rAF-deferred), so record the stronger ++ // "anchored to end" fact instead of relying on offset deltas alone. ++ anchorToEnd.value = ++ !inverted && ++ scroll.value + layout.value.height - size.value.height >= ++ blankSpace.value - 1; + + if (!inverted && keyboardLiftBehavior === "whenAtEnd" && !atEnd) { + // Sentinel: don't scroll in onMove (non-inverted only) +@@ -151,6 +162,12 @@ + } + } else { + // Android: keyboard closing — re-capture scroll position ++ extraPaddingOnStart.value = extraContentPadding.value; ++ anchorToEnd.value = ++ !inverted && ++ scroll.value + layout.value.height - size.value.height >= ++ blankSpace.value - 1; ++ + if (inverted) { + offsetBeforeScroll.value = scroll.value; + } else { +@@ -321,8 +338,36 @@ + return; + } + ++ if (anchorToEnd.value) { ++ // Anchored to the end of the scroll range: target the end ++ // directly (it tracks extraContentPadding live) so the rest ++ // position stays correct even when the composer grows around ++ // the animation start. ++ const target = Math.max( ++ size.value.height - layout.value.height + actualTotalPadding, ++ 0, ++ ); ++ ++ scrollTo(scrollViewRef, 0, target, false); ++ ++ if (!closing.value) { ++ actualOpenShift.value = ++ target - ++ offsetBeforeScroll.value - ++ (extraContentPadding.value - extraPaddingOnStart.value); ++ } ++ ++ return; ++ } ++ ++ // The composer can grow/shrink mid-animation (focus editors), which ++ // shifts extraContentPadding; include the delta since capture so the ++ // per-frame targets don't clobber that scroll correction. ++ const extraPaddingDelta = ++ extraContentPadding.value - extraPaddingOnStart.value; ++ + const target = clampedScrollTarget( +- offsetBeforeScroll.value, ++ offsetBeforeScroll.value + extraPaddingDelta, + scrollEffective, + size.value.height, + layout.value.height, +@@ -331,9 +376,11 @@ + + scrollTo(scrollViewRef, 0, target, false); + +- // Track actual (clamped) displacement during open for symmetric close ++ // Track actual (clamped) keyboard-only displacement during open so ++ // close stays symmetric even when the composer height changed too + if (!closing.value) { +- actualOpenShift.value = target - offsetBeforeScroll.value; ++ actualOpenShift.value = ++ target - offsetBeforeScroll.value - extraPaddingDelta; + } + } + }, +@@ -352,10 +399,78 @@ + + padding.value = effective; + +- // Record actual scroll displacement so close can be symmetric +- if (effective > 0 && offsetBeforeScroll.value !== -1) { +- actualOpenShift.value = scroll.value - offsetBeforeScroll.value; ++ if ( ++ inverted || ++ offsetBeforeScroll.value === -1 || ++ !shouldShiftContent(keyboardLiftBehavior, true) ++ ) { ++ return; ++ } ++ ++ const extraPaddingDelta = ++ extraContentPadding.value - extraPaddingOnStart.value; ++ ++ // Record actual keyboard-only scroll displacement so close can be ++ // symmetric (extraContentPadding shifts are compensated separately) ++ if (effective > 0) { ++ actualOpenShift.value = ++ scroll.value - offsetBeforeScroll.value - extraPaddingDelta; ++ } ++ ++ // Composer growth around the animation can leave the rendered ++ // position out of sync with the reported offset (per-frame scrollTo ++ // calls race the native inset commit). Once the animation settles, ++ // re-assert the final target (deferred, like useExtraContentPadding's ++ // scrollToTarget). ++ if (!anchorToEnd.value && (effective === 0 || extraPaddingDelta === 0)) { ++ return; ++ } ++ if ( ++ anchorToEnd.value && ++ effective === 0 && ++ keyboardLiftBehavior === "persistent" ++ ) { ++ // Persistent close holds position instead of returning to the end ++ return; ++ } ++ ++ const actualTotalPadding = Math.max( ++ blankSpace.value, ++ effective + extraContentPadding.value, ++ ); ++ let target: number; ++ ++ if (anchorToEnd.value) { ++ target = Math.max( ++ size.value.height - layout.value.height + actualTotalPadding, ++ 0, ++ ); ++ } else { ++ const minimumPaddingAbsorbed = ++ getMinimumPaddingAbsorbed( ++ blankSpace.value, ++ extraContentPadding.value, ++ ) * minimumPaddingFractionOnOpen.value; ++ const scrollEffective = getScrollEffective( ++ effective, ++ minimumPaddingAbsorbed, ++ ); ++ ++ target = clampedScrollTarget( ++ offsetBeforeScroll.value + extraPaddingDelta, ++ scrollEffective, ++ size.value.height, ++ layout.value.height, ++ actualTotalPadding, ++ ); + } ++ ++ requestAnimationFrame(() => { ++ if (!scrollViewRef()) { ++ return; ++ } ++ scrollTo(scrollViewRef, 0, target, false); ++ }); + }, + }, + [inverted, keyboardLiftBehavior, offset], +diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.js +--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.js ++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.js +@@ -31,6 +31,8 @@ + const closing = useSharedValue(false); + const minimumPaddingFractionOnOpen = useSharedValue(0); + const actualOpenShift = useSharedValue(0); ++ const extraPaddingOnStart = useSharedValue(0); ++ const anchorToEnd = useSharedValue(false); + const { + layout, + size, +@@ -79,6 +81,12 @@ + minimumPaddingFractionOnOpen.value = visibleFraction >= 1 ? 1 : 0; + padding.value = effective; + offsetBeforeScroll.value = scroll.value; ++ extraPaddingOnStart.value = extraContentPadding.value; ++ // Rest position at the very end of the scroll range (all bottom ++ // inset visible). Composer growth can race this capture (its ++ // corrective scroll is rAF-deferred), so record the stronger ++ // "anchored to end" fact instead of relying on offset deltas alone. ++ anchorToEnd.value = !inverted && scroll.value + layout.value.height - size.value.height >= blankSpace.value - 1; + if (!inverted && keyboardLiftBehavior === "whenAtEnd" && !atEnd) { + // Sentinel: don't scroll in onMove (non-inverted only) + offsetBeforeScroll.value = -1; +@@ -91,6 +99,8 @@ + } + } else { + // Android: keyboard closing — re-capture scroll position ++ extraPaddingOnStart.value = extraContentPadding.value; ++ anchorToEnd.value = !inverted && scroll.value + layout.value.height - size.value.height >= blankSpace.value - 1; + if (inverted) { + offsetBeforeScroll.value = scroll.value; + } else { +@@ -195,12 +205,30 @@ + scrollTo(scrollViewRef, 0, Math.min(keepAt, maxScroll), false); + return; + } +- const target = clampedScrollTarget(offsetBeforeScroll.value, scrollEffective, size.value.height, layout.value.height, actualTotalPadding); ++ if (anchorToEnd.value) { ++ // Anchored to the end of the scroll range: target the end ++ // directly (it tracks extraContentPadding live) so the rest ++ // position stays correct even when the composer grows around ++ // the animation start. ++ const target = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); ++ scrollTo(scrollViewRef, 0, target, false); ++ if (!closing.value) { ++ actualOpenShift.value = target - offsetBeforeScroll.value - (extraContentPadding.value - extraPaddingOnStart.value); ++ } ++ return; ++ } ++ ++ // The composer can grow/shrink mid-animation (focus editors), which ++ // shifts extraContentPadding; include the delta since capture so the ++ // per-frame targets don't clobber that scroll correction. ++ const extraPaddingDelta = extraContentPadding.value - extraPaddingOnStart.value; ++ const target = clampedScrollTarget(offsetBeforeScroll.value + extraPaddingDelta, scrollEffective, size.value.height, layout.value.height, actualTotalPadding); + scrollTo(scrollViewRef, 0, target, false); + +- // Track actual (clamped) displacement during open for symmetric close ++ // Track actual (clamped) keyboard-only displacement during open so ++ // close stays symmetric even when the composer height changed too + if (!closing.value) { +- actualOpenShift.value = target - offsetBeforeScroll.value; ++ actualOpenShift.value = target - offsetBeforeScroll.value - extraPaddingDelta; + } + } + }, +@@ -213,10 +241,44 @@ + const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); + padding.value = effective; + +- // Record actual scroll displacement so close can be symmetric +- if (effective > 0 && offsetBeforeScroll.value !== -1) { +- actualOpenShift.value = scroll.value - offsetBeforeScroll.value; ++ if (inverted || offsetBeforeScroll.value === -1 || !shouldShiftContent(keyboardLiftBehavior, true)) { ++ return; ++ } ++ const extraPaddingDelta = extraContentPadding.value - extraPaddingOnStart.value; ++ ++ // Record actual keyboard-only scroll displacement so close can be ++ // symmetric (extraContentPadding shifts are compensated separately) ++ if (effective > 0) { ++ actualOpenShift.value = scroll.value - offsetBeforeScroll.value - extraPaddingDelta; ++ } ++ ++ // Composer growth around the animation can leave the rendered ++ // position out of sync with the reported offset (per-frame scrollTo ++ // calls race the native inset commit). Once the animation settles, ++ // re-assert the final target (deferred, like useExtraContentPadding's ++ // scrollToTarget). ++ if (!anchorToEnd.value && (effective === 0 || extraPaddingDelta === 0)) { ++ return; + } ++ if (anchorToEnd.value && effective === 0 && keyboardLiftBehavior === "persistent") { ++ // Persistent close holds position instead of returning to the end ++ return; ++ } ++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); ++ let target; ++ if (anchorToEnd.value) { ++ target = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); ++ } else { ++ const minimumPaddingAbsorbed = getMinimumPaddingAbsorbed(blankSpace.value, extraContentPadding.value) * minimumPaddingFractionOnOpen.value; ++ const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); ++ target = clampedScrollTarget(offsetBeforeScroll.value + extraPaddingDelta, scrollEffective, size.value.height, layout.value.height, actualTotalPadding); ++ } ++ requestAnimationFrame(() => { ++ if (!scrollViewRef()) { ++ return; ++ } ++ scrollTo(scrollViewRef, 0, target, false); ++ }); + } + }, [inverted, keyboardLiftBehavior, offset]); + return { +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.js +--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.js +@@ -38,6 +38,8 @@ + const closing = (0, _reactNativeReanimated.useSharedValue)(false); + const minimumPaddingFractionOnOpen = (0, _reactNativeReanimated.useSharedValue)(0); + const actualOpenShift = (0, _reactNativeReanimated.useSharedValue)(0); ++ const extraPaddingOnStart = (0, _reactNativeReanimated.useSharedValue)(0); ++ const anchorToEnd = (0, _reactNativeReanimated.useSharedValue)(false); + const { + layout, + size, +@@ -86,6 +88,12 @@ + minimumPaddingFractionOnOpen.value = visibleFraction >= 1 ? 1 : 0; + padding.value = effective; + offsetBeforeScroll.value = scroll.value; ++ extraPaddingOnStart.value = extraContentPadding.value; ++ // Rest position at the very end of the scroll range (all bottom ++ // inset visible). Composer growth can race this capture (its ++ // corrective scroll is rAF-deferred), so record the stronger ++ // "anchored to end" fact instead of relying on offset deltas alone. ++ anchorToEnd.value = !inverted && scroll.value + layout.value.height - size.value.height >= blankSpace.value - 1; + if (!inverted && keyboardLiftBehavior === "whenAtEnd" && !atEnd) { + // Sentinel: don't scroll in onMove (non-inverted only) + offsetBeforeScroll.value = -1; +@@ -98,6 +106,8 @@ + } + } else { + // Android: keyboard closing — re-capture scroll position ++ extraPaddingOnStart.value = extraContentPadding.value; ++ anchorToEnd.value = !inverted && scroll.value + layout.value.height - size.value.height >= blankSpace.value - 1; + if (inverted) { + offsetBeforeScroll.value = scroll.value; + } else { +@@ -202,12 +212,30 @@ + (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, Math.min(keepAt, maxScroll), false); + return; + } +- const target = (0, _helpers.clampedScrollTarget)(offsetBeforeScroll.value, scrollEffective, size.value.height, layout.value.height, actualTotalPadding); ++ if (anchorToEnd.value) { ++ // Anchored to the end of the scroll range: target the end ++ // directly (it tracks extraContentPadding live) so the rest ++ // position stays correct even when the composer grows around ++ // the animation start. ++ const target = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); ++ (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false); ++ if (!closing.value) { ++ actualOpenShift.value = target - offsetBeforeScroll.value - (extraContentPadding.value - extraPaddingOnStart.value); ++ } ++ return; ++ } ++ ++ // The composer can grow/shrink mid-animation (focus editors), which ++ // shifts extraContentPadding; include the delta since capture so the ++ // per-frame targets don't clobber that scroll correction. ++ const extraPaddingDelta = extraContentPadding.value - extraPaddingOnStart.value; ++ const target = (0, _helpers.clampedScrollTarget)(offsetBeforeScroll.value + extraPaddingDelta, scrollEffective, size.value.height, layout.value.height, actualTotalPadding); + (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false); + +- // Track actual (clamped) displacement during open for symmetric close ++ // Track actual (clamped) keyboard-only displacement during open so ++ // close stays symmetric even when the composer height changed too + if (!closing.value) { +- actualOpenShift.value = target - offsetBeforeScroll.value; ++ actualOpenShift.value = target - offsetBeforeScroll.value - extraPaddingDelta; + } + } + }, +@@ -220,10 +248,44 @@ + const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); + padding.value = effective; + +- // Record actual scroll displacement so close can be symmetric +- if (effective > 0 && offsetBeforeScroll.value !== -1) { +- actualOpenShift.value = scroll.value - offsetBeforeScroll.value; ++ if (inverted || offsetBeforeScroll.value === -1 || !(0, _helpers.shouldShiftContent)(keyboardLiftBehavior, true)) { ++ return; ++ } ++ const extraPaddingDelta = extraContentPadding.value - extraPaddingOnStart.value; ++ ++ // Record actual keyboard-only scroll displacement so close can be ++ // symmetric (extraContentPadding shifts are compensated separately) ++ if (effective > 0) { ++ actualOpenShift.value = scroll.value - offsetBeforeScroll.value - extraPaddingDelta; ++ } ++ ++ // Composer growth around the animation can leave the rendered ++ // position out of sync with the reported offset (per-frame scrollTo ++ // calls race the native inset commit). Once the animation settles, ++ // re-assert the final target (deferred, like useExtraContentPadding's ++ // scrollToTarget). ++ if (!anchorToEnd.value && (effective === 0 || extraPaddingDelta === 0)) { ++ return; + } ++ if (anchorToEnd.value && effective === 0 && keyboardLiftBehavior === "persistent") { ++ // Persistent close holds position instead of returning to the end ++ return; ++ } ++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); ++ let target; ++ if (anchorToEnd.value) { ++ target = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); ++ } else { ++ const minimumPaddingAbsorbed = (0, _helpers.getMinimumPaddingAbsorbed)(blankSpace.value, extraContentPadding.value) * minimumPaddingFractionOnOpen.value; ++ const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); ++ target = (0, _helpers.clampedScrollTarget)(offsetBeforeScroll.value + extraPaddingDelta, scrollEffective, size.value.height, layout.value.height, actualTotalPadding); ++ } ++ requestAnimationFrame(() => { ++ if (!scrollViewRef()) { ++ return; ++ } ++ (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false); ++ }); + } + }, [inverted, keyboardLiftBehavior, offset]); + return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed866e9ff74..09d6ebb8367 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ patchedDependencies: effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 - react-native-keyboard-controller@1.21.13: 001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7 + react-native-keyboard-controller@1.21.13: 2524b02030c39015f9d95ad90a36f1362366a3b296a5838d78183494c455febf react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 @@ -374,7 +374,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.13 - version: 1.21.13(patch_hash=001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=2524b02030c39015f9d95ad90a36f1362366a3b296a5838d78183494c455febf)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -19694,7 +19694,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-keyboard-controller@1.21.13(patch_hash=001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.13(patch_hash=2524b02030c39015f9d95ad90a36f1362366a3b296a5838d78183494c455febf)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)