diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..8add3abd386 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,3 +1,7 @@ +// Installed via a side-effect import listed first so the fatal-error handler +// is in place before the rest of the app module graph evaluates. +import "./src/lib/installCrashLog"; + import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { featureFlags } from "react-native-screens"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63..737330db1d4 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + + bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default; }; struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + + bool operator==(const T3MarkdownTextAttachmentRange&) const = default; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> { const LayoutConstraints& layoutConstraints) const override; private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; + // Content must be derived from the current children whenever it is needed. + // Yoga can invoke layout() on a fresh clone without ever calling + // measureContent() on it (for example when both dimensions are already + // exact), so caching measure-time content in mutable members and publishing + // it from layout() lets state fall behind the children and drop text. + struct Content { + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + }; + + Content buildContent(const LayoutContext& layoutContext) const; + void updateStateIfNeeded(Content&& content); }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb9..7a02094a4d3 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -72,15 +72,8 @@ static void applyAttachments( } } -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { +T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent( + const LayoutContext& layoutContext) const { const auto &baseProps = getConcreteProps(); auto baseTextAttributes = TextAttributes::defaultTextAttributes(); @@ -207,14 +200,23 @@ static void applyAttachments( } } - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; + return Content{ + std::move(baseAttributedString), + std::move(paragraphStyleRanges), + std::move(attachmentRanges), + }; +} + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + const auto content = buildContent(layoutContext); NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); + [RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges); + applyAttachments(convertedAttributedString, content.attachmentRanges); const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width @@ -255,10 +257,21 @@ static void applyAttachments( void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { ensureUnsealed(); + updateStateIfNeeded(buildContent(layoutContext)); +} + +void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) { + const auto &stateData = getStateData(); + if (stateData.attributedString == content.attributedString && + stateData.paragraphStyleRanges == content.paragraphStyleRanges && + stateData.attachmentRanges == content.attachmentRanges) { + return; + } + setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, + std::move(content.attributedString), + std::move(content.paragraphStyleRanges), + std::move(content.attachmentRanges), }); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ad..5c8d5bc97c2 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -34,6 +34,7 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + fillWidth = false, preserveSoftBreaks = false, onLinkPress, marginTop = 0, @@ -63,7 +64,15 @@ export function SelectableMarkdownText({ // shrink-to-fit containers such as user-message bubbles. Yoga then gives // the native text node an unbounded second pass and the parent only clips // the resulting single-line width instead of reflowing it. - + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb6..c1287a3565f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly fillWidth?: boolean; readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 652dc02fccb..25fd595d3dc 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -3,12 +3,15 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; +import { + NativeHeaderToolbar, + NativeStackScreenOptions, + type AppNativeStackNavigationOptions, +} from "../../native/StackHeader"; +import { useCallback, useMemo, useRef } from "react"; import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; -import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useThemeColor } from "../../lib/useThemeColor"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -26,7 +29,6 @@ import { } from "./home-list-options"; export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; -const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); export function HomeHeader(props: { readonly environments: ReadonlyArray; @@ -43,70 +45,85 @@ export function HomeHeader(props: { readonly onStartNewTask: () => void; }) { const searchBarRef = useRef(null); - const iconColor = useThemeColor("--color-icon"); + const propsRef = useRef(props); + propsRef.current = props; + const iconColor = String(useThemeColor("--color-icon")); const hasCustomListOptions = hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + + // Keep navigation options referentially stable. A fresh options object each + // render re-enters navigation.setOptions via NativeStackScreenOptions and can + // trip "Maximum update depth exceeded" under PreventRemoveProvider on iOS. + const screenOptions = useMemo((): AppNativeStackNavigationOptions => { + const filterSystemImageName = hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease"; + return { + // Static header config (glass, title, fonts) lives in Stack.tsx + // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. + headerTintColor: iconColor, + unstable_headerRightItems: + Platform.OS === "ios" + ? () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: () => propsRef.current.onOpenSettings(), + type: "button", + }), + ] + : undefined, + unstable_headerToolbarItems: + Platform.OS === "ios" + ? () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + filterMenu: buildHomeListFilterMenu(propsRef.current), + filterButtonId: "home-filter", + filterSystemImageName, + onComposePress: () => propsRef.current.onStartNewTask(), + onSearchTextChange: (query) => propsRef.current.onSearchQueryChange(query), + placeholder: "Search", + searchTextChangeId: "home-search-text", + }), + ] + : undefined, + headerSearchBarOptions: + Platform.OS === "ios" + ? undefined + : { + ref: searchBarRef, + allowToolbarIntegration: true, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + propsRef.current.onSearchQueryChange(""); + }, + onChangeText: (event) => { + propsRef.current.onSearchQueryChange(event.nativeEvent.text); + }, + }, + }; + }, [ + hasCustomListOptions, + iconColor, + props.environments, + props.projectGroupingMode, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threadSortOrder, + ]); return ( <> - [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ] - : undefined, - unstable_headerToolbarItems: - Platform.OS === "ios" - ? () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - }), - ] - : undefined, - headerSearchBarOptions: - Platform.OS === "ios" - ? undefined - : { - ref: searchBarRef, - allowToolbarIntegration: true, - hideNavigationBar: false, - placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, - }, - }} - /> + {Platform.OS === "ios" ? null : ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index eaea597211f..a8ff5b8bbf2 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,7 +1,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -17,6 +17,9 @@ import { useHomeListOptions } from "./home-list-options"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; +const EMPTY_HOME_TITLE_OPTIONS = { title: "", headerTitle: "" } as const; +const THREADS_HOME_TITLE_OPTIONS = { title: "Threads", headerTitle: "Threads" } as const; + /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { @@ -56,25 +59,29 @@ export function HomeRouteScreen() { setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const openSettings = useCallback(() => { + navigation.navigate("SettingsSheet", { screen: "Settings" }); + }, [navigation]); + const openNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. if (layout.usesSplitView) { return ( <> - + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onPress={openNewTask} /> } /> - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - /> + ); } @@ -82,7 +89,7 @@ export function HomeRouteScreen() { return ( <> {/* Restore the compact title in case the split branch blanked it. */} - + navigation.navigate("SettingsSheet", { screen: "Settings" })} + onOpenSettings={openSettings} onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={openNewTask} onThreadSortOrderChange={setThreadSortOrder} /> @@ -110,7 +117,7 @@ export function HomeRouteScreen() { onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) } - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} + onOpenSettings={openSettings} onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 88686d15778..0ad48198a7f 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -176,6 +176,40 @@ describe("buildHomeThreadGroups", () => { expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); }); + it("excludes subagent threads, matching the desktop sidebar", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const parentThreadId = ThreadId.make("thread-parent"); + const threads = [ + makeThread({ + environmentId, + id: parentThreadId, + projectId: project.id, + title: "Parent thread", + }), + makeThread({ + environmentId, + id: ThreadId.make("thread-subagent"), + projectId: project.id, + title: "Continue the delegated task", + lineage: { + rootThreadId: parentThreadId, + parentThreadId, + relationshipToParent: "subagent", + }, + }), + ]; + + const groups = buildGroups([project], threads); + + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([parentThreadId]); + }); + it("matches web repository, repository-path, and separate grouping modes", () => { const environmentId = EnvironmentId.make("environment-1"); const repositoryIdentity = { diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index cada956d8bb..1efba866be4 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -165,6 +165,11 @@ export function buildHomeThreadGroups(input: { if (thread.archivedAt !== null) { continue; } + // Subagent threads are surfaced through their parent thread, matching the + // desktop sidebar (isSidebarSubagentThread). + if (thread.lineage.relationshipToParent === "subagent") { + continue; + } if (input.environmentId !== null && thread.environmentId !== input.environmentId) { continue; } diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 300434eb736..2398d574c5c 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -55,7 +55,10 @@ export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean { const commandHandlers = handlers.get(command); if (!commandHandlers) return false; - for (const handler of [...commandHandlers].toReversed()) { + const handlerList = [...commandHandlers]; + for (let index = handlerList.length - 1; index >= 0; index -= 1) { + const handler = handlerList[index]; + if (!handler) continue; if (handler() !== false) return true; } return false; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0b9aa2fdfbc..17caed505d2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -178,6 +178,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray + {props.loading ? : null} {props.title} {props.detail} @@ -1644,6 +1646,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); } + if (props.contentPresentation.kind === "loading") { + return ( + + ); + } + return ( <> @@ -1658,6 +1673,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // mount positions during attach, where UIKit applies the inset. key={listMountKey} style={{ flex: 1 }} + bounces={false} + alwaysBounceVertical={false} // RN 0.81+ drops touches inside the contentInset area // (facebook/react-native#54123); the anchored end space after a send // is pure inset, so without this the blank region can't be scrolled. diff --git a/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx b/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx index 91091a1132a..5e105b6955e 100644 --- a/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx +++ b/apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx @@ -76,16 +76,23 @@ export function ThreadRelationshipsBanner(props: { [projection, shells], ); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); - const rows = useMemo( - () => - immediateThreadRelationships(graph, props.threadId).toSorted( - (left, right) => - Number(right.threadId === mergeTargetThreadId) - - Number(left.threadId === mergeTargetThreadId), - ), - [graph, mergeTargetThreadId, props.threadId], - ); - const latestCompletedRun = projection?.runs.findLast((run) => run.status === "completed") ?? null; + const rows = useMemo(() => { + const relationships = [...immediateThreadRelationships(graph, props.threadId)]; + relationships.sort( + (left, right) => + Number(right.threadId === mergeTargetThreadId) - + Number(left.threadId === mergeTargetThreadId), + ); + return relationships; + }, [graph, mergeTargetThreadId, props.threadId]); + const latestCompletedRun = useMemo(() => { + if (!projection) return null; + for (let index = projection.runs.length - 1; index >= 0; index -= 1) { + const run = projection.runs[index]; + if (run?.status === "completed") return run; + } + return null; + }, [projection]); const canMerge = mergeTargetThreadId !== null && latestCompletedRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; const [visible, setVisible] = useState(false); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index b45f350aafe..24f5f7fce0f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -30,7 +30,7 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailQuery } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -78,6 +78,8 @@ interface ThreadInspectorSelection { type NativeHeaderItems = ReadonlyArray>; +const THREAD_DETAIL_STALL_RETRY_DELAYS_MS = [2_500, 6_000] as const; + function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -144,7 +146,80 @@ 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); + + 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); + return; + } + + if (routeConnectionState !== "connected") { + 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]; + if (delayMs === undefined) { + return; + } + timer = setTimeout(() => { + if (cancelled) { + return; + } + const currentAttempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + if (currentAttempts !== attempts) { + 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, + ]); if (environmentId === null || threadIdRaw === null) { return ; @@ -172,7 +247,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { function ThreadRouteContent( props: ThreadRouteScreenProps & { - readonly selectedThreadDetailState: ReturnType; + readonly selectedThreadDetailState: ReturnType["state"]; }, ) { const { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 654be473b98..b3572172e81 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -7,6 +7,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "expo-symbols"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; +import { Pressable as GesturePressable } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { AppText as Text } from "../../components/AppText"; @@ -508,16 +509,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const rowContent = (close: () => void) => compact ? ( - { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={({ pressed }) => ({ backgroundColor: screenColor, opacity: pressed ? 0.7 : 1 })} > - + ) : ( ( // Messages-style row actions: a real UIContextMenuInteraction on // long-press / pointer right-click, with the row as the zoom preview. + // Compact (Home) and sidebar rows both use this so long-press opens + // archive/delete while taps still open the thread. // Requires the patched @react-native-menu (see // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode // the interaction is hosted by the component view and the underlying diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..21f2edaf52f 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,7 +36,37 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + isOwnedPastedImageUri, + toUploadChatImageAttachments, +} from "./composerImages"; + +describe("toUploadChatImageAttachments", () => { + it("strips client draft id and previewUri for the startTurn wire shape", () => { + expect( + toUploadChatImageAttachments([ + { + id: "client-draft-id", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/preview.png", + }, + ]), + ).toEqual([ + { + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + }, + ]); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..8aa6d2a3fa8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,6 +10,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ +export function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): ReadonlyArray { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })); +} + const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; function estimateBase64ByteSize(base64: string): number { diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts new file mode 100644 index 00000000000..486b485b774 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,80 @@ +import { Directory, File, Paths } from "expo-file-system"; +import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const MAX_PERSISTED_CRASH_LOGS = 20; +const REPORTED_ON_LAUNCH_COUNT = 3; + +let crashSequence = 0; + +export function installCrashLogger(): void { + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils === undefined) { + return; + } + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + if (isFatal === true) { + try { + persistCrashRecord(error); + } catch { + // Never let the logger mask the original error. + } + } + previousHandler(error, isFatal); + }); + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function persistCrashRecord(error: unknown): void { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const cause = error instanceof Error ? error : null; + const record = { + capturedAt: new Date().toISOString(), + isFatal: true, + message: cause?.message ?? String(error), + name: cause?.name ?? null, + stack: cause?.stack ?? null, + }; + crashSequence += 1; + const file = new File(directory, `crash-${Date.now()}-${crashSequence}.json`); + file.create({ intermediates: true, overwrite: true }); + file.write(JSON.stringify(record, null, 2)); +} + +function reportAndPrunePreviousCrashes(): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + if (!directory.exists) { + return; + } + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File && entry.name.startsWith("crash-") && entry.name.endsWith(".json"), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { + try { + console.warn(`[crash-log] previous fatal JS error (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + for (const file of files.slice(0, Math.max(0, files.length - MAX_PERSISTED_CRASH_LOGS))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } + } catch { + // Reporting past crashes must never affect startup. + } +} diff --git a/apps/mobile/src/lib/installCrashLog.ts b/apps/mobile/src/lib/installCrashLog.ts new file mode 100644 index 00000000000..ac3d1b76558 --- /dev/null +++ b/apps/mobile/src/lib/installCrashLog.ts @@ -0,0 +1,3 @@ +import { installCrashLogger } from "./crashLog"; + +installCrashLogger(); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 1330ffddbff..310f1818590 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(); @@ -56,7 +56,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..564da418a52 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,15 @@ export function hasNativeSelectableMarkdownText(): boolean { return true; } -export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; +export function SelectableMarkdownText({ + fillWidth = false, + ...props +}: MobileSelectableMarkdownTextProps) { + const content = ; + + if (!fillWidth) { + return content; + } + + return {content}; } diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de4..e536048a241 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,6 +1,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..552f0e23cdc 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } from "react"; @@ -67,12 +68,19 @@ export function NativeStackScreenOptions(props: { readonly name?: string; }) { const navigation = useNativeStackNavigation(); + const lastAppliedOptionsRef = useRef(undefined); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); useLayoutEffect(() => { if (!navigation || !normalizedOptions) { return; } + // Avoid re-entering navigation state when the same options object is + // reapplied every layout (common when callers pass unstable object literals). + if (lastAppliedOptionsRef.current === normalizedOptions) { + return; + } + lastAppliedOptionsRef.current = normalizedOptions; navigation.setOptions(normalizedOptions); }, [navigation, normalizedOptions]); diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..e2adffbdfb7 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,5 +1,6 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import * as Cause from "effect/Cause"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -15,10 +16,7 @@ export interface EnvironmentQueryView { } function formatError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The environment request failed."; + return causeFailureMessage(cause, "The environment request failed."); } export function useEnvironmentQuery( diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 34dc85f1689..b4d6f6bef41 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,4 +1,5 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; import { createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, @@ -8,6 +9,7 @@ import { createThreadEnvironmentAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -29,18 +31,55 @@ 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; +} + +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)), + }; + } + + if (value !== null) { + return value; + } + + return EMPTY_ENVIRONMENT_THREAD_STATE; +} + +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, - ); - const state = Option.getOrElse( - AsyncResult.value(result), - () => EMPTY_ENVIRONMENT_THREAD_STATE, - ) as EnvironmentThreadState; - return state; + : 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 8c48aa880ec..ff6c6dda75b 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -3,7 +3,11 @@ import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, OrchestrationV2ThreadProjection, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { environmentThreadDetails, useEnvironmentThread } from "./threads"; +import { + environmentThreadDetails, + useEnvironmentThread, + useEnvironmentThreadQuery, +} from "./threads"; import { useThreadSelection } from "./use-thread-selection"; const EMPTY_THREAD_PROJECTION_ATOM = Atom.make(null).pipe( @@ -22,9 +26,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 b82214c9e2a..15793c07515 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -16,6 +16,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { randomHex } from "../lib/uuid"; @@ -223,7 +224,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 524540ad83d..0af3acedeb8 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -56,7 +56,7 @@ function threadDetailToShell( projection: OrchestrationV2ThreadProjection, ): EnvironmentThreadShell { const thread = projection.thread; - const runsByOrdinal = projection.runs.toSorted((left, right) => right.ordinal - left.ordinal); + const runsByOrdinal = [...projection.runs].sort((left, right) => right.ordinal - left.ordinal); const latestRun = runsByOrdinal[0] ?? null; const activeRun = runsByOrdinal.find( 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/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index be5950e427c..010f309c7eb 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -66,6 +66,14 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct readonly commands: OrchestrationV2Command[]; readonly projects: ProjectMutation[]; readonly launches?: OrchestrationV2ThreadLaunchInput[]; + readonly persistedUploads?: Array<{ + readonly threadId: string; + readonly messageId: string; + readonly attachments: ReadonlyArray<{ + readonly name: string; + readonly dataUrl: string; + }>; + }>; readonly projection?: OrchestrationV2ThreadProjection; }) { const client = { @@ -85,6 +93,36 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct resumed: false, }; }), + [WS_METHODS.assetsPersistChatAttachments]: (persistInput: { + readonly threadId: string; + readonly messageId: string; + readonly attachments: ReadonlyArray<{ + readonly type: "image"; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly dataUrl: string; + }>; + }) => + Effect.sync(() => { + input.persistedUploads?.push({ + threadId: persistInput.threadId, + messageId: persistInput.messageId, + attachments: persistInput.attachments.map((attachment) => ({ + name: attachment.name, + dataUrl: attachment.dataUrl, + })), + }); + return { + attachments: persistInput.attachments.map((attachment, index) => ({ + type: "image" as const, + id: `server-attachment-${index}`, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })), + }; + }), [WS_METHODS.projectsMutate]: (mutation: ProjectMutation) => Effect.sync(() => { input.projects.push(mutation); @@ -207,6 +245,69 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("remaps dual-tagged draft image uploads to server attachment ids", () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const persistedUploads: Array<{ + readonly threadId: string; + readonly messageId: string; + readonly attachments: ReadonlyArray<{ + readonly name: string; + readonly dataUrl: string; + }>; + }> = []; + const supervisor = yield* makeSupervisor({ commands, projects: [], persistedUploads }); + + // Mobile drafts carry a client-local id for previews plus dataUrl for upload. + const draftUpload = { + type: "image" as const, + id: "b30d83c1-0159-4e5b-9967-e721201aeca3", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/pasted.png", + }; + + yield* startThreadTurn({ + commandId: CommandId.make("mobile-image-turn"), + threadId: v2ThreadId, + message: { + messageId: MessageId.make("message-mobile-image"), + role: "user", + text: "see this", + attachments: [draftUpload as never], + }, + runtimeMode: "full-access", + interactionMode: "default", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(persistedUploads).toEqual([ + { + threadId: v2ThreadId, + messageId: "message-mobile-image", + attachments: [{ name: "pasted-image.png", dataUrl: "data:image/png;base64,AA==" }], + }, + ]); + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ + type: "message.dispatch", + attachments: [ + { + type: "image", + id: "server-attachment-0", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + }, + ], + }); + expect(commands[0]).not.toMatchObject({ + attachments: [{ id: "b30d83c1-0159-4e5b-9967-e721201aeca3" }], + }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("preserves plan implementation provenance on V2 runs", () => Effect.gen(function* () { const commands: OrchestrationV2Command[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index fda9980ac55..9b71cbebc29 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -184,31 +184,67 @@ const dispatch = (command: OrchestrationV2Command) => const getProjection = (threadId: ThreadId) => request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); +function isActiveRunStatus(status: string): boolean { + return ( + status === "preparing" || status === "starting" || status === "running" || status === "waiting" + ); +} + +function isUploadChatAttachment( + attachment: ChatAttachment | UploadChatAttachment, +): attachment is UploadChatAttachment { + return "dataUrl" in attachment; +} + +function isStoredChatAttachment( + attachment: ChatAttachment | UploadChatAttachment, +): attachment is ChatAttachment { + return "id" in attachment && !("dataUrl" in attachment); +} + +function toUploadChatAttachmentPayload(attachment: UploadChatAttachment): UploadChatAttachment { + return { + type: "image", + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + }; +} + +/** + * Persist draft image uploads, then return server attachment ids for dispatch. + * + * Draft objects may carry both a client-local `id` (for previews) and `dataUrl`. + * Those must still go through persist and use the remapped server ids; never + * keep the client id for provider path resolution. + */ const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(function* ( threadId: ThreadId, messageId: MessageId, attachments: ReadonlyArray, ) { - const stored = attachments.filter( - (attachment): attachment is ChatAttachment => "id" in attachment, - ); - const uploads = attachments.filter( - (attachment): attachment is UploadChatAttachment => "dataUrl" in attachment, - ); - if (uploads.length === 0) return stored; + const uploads = attachments.filter(isUploadChatAttachment); + const storedOnly = attachments.filter(isStoredChatAttachment); + if (uploads.length === 0) return storedOnly; + const result = yield* request(WS_METHODS.assetsPersistChatAttachments, { threadId, messageId, - attachments: uploads, + attachments: uploads.map(toUploadChatAttachmentPayload), }); - if (stored.length === 0) return result.attachments; - const byUpload = new Map( - uploads.map((attachment, index) => [attachment, result.attachments[index]]), - ); + + let uploadIndex = 0; return attachments.flatMap((attachment) => { - if ("id" in attachment) return [attachment]; - const persisted = byUpload.get(attachment); - return persisted === undefined ? [] : [persisted]; + if (isUploadChatAttachment(attachment)) { + const persisted = result.attachments[uploadIndex]; + uploadIndex += 1; + return persisted === undefined ? [] : [persisted]; + } + if (isStoredChatAttachment(attachment)) { + return [attachment]; + } + return []; }); }); @@ -449,13 +485,14 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( } const projection = yield* getProjection(input.threadId); - const activeRun = projection.runs.findLast( - (run) => - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting", - ); + let activeRun: (typeof projection.runs)[number] | undefined; + for (let index = projection.runs.length - 1; index >= 0; index -= 1) { + const run = projection.runs[index]; + if (run && isActiveRunStatus(run.status)) { + activeRun = run; + break; + } + } const requestedMode = input.dispatchMode ?? "auto"; const activeProviderThread = activeRun === undefined @@ -504,16 +541,15 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea input: InterruptThreadTurnInput, ) { const projection = yield* getProjection(input.threadId); - const runId = - input.runId ?? - (input.turnId as RunId | undefined) ?? - projection.runs.findLast( - (run) => - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting", - )?.id; + let latestActiveRunId: RunId | undefined; + for (let index = projection.runs.length - 1; index >= 0; index -= 1) { + const run = projection.runs[index]; + if (run && isActiveRunStatus(run.status)) { + latestActiveRunId = run.id; + break; + } + } + const runId = input.runId ?? (input.turnId as RunId | undefined) ?? latestActiveRunId; if (runId === undefined) return { sequence: 0 }; return yield* dispatch({ type: "run.interrupt", @@ -550,15 +586,23 @@ export const respondToThreadUserInput = Effect.fn("EnvironmentCommands.respondTo export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")( function* (input: RevertThreadCheckpointInput) { const projection = yield* getProjection(input.threadId); + let fallbackCheckpoint: (typeof projection.checkpoints)[number] | undefined; + for (let index = projection.checkpoints.length - 1; index >= 0; index -= 1) { + const candidate = projection.checkpoints[index]; + if ( + candidate && + (input.turnCount === 0 + ? candidate.ordinalWithinScope === 0 && candidate.appRunOrdinal === null + : candidate.appRunOrdinal === input.turnCount) + ) { + fallbackCheckpoint = candidate; + break; + } + } const checkpoint = projection.checkpoints.find( (candidate) => candidate.id === input.checkpointId && candidate.scopeId === input.scopeId, - ) ?? - projection.checkpoints.findLast((candidate) => - input.turnCount === 0 - ? candidate.ordinalWithinScope === 0 && candidate.appRunOrdinal === null - : candidate.appRunOrdinal === input.turnCount, - ); + ) ?? fallbackCheckpoint; if (checkpoint === undefined || checkpoint.status !== "ready") { const target = input.checkpointId === undefined diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 82a43350650..81d99efd0fd 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -165,10 +165,18 @@ export function inferProjectTitleFromPath(value: string): string { const normalized = normalizeProjectPathForDispatch(value); const absolutePath = splitAbsolutePath(normalized); if (absolutePath) { - return absolutePath.segments.findLast(Boolean) ?? normalized; + for (let index = absolutePath.segments.length - 1; index >= 0; index -= 1) { + const segment = absolutePath.segments[index]; + if (segment) return segment; + } + return normalized; } const segments = normalized.split(/[/\\]/); - return segments.findLast(Boolean) ?? normalized; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (segment) return segment; + } + return normalized; } export function appendBrowsePathSegment(currentPath: string, segment: string): string { diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index fadf4736fb7..7712d8c78dd 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -132,6 +132,82 @@ describe("environment shell synchronization", () => { }), ); + it.effect("restores live status after reconnect when no new shell events arrive", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_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, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => Effect.succeed(Option.none()), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "synchronizing"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + const state = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); + + expect(Option.getOrThrow(state).status).toBe("live"); + expect(Option.getOrThrow(Option.getOrThrow(state).snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + }), + ); + it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationV2ShellSnapshot = { diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index aed33f9e500..b45b4f7db5b 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -100,15 +100,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: "synchronizing" as const, error: Option.none(), })); - const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" - ? current - : { - ...current, - status: "synchronizing" as const, - error: Option.none(), - }, - ); + const setReady = SubscriptionRef.update(state, (current) => ({ + ...current, + status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const), + error: Option.none(), + })); const setStreamError = (error: unknown) => Effect.logWarning("Could not synchronize the environment shell.").pipe( Effect.annotateLogs({ diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 6d4ad38b87a..0b23434f853 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -31,10 +31,14 @@ export function deriveThreadCheckpointSummaries( ): ReadonlyArray { return projection.checkpoints.flatMap((checkpoint) => { if (checkpoint.appRunOrdinal === null || checkpoint.runId === null) return []; - const assistantMessageId = - projection.messages.findLast( - (message) => message.runId === checkpoint.runId && message.role === "assistant", - )?.id ?? null; + let assistantMessageId: MessageId | null = null; + for (let index = projection.messages.length - 1; index >= 0; index -= 1) { + const message = projection.messages[index]; + if (message?.runId === checkpoint.runId && message.role === "assistant") { + assistantMessageId = message.id; + break; + } + } return [ { checkpointId: checkpoint.id, diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index d302a90623b..ec006a34320 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -5,6 +5,17 @@ import type { ThreadRunSummary, ThreadRuntimeSummary } from "./models.ts"; const ACTIVE_RUN_STATUSES = new Set(["preparing", "queued", "starting", "running", "waiting"]); +function findLatestAssistantMessageIdForRun( + projection: OrchestrationV2ThreadProjection, + runId: OrchestrationV2ThreadProjection["runs"][number]["id"], +): OrchestrationV2ThreadProjection["messages"][number]["id"] | null { + for (let index = projection.messages.length - 1; index >= 0; index -= 1) { + const message = projection.messages[index]; + if (message?.runId === runId && message.role === "assistant") return message.id; + } + return null; +} + export function deriveLatestThreadRun( projection: OrchestrationV2ThreadProjection, ): ThreadRunSummary | null { @@ -20,10 +31,7 @@ export function deriveLatestThreadRun( requestedAt: DateTime.formatIso(run.requestedAt), startedAt: run.startedAt === null ? null : DateTime.formatIso(run.startedAt), completedAt: run.completedAt === null ? null : DateTime.formatIso(run.completedAt), - assistantMessageId: - projection.messages.findLast( - (message) => message.runId === run.id && message.role === "assistant", - )?.id ?? null, + assistantMessageId: findLatestAssistantMessageIdForRun(projection, run.id), ...(run.sourcePlanRef === undefined ? {} : { sourcePlanRef: run.sourcePlanRef }), }; } @@ -32,12 +40,23 @@ export function deriveThreadRuntime( projection: OrchestrationV2ThreadProjection, ): ThreadRuntimeSummary | null { const latestRun = deriveLatestThreadRun(projection); - const providerSession = projection.providerSessions.findLast( - (session) => session.providerInstanceId === projection.thread.providerInstanceId, - ); + let providerSession: (typeof projection.providerSessions)[number] | undefined; + for (let index = projection.providerSessions.length - 1; index >= 0; index -= 1) { + const session = projection.providerSessions[index]; + if (session?.providerInstanceId === projection.thread.providerInstanceId) { + providerSession = session; + break; + } + } if (latestRun === null && projection.thread.activeProviderThreadId === null) return null; - const activeRunId = - projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status))?.id ?? null; + let activeRunId: (typeof projection.runs)[number]["id"] | null = null; + for (let index = projection.runs.length - 1; index >= 0; index -= 1) { + const run = projection.runs[index]; + if (run && ACTIVE_RUN_STATUSES.has(run.status)) { + activeRunId = run.id; + break; + } + } return { status: latestRun?.status ?? "idle", activeRunId, diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index ad5e801ef9d..df76c719471 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -47,10 +47,14 @@ export function derivePendingThreadRequests( if (request.status !== "pending") continue; const responseCapability = request.responseCapability.type; if (request.kind === "user_input") { - const item = projection.turnItems.findLast( - (candidate) => - candidate.type === "user_input_request" && candidate.requestId === request.id, - ); + let item: (typeof projection.turnItems)[number] | undefined; + for (let index = projection.turnItems.length - 1; index >= 0; index -= 1) { + const candidate = projection.turnItems[index]; + if (candidate?.type === "user_input_request" && candidate.requestId === request.id) { + item = candidate; + break; + } + } if (item === undefined || item.type !== "user_input_request") continue; userInputs.push({ requestId: request.id, @@ -62,9 +66,14 @@ export function derivePendingThreadRequests( } if (request.kind === "auth_refresh" || request.kind === "dynamic_tool_call") continue; - const item = projection.turnItems.findLast( - (candidate) => candidate.type === "approval_request" && candidate.requestId === request.id, - ); + let item: (typeof projection.turnItems)[number] | undefined; + for (let index = projection.turnItems.length - 1; index >= 0; index -= 1) { + const candidate = projection.turnItems[index]; + if (candidate?.type === "approval_request" && candidate.requestId === request.id) { + item = candidate; + break; + } + } approvals.push({ requestId: request.id, requestKind: request.kind, diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts index 4fbceeb8c2c..64ca298f3a8 100644 --- a/packages/client-runtime/src/state/threadWorkflows.ts +++ b/packages/client-runtime/src/state/threadWorkflows.ts @@ -23,7 +23,11 @@ export interface ThreadQueueWorkflowState { } export function resolveActiveThreadRun(projection: Projection): Run | null { - return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null; + for (let index = projection.runs.length - 1; index >= 0; index -= 1) { + const run = projection.runs[index]; + if (run && ACTIVE_RUN_STATUSES.has(run.status)) return run; + } + return null; } export function resolveThreadProviderSession(projection: Projection): ProviderSession | null { @@ -43,30 +47,29 @@ export function resolveThreadProviderSession(projection: Projection): ProviderSe if (sessionId !== null) { return projection.providerSessions.find((session) => session.id === sessionId) ?? null; } - return ( - projection.providerSessions.findLast( - (session) => session.status !== "stopped" && session.status !== "error", - ) ?? null - ); + for (let index = projection.providerSessions.length - 1; index >= 0; index -= 1) { + const session = projection.providerSessions[index]; + if (session && session.status !== "stopped" && session.status !== "error") return session; + } + return null; } export function deriveThreadQueueWorkflowState(projection: Projection): ThreadQueueWorkflowState { const activeRun = resolveActiveThreadRun(projection); const session = resolveThreadProviderSession(projection); const capabilities = session?.capabilities.turns; - const queuedRuns = projection.runs - .filter((run) => run.status === "queued") - .toSorted( - (left, right) => - (left.queuePosition ?? left.ordinal) - (right.queuePosition ?? right.ordinal) || - left.ordinal - right.ordinal, - ) - .map((run) => ({ - run, - text: - projection.messages.find((message) => message.id === run.userMessageId)?.text ?? - "Queued message", - })); + const queued = projection.runs.filter((run) => run.status === "queued"); + queued.sort( + (left, right) => + (left.queuePosition ?? left.ordinal) - (right.queuePosition ?? right.ordinal) || + left.ordinal - right.ordinal, + ); + const queuedRuns = queued.map((run) => ({ + run, + text: + projection.messages.find((message) => message.id === run.userMessageId)?.text ?? + "Queued message", + })); return { activeRun, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 469b7150dfc..d0722383504 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -455,4 +455,39 @@ describe("EnvironmentThreads", () => { expect((yield* Ref.get(harness.latest)).status).toBe("live"); }), ); + + it.effect("restores live status after reconnect when no new thread events arrive", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_PROJECTION }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 2, + generation: 1, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "synchronizing"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 2, + generation: 2, + lastFailure: null, + retryAt: null, + }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + const latest = yield* Ref.get(harness.latest); + expect(latest.status).toBe("live"); + expect(Option.getOrThrow(latest.data)).toEqual(BASE_PROJECTION); + }), + ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index ce9227be532..d4a2e7e84cc 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; +import { causeFailureMessage } from "../errors/causeMessage.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; @@ -37,10 +38,7 @@ function statusWithoutLiveData( } 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* ( @@ -103,11 +101,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make error: Option.none(), })); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "deleted" ? current : { ...current, - status: "synchronizing" as const, + status: Option.isSome(current.data) ? ("live" as const) : ("synchronizing" as const), error: Option.none(), }, ); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2345a98f269..ad3f5300511 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -80,12 +80,14 @@ export function getModelSelectionBooleanOptionValue( function canonicalModelSelectionOptions( modelSelection: ModelSelection, ): ReadonlyArray { - return (modelSelection.options ?? []) - .map((selection) => [selection.id, selection.value] as const) - .toSorted(([leftId, leftValue], [rightId, rightValue]) => { - const idOrder = leftId.localeCompare(rightId); - return idOrder !== 0 ? idOrder : String(leftValue).localeCompare(String(rightValue)); - }); + const options = (modelSelection.options ?? []).map( + (selection) => [selection.id, selection.value] as const, + ); + options.sort(([leftId, leftValue], [rightId, rightValue]) => { + const idOrder = leftId.localeCompare(rightId); + return idOrder !== 0 ? idOrder : String(leftValue).localeCompare(String(rightValue)); + }); + return options; } /** diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch index 686ea249b7a..92701a64af3 100644 --- a/patches/@legendapp__list@3.2.0.patch +++ b/patches/@legendapp__list@3.2.0.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 5a115ea..2c65d31 100644 +index 5a115ea2b..2c65d3158 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { @@ -23,7 +23,7 @@ index 5a115ea..2c65d31 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 +index 736286a3f..fbf1814f7 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -50,7 +50,7 @@ index 736286a..8218172 100644 ); React.useLayoutEffect(() => { var _a; -@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -84,14 +84,19 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -62,7 +62,15 @@ index 736286a..8218172 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...rest + } = props; ++ // Keyboard scroll math must respect the list's adjusted top inset or short ++ // content gets pinned at offset 0 under a translucent header. ++ const contentInsetStartCompensation = typeof rest.contentInsetStartAdjustment === "number" && Number.isFinite(rest.contentInsetStartAdjustment) ? Math.max(0, rest.contentInsetStartAdjustment) : 0; + const refLegendList = React.useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const blankSpace = reactNativeReanimated.useSharedValue(0); +@@ -109,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( includeInEndInset: true, onSizeChanged: (size) => { var _a; @@ -80,15 +88,18 @@ index 736286a..8218172 100644 const onContentInsetChange = React.useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -124,8 +133,10 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, ++ contentInsetStartCompensation, extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + freeze, + keyboardLiftBehavior, +@@ -135,9 +146,11 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -96,7 +107,11 @@ index 736286a..8218172 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ++ contentInsetStartCompensation, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -149,6 +162,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -105,7 +120,7 @@ index 736286a..8218172 100644 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 +index c1dd27020..1df18da00 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { @@ -132,7 +147,7 @@ index c1dd270..cb0d142 100644 ); useLayoutEffect(() => { var _a; -@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -63,14 +63,19 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -144,7 +159,15 @@ index c1dd270..cb0d142 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...rest + } = props; ++ // Keyboard scroll math must respect the list's adjusted top inset or short ++ // content gets pinned at offset 0 under a translucent header. ++ const contentInsetStartCompensation = typeof rest.contentInsetStartAdjustment === "number" && Number.isFinite(rest.contentInsetStartAdjustment) ? Math.max(0, rest.contentInsetStartAdjustment) : 0; + const refLegendList = useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const blankSpace = useSharedValue(0); +@@ -88,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( includeInEndInset: true, onSizeChanged: (size) => { var _a; @@ -162,15 +185,18 @@ index c1dd270..cb0d142 100644 const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -103,8 +112,10 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, ++ contentInsetStartCompensation, extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + freeze, + keyboardLiftBehavior, +@@ -114,9 +125,11 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -178,7 +204,11 @@ index c1dd270..cb0d142 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ++ contentInsetStartCompensation, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -128,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -187,7 +217,7 @@ index c1dd270..cb0d142 100644 renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index 72d3f59..435a5fc 100644 +index 72d3f599b..435a5fcde 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -284,6 +284,12 @@ interface LegendListSpecificProps { @@ -204,10 +234,26 @@ index 72d3f59..435a5fc 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 8d4ff89..18f0d62 100644 +index 8d4ff89d9..37b48722b 100644 --- a/react-native.js +++ b/react-native.js -@@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { +@@ -986,13 +986,14 @@ function checkAtBottom(ctx) { + if (contentSize > 0 && queuedInitialLayout) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const distanceFromScrollEnd = contentSize - scroll - scrollLength; + const isContentLess = contentSize < scrollLength; + set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, + "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ isContentLess || distanceFromScrollEnd <= maintainScrollAtEndThreshold * scrollLength + ); + const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; + if (!shouldSkipThresholdChecks) { +@@ -1195,7 +1196,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +262,7 @@ index 8d4ff89..18f0d62 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal"); -@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1480,18 +1481,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +288,7 @@ index 8d4ff89..18f0d62 100644 return clampedOffset; } -@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1626,10 +1632,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,7 +301,7 @@ index 8d4ff89..18f0d62 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1676,7 +1682,10 @@ function checkFinishedScrollFallback(ctx) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -267,7 +313,7 @@ index 8d4ff89..18f0d62 100644 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1737,9 +1746,18 @@ function doMaintainScrollAtEnd(ctx) { } state.pendingMaintainScrollAtEnd = false; if (shouldMaintainScrollAtEnd) { @@ -287,7 +333,7 @@ index 8d4ff89..18f0d62 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1759,9 +1777,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +355,7 @@ index 8d4ff89..18f0d62 100644 } setTimeout( () => { -@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1888,7 +1915,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -320,7 +366,7 @@ index 8d4ff89..18f0d62 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1950,7 +1979,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -329,7 +375,7 @@ index 8d4ff89..18f0d62 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -2083,7 +2112,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -338,7 +384,7 @@ index 8d4ff89..18f0d62 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { +@@ -2374,8 +2403,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -460,7 +506,7 @@ index 8d4ff89..18f0d62 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2804,7 +2946,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -471,7 +517,7 @@ index 8d4ff89..18f0d62 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4646,7 +4790,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -481,7 +527,7 @@ index 8d4ff89..18f0d62 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4664,6 +4809,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -494,7 +540,7 @@ index 8d4ff89..18f0d62 100644 } return nextSize; } -@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6462,6 +6613,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -502,7 +548,7 @@ index 8d4ff89..18f0d62 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6492,6 +6644,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -510,7 +556,7 @@ index 8d4ff89..18f0d62 100644 onRefresh, onScroll: onScrollProp, onStartReached, -@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6710,6 +6863,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -518,7 +564,7 @@ index 8d4ff89..18f0d62 100644 data: dataProp, dataVersion, drawDistance, -@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6789,6 +6943,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -532,7 +578,7 @@ index 8d4ff89..18f0d62 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6995,6 +7156,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onMomentumScrollEnd(event); } }, @@ -545,7 +591,7 @@ index 8d4ff89..18f0d62 100644 onScroll: (event) => onScroll(ctx, event) }), [] -@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7019,6 +7186,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -554,10 +600,26 @@ index 8d4ff89..18f0d62 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 2e96ca7..6e8913e 100644 +index 2e96ca740..10ea1d456 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { +@@ -965,13 +965,14 @@ function checkAtBottom(ctx) { + if (contentSize > 0 && queuedInitialLayout) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const distanceFromScrollEnd = contentSize - scroll - scrollLength; + const isContentLess = contentSize < scrollLength; + set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, + "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ isContentLess || distanceFromScrollEnd <= maintainScrollAtEndThreshold * scrollLength + ); + const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; + if (!shouldSkipThresholdChecks) { +@@ -1174,7 +1175,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -566,7 +628,7 @@ index 2e96ca7..6e8913e 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal"); -@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1459,18 +1460,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -592,7 +654,7 @@ index 2e96ca7..6e8913e 100644 return clampedOffset; } -@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1605,10 +1611,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -605,7 +667,7 @@ index 2e96ca7..6e8913e 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1655,7 +1661,10 @@ function checkFinishedScrollFallback(ctx) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -617,7 +679,7 @@ index 2e96ca7..6e8913e 100644 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1716,9 +1725,18 @@ function doMaintainScrollAtEnd(ctx) { } state.pendingMaintainScrollAtEnd = false; if (shouldMaintainScrollAtEnd) { @@ -637,7 +699,7 @@ index 2e96ca7..6e8913e 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1738,9 +1756,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -659,7 +721,7 @@ index 2e96ca7..6e8913e 100644 } setTimeout( () => { -@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1867,7 +1894,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -670,7 +732,7 @@ index 2e96ca7..6e8913e 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1929,7 +1958,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -679,7 +741,7 @@ index 2e96ca7..6e8913e 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -2062,7 +2091,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -688,7 +750,7 @@ index 2e96ca7..6e8913e 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { +@@ -2353,8 +2382,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -810,7 +872,7 @@ index 2e96ca7..6e8913e 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2783,7 +2925,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -821,7 +883,7 @@ index 2e96ca7..6e8913e 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4625,7 +4769,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -831,7 +893,7 @@ index 2e96ca7..6e8913e 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4643,6 +4788,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -844,7 +906,7 @@ index 2e96ca7..6e8913e 100644 } return nextSize; } -@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6441,6 +6592,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -852,7 +914,7 @@ index 2e96ca7..6e8913e 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6471,6 +6623,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -860,7 +922,7 @@ index 2e96ca7..6e8913e 100644 onRefresh, onScroll: onScrollProp, onStartReached, -@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6689,6 +6842,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -868,7 +930,7 @@ index 2e96ca7..6e8913e 100644 data: dataProp, dataVersion, drawDistance, -@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6768,6 +6922,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -882,7 +944,7 @@ index 2e96ca7..6e8913e 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6974,6 +7135,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onMomentumScrollEnd(event); } }, @@ -895,7 +957,7 @@ index 2e96ca7..6e8913e 100644 onScroll: (event) => onScroll(ctx, event) }), [] -@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6998,6 +7165,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -904,7 +966,7 @@ index 2e96ca7..6e8913e 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts -index 7e2d11f..d5b0d66 100644 +index 7e2d11ffd..d5b0d66d4 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -285,6 +285,12 @@ interface LegendListSpecificProps { diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index cd2b86fb76c..1ec4d978529 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -1,3 +1,5 @@ +diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js +index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644 --- a/lib/module/views/useHeaderConfigProps.js +++ b/lib/module/views/useHeaderConfigProps.js @@ -19,6 +19,12 @@ diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch index 3dee935cda3..a8a505282a4 100644 --- a/patches/react-native-keyboard-controller@1.21.13.patch +++ b/patches/react-native-keyboard-controller@1.21.13.patch @@ -1,12 +1,48 @@ +diff --git a/ios/views/ClippingScrollViewDecoratorViewManager.mm b/ios/views/ClippingScrollViewDecoratorViewManager.mm +index d960900fa..26101809e 100644 +--- a/ios/views/ClippingScrollViewDecoratorViewManager.mm ++++ b/ios/views/ClippingScrollViewDecoratorViewManager.mm +@@ -78,6 +78,30 @@ static void KCApplyNoopScrollRectToVisible(UIScrollView *scrollView) + method_getTypeEncoding(original)); + } + ++ // Applying a new raw contentInset makes UIKit re-clamp contentOffset against ++ // the raw (not adjusted) insets, which snaps chat lists resting at a negative ++ // offset under a translucent header back to 0. The JS side owns offset ++ // management here, so preserve the offset across inset applications. ++ Method originalSetInset = class_getInstanceMethod(originalClass, @selector(setContentInset:)); ++ if (originalSetInset) { ++ void (*callOriginalSetInset)(id, SEL, UIEdgeInsets) = ++ (void (*)(id, SEL, UIEdgeInsets))method_getImplementation(originalSetInset); ++ IMP preserveOffsetImp = imp_implementationWithBlock( ++ ^(__unsafe_unretained UIScrollView *self, UIEdgeInsets inset) { ++ CGPoint before = self.contentOffset; ++ callOriginalSetInset(self, @selector(setContentInset:), inset); ++ if (!CGPointEqualToPoint(before, self.contentOffset) && !self.isDragging && ++ !self.isDecelerating) { ++ self.contentOffset = before; ++ } ++ }); ++ class_addMethod( ++ subclass, ++ @selector(setContentInset:), ++ preserveOffsetImp, ++ method_getTypeEncoding(originalSetInset)); ++ } ++ + objc_registerClassPair(subclass); + } + diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 +index db8cfb1d2..d4c99886b 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -26,9 +26,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ contentInsetStartCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -14,13 +50,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 onEndVisible, ...rest }, ref) => { -@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -50,13 +53,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ freeze: freezeSV, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation }); (0, _useExtraContentPadding.useExtraContentPadding)({ scrollViewRef, @@ -28,10 +65,11 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation, scroll, layout, size, -@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -82,10 +89,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ // a bug for you, please open an issue. const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); @@ -54,21 +92,52 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 const onLayout = (0, _react.useCallback)(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +index 4d33b3fe4..ea710c1ca 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight, + * ``` + */ + exports.clampedScrollTarget = clampedScrollTarget; +-const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => { ++const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInset = 0) => { + "worklet"; + + const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; +@@ -214,8 +214,13 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, + const maxScroll = Math.max(contentHeight - layoutHeight, 0); + return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); + } +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ // Non-inverted lists under an adjusted top inset (translucent header / ++ // automatic safe-area) rest at a NEGATIVE offset (-startInset). Flooring at ++ // 0 would force short content up under the header and strand it there, ++ // because the offset can never return below 0 through this math. ++ const minScroll = -startInset; ++ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); ++ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); + }; + exports.computeIOSContentOffset = computeIOSContentOffset; + //# sourceMappingURL=helpers.js.map +\ No newline at end of file diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 +index 2073da84b..0c9ad761e 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation = 0 } = options; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -66,7 +68,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); @@ -77,28 +146,62 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e // persistent mode: when keyboard shrinks, clamp to valid range if (keyboardLiftBehavior === "persistent" && effective < padding.value) { -@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -76,8 +78,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -91,8 +94,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -118,7 +122,7 @@ function useChatKeyboard(scrollViewRef, options) { + contentOffsetY.value = scroll.value; + return; + } +- contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, contentInsetStartCompensation); + }, + onMove: () => { + "worklet"; +@@ -134,7 +138,7 @@ function useChatKeyboard(scrollViewRef, options) { const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, contentInsetStartCompensation]); return { padding, currentHeight, diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644 +index 0d50bdfeb..d20279bcf 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -29,6 +29,7 @@ function useExtraContentPadding(options) { +@@ -29,6 +29,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, scroll, layout, size, -@@ -68,8 +69,8 @@ function useExtraContentPadding(options) { +@@ -68,8 +70,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -109,17 +212,26 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -92,6 +93,6 @@ function useExtraContentPadding(options) { - const target = Math.min(scroll.value + effectiveDelta, maxScroll); +@@ -88,10 +90,13 @@ function useExtraContentPadding(options) { + const target = Math.max(scroll.value - effectiveDelta, -currentTotal); + scrollToTarget(target); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); ++ // Respect the adjusted top inset: short content rests at a negative ++ // offset (-startInset), and the scrollable range never extends past it. ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); ++ const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), minScroll); scrollToTarget(target); } - }, [inverted, keyboardLiftBehavior]); -+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, contentInsetStartCompensation]); } //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644 +index 612dd8bd9..7daa4ede5 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js @@ -1,7 +1,7 @@ @@ -131,11 +243,12 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 import Reanimated from "react-native-reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -19,9 +19,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ contentInsetStartCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -143,13 +256,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 onEndVisible, ...rest }, ref) => { -@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -43,13 +46,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ freeze: freezeSV, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation }); useExtraContentPadding({ scrollViewRef, @@ -157,10 +271,11 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation, scroll, layout, size, -@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -75,10 +82,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ // a bug for you, please open an issue. const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); @@ -183,21 +298,51 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 const onLayout = useCallback(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +index 295e2219b..aa5d7b165 100644 +--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js ++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH + * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 + * ``` + */ +-export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => { ++export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInset = 0) => { + "worklet"; + + const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; +@@ -201,7 +201,12 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH + const maxScroll = Math.max(contentHeight - layoutHeight, 0); + return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); + } +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ // Non-inverted lists under an adjusted top inset (translucent header / ++ // automatic safe-area) rest at a NEGATIVE offset (-startInset). Flooring at ++ // 0 would force short content up under the header and strand it there, ++ // because the offset can never return below 0 through this math. ++ const minScroll = -startInset; ++ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); ++ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); + }; + //# sourceMappingURL=helpers.js.map +\ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 +index 52943c3a7..139fa4dd5 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ contentInsetStartCompensation = 0 } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -59,7 +61,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); @@ -206,28 +351,62 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669 // persistent mode: when keyboard shrinks, clamp to valid range if (keyboardLiftBehavior === "persistent" && effective < padding.value) { -@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -69,8 +71,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -84,8 +87,9 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, minScroll); ++ contentOffsetY.value = Math.max(minScroll, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -111,7 +115,7 @@ function useChatKeyboard(scrollViewRef, options) { + contentOffsetY.value = scroll.value; + return; + } +- contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, contentInsetStartCompensation); + }, + onMove: () => { + "worklet"; +@@ -127,7 +131,7 @@ function useChatKeyboard(scrollViewRef, options) { const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, contentInsetStartCompensation]); return { padding, currentHeight, diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644 +index 1afa50987..8c1e4c02c 100644 --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -23,6 +23,7 @@ function useExtraContentPadding(options) { +@@ -23,6 +23,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, scroll, layout, size, -@@ -62,8 +63,8 @@ function useExtraContentPadding(options) { +@@ -62,8 +64,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -238,57 +417,88 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -86,7 +87,7 @@ function useExtraContentPadding(options) { - const target = Math.min(scroll.value + effectiveDelta, maxScroll); +@@ -82,11 +84,14 @@ function useExtraContentPadding(options) { + const target = Math.max(scroll.value - effectiveDelta, -currentTotal); + scrollToTarget(target); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); ++ // Respect the adjusted top inset: short content rests at a negative ++ // offset (-startInset), and the scrollable range never extends past it. ++ const minScroll = -contentInsetStartCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); ++ const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), minScroll); scrollToTarget(target); } - }, [inverted, keyboardLiftBehavior]); -+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, contentInsetStartCompensation]); } export { useExtraContentPadding }; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644 +index a036b431f..49a6cad04 100644 --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = { +@@ -86,6 +86,18 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ + adjustedInsetCompensation?: number; ++ /** ++ * Adjusted TOP inset UIKit applies to this scroll view (safe area / ++ * translucent header under `contentInsetAdjustmentBehavior="automatic"`). ++ * Non-inverted lists rest at `-contentInsetStartCompensation`, so all ++ * scroll-offset clamps floor there instead of 0. Used ONLY in scroll-offset ++ * math — never written into `contentInset`. ++ * ++ * Default is `0`. ++ */ ++ contentInsetStartCompensation?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. +diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +index 2b51cc473..8e4b08345 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH + * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 + * ``` + */ +-export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; ++export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInset?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 +index aff9b5a8d..cf6d63741 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = { +@@ -9,6 +9,9 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; + /** Safe-area extra beyond raw contentInset. Offset math only. */ + adjustedInsetCompensation: number; ++ contentInsetStartCompensation?: number; }; type UseChatKeyboardReturn = { /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts -index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644 +index ec73f7054..85ba62f04 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts -@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = { +@@ -8,6 +8,9 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; + /** Safe-area extra beyond raw contentInset. Offset math only. */ + adjustedInsetCompensation: number; ++ contentInsetStartCompensation?: number; /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644 +index 03f5f74e9..bc67b7b9c 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx @@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; @@ -300,11 +510,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d useAnimatedRef, useAnimatedStyle, useDerivedValue, -@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< +@@ -35,9 +37,12 @@ const KeyboardChatScrollView = forwardRef< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ contentInsetStartCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -312,23 +523,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d onEndVisible, ...rest }, -@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< +@@ -64,6 +69,8 @@ const KeyboardChatScrollView = forwardRef< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, ++ contentInsetStartCompensation, }); useExtraContentPadding({ -@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< +@@ -71,6 +78,8 @@ const KeyboardChatScrollView = forwardRef< extraContentPadding, keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation, scroll, layout, size, -@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef< +@@ -102,13 +111,25 @@ const KeyboardChatScrollView = forwardRef< ), ); @@ -360,10 +573,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d (e: LayoutChangeEvent) => { onLayoutInternal(e); diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts -index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 +index dd222b57b..1a4a308d4 100644 --- a/src/components/KeyboardChatScrollView/types.ts +++ b/src/components/KeyboardChatScrollView/types.ts -@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { +@@ -90,6 +90,25 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; @@ -376,22 +589,74 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb + * Default is `0`. + */ + adjustedInsetCompensation?: number; ++ /** ++ * Adjusted TOP inset UIKit applies to this scroll view (safe area / ++ * translucent header under `contentInsetAdjustmentBehavior="automatic"`). ++ * Non-inverted lists rest at `-contentInsetStartCompensation`, so all ++ * scroll-offset clamps floor there instead of 0. Used ONLY in scroll-offset ++ * math — never written into `contentInset`. ++ * ++ * Default is `0`. ++ */ ++ contentInsetStartCompensation?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +index 37eacd108..0e47c74a8 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +@@ -232,6 +232,7 @@ export const clampedScrollTarget = ( + * @param layoutHeight - Visible height of the scroll view. + * @param inverted - Whether the list is inverted. + * @param totalPaddingForMaxScroll - Total padding to use for maxScroll calculation. When provided, used instead of keyboardHeight for the scrollable range. Defaults to keyboardHeight. ++ * @param startInset - Adjusted top inset; non-inverted offsets floor at `-startInset` instead of 0. + * @returns The absolute contentOffset.y to set. + * @example + * ```ts +@@ -245,6 +246,7 @@ export const computeIOSContentOffset = ( + layoutHeight: number, + inverted: boolean, + totalPaddingForMaxScroll?: number, ++ startInset: number = 0, + ): number => { + "worklet"; + +@@ -262,7 +264,18 @@ export const computeIOSContentOffset = ( + ); + } + +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); ++ // Non-inverted lists under an adjusted top inset (translucent header / ++ // automatic safe-area) rest at a NEGATIVE offset (-startInset). Flooring at ++ // 0 would force short content up under the header and strand it there, ++ // because the offset can never return below 0 through this math. ++ const minScroll = -startInset; ++ const maxScroll = Math.max( ++ contentHeight - layoutHeight + paddingForMax, ++ minScroll, ++ ); + +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ return Math.min( ++ Math.max(keyboardHeight + relativeScroll, minScroll), ++ maxScroll, ++ ); + }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 +index 560df54ba..ef8b4ec8e 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -@@ -43,6 +43,7 @@ function useChatKeyboard( +@@ -43,6 +43,8 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, } = options; const padding = useSharedValue(0); -@@ -104,10 +105,12 @@ function useChatKeyboard( +@@ -104,10 +106,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -408,50 +673,95 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670 // persistent mode: when keyboard shrinks, clamp to valid range if ( -@@ -242,7 +245,7 @@ function useChatKeyboard( +@@ -128,13 +132,14 @@ function useChatKeyboard( + Math.min(scroll.value, maxScroll), + ); + } else { ++ const minScroll = -contentInsetStartCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + actualTotalPadding, +- 0, ++ minScroll, + ); + + contentOffsetY.value = Math.max( +- 0, ++ minScroll, + Math.min(scroll.value, maxScroll), + ); + } +@@ -163,13 +168,14 @@ function useChatKeyboard( + Math.min(scroll.value, maxScroll), + ); + } else { ++ const minScroll = -contentInsetStartCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + actualTotalPadding, +- 0, ++ minScroll, + ); + + contentOffsetY.value = Math.max( +- 0, ++ minScroll, + Math.min(scroll.value, maxScroll), + ); + } +@@ -219,6 +225,7 @@ function useChatKeyboard( + layout.value.height, + inverted, + actualTotalPadding, ++ contentInsetStartCompensation, + ); + }, + onMove: () => { +@@ -242,7 +249,7 @@ function useChatKeyboard( padding.value = effective; }, }, - [inverted, keyboardLiftBehavior, offset, extraContentPadding], -+ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation], ++ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, contentInsetStartCompensation], ); return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 +index 02abf5cd9..cd18b205d 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,9 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ + adjustedInsetCompensation: number; ++ contentInsetStartCompensation?: number; }; type UseChatKeyboardReturn = { diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 +index 833acbe78..143baf951 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { +@@ -16,6 +16,9 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ + adjustedInsetCompensation: number; ++ contentInsetStartCompensation?: number; /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ -@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -49,6 +52,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ contentInsetStartCompensation = 0, scroll, layout, size, -@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -97,14 +102,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { } // Compute effective delta considering blankSpace floor @@ -472,12 +782,29 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { -@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -137,16 +140,22 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + + scrollToTarget(target); + } else { ++ // Respect the adjusted top inset: short content rests at a negative ++ // offset (-startInset), and the scrollable range never extends past it. ++ const minScroll = -contentInsetStartCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + currentTotal, +- 0, ++ minScroll, ++ ); ++ const target = Math.max( ++ Math.min(scroll.value + effectiveDelta, maxScroll), ++ minScroll, + ); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); } }, - [inverted, keyboardLiftBehavior], -+ [inverted, keyboardLiftBehavior, adjustedInsetCompensation], ++ [inverted, keyboardLiftBehavior, adjustedInsetCompensation, contentInsetStartCompensation], ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17be82f7e4c..abd23ba668e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,14 +70,14 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 + '@legendapp/list@3.2.0': 29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae - '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca + '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 - react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + react-native-keyboard-controller@1.21.13: 001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 @@ -206,7 +206,7 @@ importers: version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -227,7 +227,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -359,7 +359,7 @@ importers: version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.13 - version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -526,7 +526,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) @@ -12767,7 +12767,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) @@ -12775,7 +12775,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) @@ -13775,7 +13775,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) @@ -19071,7 +19071,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(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-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.13(patch_hash=001d3934d3afc439db85f6918a5816301e50689d701d7ffbc86ece2abba767b7)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)