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/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/modules/t3-native-controls/ios/T3CrashLog.swift b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift new file mode 100644 index 00000000000..aa6eca69707 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift @@ -0,0 +1,163 @@ +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/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; @@ -152,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%" })} + > + + + )} + ); } @@ -937,6 +947,7 @@ function renderFeedEntry( hasNativeSelectableMarkdownText() ? ( + {props.loading ? : null} {props.title} {props.detail} @@ -1265,6 +1278,10 @@ 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); const previousLatestTurnRef = useRef(props.latestTurn); @@ -1273,6 +1290,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 +1413,51 @@ 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)); + }, []); + // 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 < usableViewportHeight - 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 +1482,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) { @@ -1434,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( @@ -1496,6 +1603,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + } + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + } }; }, []); @@ -1711,7 +1824,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 +1863,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { scrollToOverflowEnabled estimatedItemSize={180} initialScrollAtEnd + onContentSizeChange={handleContentSizeChange} onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ @@ -1772,6 +1886,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..65f51be33f5 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"; @@ -31,7 +32,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 +79,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 +150,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 +245,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 +268,8 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { function ThreadRouteContent( props: ThreadRouteScreenProps & { - readonly selectedThreadDetailState: ReturnType; + readonly detailLoadError: string | null; + readonly selectedThreadDetailState: ReturnType["state"]; }, ) { const { @@ -467,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: { @@ -725,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 ; } @@ -735,7 +887,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, }); @@ -796,41 +948,7 @@ function ThreadRouteContent( 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/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.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 new file mode 100644 index 00000000000..d1d84e27243 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,307 @@ +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 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 { + if (installed) { + return; + } + 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 { + emitConsoleMarker(error, fatal, handlerInvocationCount, shouldPersist); + } catch { + // Never let the logger mask the original error. + } + previousHandler(error, isFatal); + }); + } + + installUnhandledRejectionHandler(); + + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function emitConsoleMarker( + error: unknown, + isFatal: boolean, + handlerInvocation: number, + willPersist: boolean, +): void { + const cause = error instanceof Error ? error : null; + const marker = { + breadcrumbs: getBreadcrumbs().slice(-12), + handlerInvocation, + isFatal, + message: cause?.message ?? String(error), + name: cause?.name ?? 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 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; + } + const files = directory + .list() + .filter( + (entry): entry is File => + 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 record (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + + 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/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/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/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..bc26f38ec32 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,17 +11,21 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } 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, @@ -67,14 +71,86 @@ export function NativeStackScreenOptions(props: { readonly name?: string; }) { const navigation = useNativeStackNavigation(); - 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; } - navigation.setOptions(normalizedOptions); - }, [navigation, normalizedOptions]); + lastSignatureRef.current = signature; + + const normalized = normalizeScreenOptions(props.options); + if (!normalized) { + return; + } + + 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/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..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,8 @@ 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"; import { randomHex } from "../lib/uuid"; @@ -221,7 +223,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, @@ -335,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, @@ -360,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); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 8e5c004b459..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 Effect.runPromise(Effect.yieldNow); + await Effect.runPromise(wallClockDelay(25)); } throw new Error(`Timed out waiting for file content at ${filePath}`); } @@ -124,9 +138,15 @@ function waitForJsonLogMatch( if (requests.some(predicate)) { return requests; } - yield* Effect.yieldNow; + // 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* new WaitForJsonLogTimeout({ filePath }); } - return yield* Effect.promise(() => readJsonLines(filePath)); + return requests; }); } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..56024aa736b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5607,6 +5607,60 @@ 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, + requestCompletionMarker: true, + }).pipe(Stream.runHead), + ), + ); + + assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); + }).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 fafdbfa6814..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, }; }); @@ -1204,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/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..5897159c875 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -5,9 +5,11 @@ 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"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -15,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"; @@ -137,12 +140,115 @@ describe("environment shell synchronization", () => { }), ); - it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => + 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("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(); @@ -183,22 +289,457 @@ 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("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; + // 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.none(), + ); + 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), + ); + + 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( + (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 = { + 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 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()), + }); + const shellState = 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, + ); + + // 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"); + }), + ); + + 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(); + 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, + ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); + 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 faa70bc4f3a..70d4e9f3362 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"; @@ -62,11 +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* ( @@ -90,46 +102,31 @@ 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 === "live" - ? current - : { - ...current, - status: "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 }, ) { 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" - ? 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) => @@ -141,6 +138,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", @@ -149,43 +152,122 @@ 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 }); + }); + + 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* () { - // 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 }); - } + // 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. + // + // 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: () => 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. + // + // 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)) { + const httpSnapshot = yield* snapshotLoader.load(prepared.value); + if (Option.isSome(httpSnapshot)) { + yield* applyItem( + { kind: "snapshot", snapshot: httpSnapshot.value }, + { 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); + } + } - const subscribeInput = Option.match(base, { - onNone: () => ({}), - onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }), - }); + 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); + } - 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) => + 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); + }), + ), + ); }), ); yield* SubscriptionRef.changes(supervisor.state).pipe( diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8e982723d22..266e5f48cc6 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -77,10 +77,21 @@ const BASE_THREAD: OrchestrationThread = { type TestThreadInput = OrchestrationThreadStreamItem | Error; -function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { +function synchronized(): OrchestrationThreadStreamItem { + return { kind: "synchronized" }; +} + +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, @@ -101,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(); @@ -109,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( @@ -121,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), @@ -196,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, + ), + ), + ), }; }); @@ -483,4 +518,214 @@ describe("EnvironmentThreads", () => { expect((yield* Ref.get(harness.latest)).status).toBe("live"); }), ); + + it.effect( + "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, + 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); + }), + ); + + 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* 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: "backoff", + stage: null, + 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"); + expect(Option.getOrThrow((yield* Ref.get(harness.latest)).data).title).toBe( + "Post-reconnect catch-up", + ); + }), + ); + + 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 fd5b425fa2a..791480d8fda 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -10,10 +10,12 @@ 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"; +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 +37,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* ( @@ -72,6 +71,13 @@ 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. + // 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* ( snapshot: OrchestrationThreadDetailSnapshot, @@ -95,37 +101,65 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => ({ - ...current, - status: "synchronizing" as const, - error: Option.none(), - })); - const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" - ? current - : { + 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) => { + 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. + if (yield* Ref.get(markerMode)) { + 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 @@ -135,6 +169,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", @@ -156,6 +191,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( 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() } + : current, + ); + return; + } + if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); @@ -225,17 +270,37 @@ 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(markerMode, true); + 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) => setStreamError(cause), retryExpectedFailureAfter: "250 millis", }).pipe(Stream.runForEach(applyItem)); }), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..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; @@ -1135,6 +1143,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, 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; 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..6a1efd0c67b 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,717 @@ 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/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 >= ++ Math.max(blankSpace.value, padding.value + extraContentPadding.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 >= Math.max(blankSpace.value, padding.value + extraContentPadding.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 >= Math.max(blankSpace.value, padding.value + extraContentPadding.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 ee737f9e750..ae8508f3da3 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: 50a1cfb28346998d5dfa7c2ae5106d6fff47083853eca320e87d5ef8ad9c45a0 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=50a1cfb28346998d5dfa7c2ae5106d6fff47083853eca320e87d5ef8ad9c45a0)(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=50a1cfb28346998d5dfa7c2ae5106d6fff47083853eca320e87d5ef8ad9c45a0)(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)