From e0e2a061c75d4e7e1ff6c17409d2ad478ec93e28 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 12:27:59 +0200 Subject: [PATCH 1/4] fix(mobile): pin thread composer to bottom and solid scroll chip KeyboardStickyView was absolutely positioned with bottom:0, so a stale keyboard height left the input floating mid-feed. Host it in a full-screen column instead, wrap the route body in a flex-1 container, opaque the composer blend, and give Scroll to latest a real card background (bg-background was not a theme token). --- .../src/features/threads/ThreadComposer.tsx | 7 +- .../features/threads/ThreadDetailScreen.tsx | 146 +++++++++--------- .../src/features/threads/ThreadFeed.tsx | 15 +- 3 files changed, 95 insertions(+), 73 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a6cdddead33..6faf9941cab 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -786,9 +786,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer style={{ paddingTop: isExpanded ? 8 : 6, paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), + // Keep the top soft for a short blend into the feed, but make the + // lower band nearly opaque so timeline rows never read as sitting + // *inside* the composer chrome. experimental_backgroundImage: isDarkMode - ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)" - : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", + ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.82) 42%, rgba(0,0,0,0.96) 100%)" + : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.88) 42%, rgba(255,255,255,0.98) 100%)", }} > )} - {/* Floating composer — sticks to keyboard via KeyboardStickyView */} + {/* + Pin the composer to the bottom of a full-screen overlay host. + KeyboardStickyView only applies translateY for the IME — it must sit in a + full-height column (not `position: absolute; bottom: 0` on itself), or a + stale keyboard height leaves the input floating mid-thread with the feed + scrolling behind it. + */} {showContent ? ( - - {/* No paddingTop here: the overlay's measured height becomes the - list's bottom inset, so any padding above the pill/composer - pushes the resting content floor up by the same amount. */} - - - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} - + + + + {/* No paddingTop here: the overlay's measured height becomes the + list's bottom inset, so any padding above the pill/composer + pushes the resting content floor up by the same amount. */} + + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + - - - + + + + ) : null} ); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2cc16f12f32..b1a8424e299 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1418,8 +1418,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? navigationHeaderHeight || insets.top + 44 : topContentInset; + const isDarkMode = useColorScheme() === "dark"; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); + const scrollToLatestBackground = useThemeColor("--color-card"); const onMarkdownLinkPress = useCallback( (href: string) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -1963,7 +1965,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } accessibilityRole="button" onPress={scrollToLatest} - className="flex-row items-center gap-1.5 rounded-full border border-border bg-background px-3 py-2 shadow-sm active:opacity-70" + // Use the real card token — `bg-background` is not defined in the + // mobile theme, so the chip rendered as a transparent outline and + // looked like floating text over the feed. + className="flex-row items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 active:opacity-70" + style={{ + backgroundColor: String(scrollToLatestBackground), + shadowColor: "#000000", + shadowOpacity: isDarkMode ? 0.35 : 0.14, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 6, + }} > Date: Sun, 26 Jul 2026 12:28:24 +0200 Subject: [PATCH 2/4] fix(mobile): give thread route a flex host for composer anchor Wrap the thread route body in a flex-1 View instead of a fragment so the composer overlay always measures against the full content column. --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 74e4588c84d..7baddde7f5f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -762,12 +762,14 @@ function ThreadRouteContent( }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const renderThreadRouteBody = (showActionControls: boolean) => ( - <> + // A real flex host (not a fragment) keeps the thread body filling the + // screen so the absolute composer overlay anchors to the true bottom. + - + - + ); return ( From 7c29f412536fa5bbdc00897821f734912d690a81 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 12:59:15 +0200 Subject: [PATCH 3/4] fix(mobile): use StyleSheet.absoluteFill for RN types absoluteFillObject is not in the React Native StyleSheet typings used here. --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e4ffcdfc2c1..c962135d2c6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -394,7 +394,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread scrolling behind it. */} {showContent ? ( - + {/* No paddingTop here: the overlay's measured height becomes the From 185d4bfc59f36509eb3fd74c1a67a0b286f8871d Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sun, 26 Jul 2026 13:01:37 +0200 Subject: [PATCH 4/4] perf(mobile): prefetch thread detail on list press and reduce feed remounts Start SQLite/HTTP/WS hydrate on press-in and keep the last selected thread warm so open no longer waits for the route to mount cold. Avoid remounting the feed when detail briefly empties after the first filled paint. --- .../src/features/home/HomeRouteScreen.tsx | 5 ++ .../layout/AdaptiveWorkspaceLayout.tsx | 4 ++ .../src/features/threads/ThreadFeed.tsx | 21 +++++---- .../features/threads/thread-list-items.tsx | 7 +++ .../features/threads/thread-list-v2-items.tsx | 7 +++ apps/mobile/src/state/threads.ts | 46 +++++++++++++++++++ 6 files changed, 82 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..f8649280c37 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; +import { prefetchEnvironmentThread, warmSelectedEnvironmentThread } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -147,6 +148,10 @@ export function HomeRouteScreen() { onSelectThread={(thread) => { // Settled threads are live shells: opening one is plain // navigation, and sending a message un-settles server-side. + // Warm detail (SQLite/HTTP) before the route mounts so open + // latency overlaps the stack transition. + prefetchEnvironmentThread(thread.environmentId, thread.id); + warmSelectedEnvironmentThread(thread.environmentId, thread.id); navigation.navigate("Thread", { environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9c068c6249c..67e56112770 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -37,6 +37,7 @@ import { import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { mobilePreferencesAtom } from "../../state/preferences"; +import { prefetchEnvironmentThread, warmSelectedEnvironmentThread } from "../../state/threads"; import { parseActiveThreadPath, useHardwareKeyboardCommand, @@ -478,6 +479,9 @@ function AdaptiveWorkspaceLayoutContent( environmentId: String(thread.environmentId), threadId: String(thread.id), }; + // Overlap SQLite/HTTP detail hydrate with navigation / setParams. + prefetchEnvironmentThread(thread.environmentId, thread.id); + warmSelectedEnvironmentThread(thread.environmentId, thread.id); const navigationAction = resolveThreadSelectionNavigationAction({ usesSplitView: layout.usesSplitView, pathname, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b1a8424e299..1e7a623ca0e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1584,14 +1584,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.listRef.current?.scrollToEnd({ animated: true }); }, [props.listRef]); - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above - // the remount boundary) deduplicates by height, so it never re-reports the - // composer inset to the fresh instance. Without this, the remounted list's - // initial scroll-to-end computes with a zero end inset and rests one - // composer-height short of the end. Layout effect: it must land before the - // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + // Remount empty→filled once per thread open so initialScrollAtEnd lands under + // automatic insets. After the first filled mount for this threadId, keep the + // filled key even if the feed briefly empties during sync — remounting then + // feels like "conversation cleared and reloaded from scratch". + const listMountThreadIdRef = useRef(props.threadId); + const sawFilledFeedRef = useRef(props.feed.length > 0); + if (listMountThreadIdRef.current !== props.threadId) { + listMountThreadIdRef.current = props.threadId; + sawFilledFeedRef.current = props.feed.length > 0; + } else if (props.feed.length > 0) { + sawFilledFeedRef.current = true; + } + const listMountKey = `${props.threadId}:${sawFilledFeedRef.current ? "filled" : "empty"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 8e2c368b926..1c0f5dddf8d 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -18,6 +18,7 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentServerConfig } from "../../state/entities"; +import { prefetchEnvironmentThread } from "../../state/threads"; import { useAiUsageSnapshot } from "../../state/useAiUsageSnapshot"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; @@ -561,6 +562,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" className="bg-screen" + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); @@ -618,6 +622,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityState={{ selected }} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 69729cb6469..f06f850b0a9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -15,6 +15,7 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadPr } from "../../state/use-thread-pr"; +import { prefetchEnvironmentThread } from "../../state/threads"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; @@ -326,6 +327,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel={thread.title} accessibilityRole="button" accessibilityState={{ selected }} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); @@ -365,6 +369,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityRole="button" accessibilityState={{ selected }} className={sidebarPane ? undefined : "bg-screen"} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} onPress={() => { close(); onSelectThread(thread); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 7f247123051..7bfb9adc74c 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -13,6 +13,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); @@ -29,6 +30,51 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ Atom.withLabel("mobile-environment-thread:empty"), ); +/** Keep the last selected thread detail stream mounted so reopen/back-nav is warm. */ +let warmThreadUnmount: (() => void) | null = null; +const pressPrefetchTimers = new Map>(); + +function threadPrefetchKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return `${environmentId}\u0000${threadId}`; +} + +/** + * Start loading thread detail (SQLite → HTTP snapshot → WS resume) before the + * Thread route mounts. Call from list press-in / selection so open latency + * overlaps the navigation transition. + */ +export function prefetchEnvironmentThread(environmentId: EnvironmentId, threadId: ThreadId): void { + const key = threadPrefetchKey(environmentId, threadId); + const existingTimer = pressPrefetchTimers.get(key); + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + } + // Mount kicks off makeEnvironmentThreadState. Hold briefly so navigate can + // attach useAtomValue; the Thread screen then keeps the same atom alive. + const unmount = appAtomRegistry.mount(environmentThreads.stateAtom(environmentId, threadId)); + const timer = setTimeout(() => { + pressPrefetchTimers.delete(key); + unmount(); + }, 15_000); + pressPrefetchTimers.set(key, timer); +} + +/** + * Hold the selected thread's detail atom mounted while the user stays in the + * app session, so returning from the list does not re-run a cold full hydrate. + * Replaces any previous warm hold. + */ +export function warmSelectedEnvironmentThread( + environmentId: EnvironmentId, + threadId: ThreadId, +): void { + const atom = environmentThreads.stateAtom(environmentId, threadId); + const nextUnmount = appAtomRegistry.mount(atom); + const previous = warmThreadUnmount; + warmThreadUnmount = nextUnmount; + previous?.(); +} + export function useEnvironmentThread( environmentId: EnvironmentId | null, threadId: ThreadId | null,