Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%)",
}}
>
<Animated.View
Expand Down
146 changes: 76 additions & 70 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
} from "@t3tools/contracts";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { Platform, StyleSheet, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -386,77 +386,83 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
<View className="flex-1" />
)}

{/* 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 ? (
<KeyboardStickyView
style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
offset={{ closed: 0, opened: 0 }}
>
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
{props.activePendingApproval ? (
<PendingApprovalCard
approval={props.activePendingApproval}
respondingApprovalId={props.respondingApprovalId}
onRespond={props.onRespondToApproval}
/>
) : null}
{props.activePendingUserInput ? (
<PendingUserInputCard
pendingUserInput={props.activePendingUserInput}
drafts={props.activePendingUserInputDrafts}
answers={props.activePendingUserInputAnswers}
respondingUserInputId={props.respondingUserInputId}
onSelectOption={props.onSelectUserInputOption}
onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
onSubmit={props.onSubmitUserInput}
/>
) : null}
</Animated.View>
) : null}
</View>
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
<View pointerEvents="none" style={{ flex: 1 }} />
<KeyboardStickyView offset={{ closed: 0, opened: 0 }}>
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
{props.activePendingApproval ? (
<PendingApprovalCard
approval={props.activePendingApproval}
respondingApprovalId={props.respondingApprovalId}
onRespond={props.onRespondToApproval}
/>
) : null}
{props.activePendingUserInput ? (
<PendingUserInputCard
pendingUserInput={props.activePendingUserInput}
drafts={props.activePendingUserInputDrafts}
answers={props.activePendingUserInputAnswers}
respondingUserInputId={props.respondingUserInputId}
onSelectOption={props.onSelectUserInputOption}
onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
onSubmit={props.onSubmitUserInput}
/>
) : null}
</Animated.View>
) : null}
</View>

<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
connectionError={props.connectionError}
environmentLabel={props.environmentLabel}
threadSyncPhase={threadSyncPhase}
selectedThread={props.selectedThread}
serverConfig={props.serverConfig}
activeThreadBusy={props.activeThreadBusy}
isEditingQueuedMessage={props.isEditingQueuedMessage}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onStartNewThread={props.onStartNewThread}
onReconnectEnvironment={props.onReconnectEnvironment}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
/>
</View>
</KeyboardStickyView>
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
connectionError={props.connectionError}
environmentLabel={props.environmentLabel}
threadSyncPhase={threadSyncPhase}
selectedThread={props.selectedThread}
serverConfig={props.serverConfig}
activeThreadBusy={props.activeThreadBusy}
isEditingQueuedMessage={props.isEditingQueuedMessage}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onStartNewThread={props.onStartNewThread}
onReconnectEnvironment={props.onReconnectEnvironment}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
/>
</View>
</KeyboardStickyView>
</View>
) : null}
</View>
);
Expand Down
36 changes: 27 additions & 9 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1582,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) {
Expand Down Expand Up @@ -1963,7 +1970,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,
}}
>
<SymbolView
name={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<View className="flex-1 bg-screen">
<ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />

<GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />

<View className="flex-1 bg-screen">
<View className="flex-1">
<ThreadDetailScreen
selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
contentPresentation={contentPresentation}
Expand Down Expand Up @@ -817,7 +819,7 @@ function ThreadRouteContent(
onSubmitUserInput={requests.onSubmitUserInput}
/>
</View>
</>
</View>
);

return (
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading