Skip to content
Closed
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: 4 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import * as Schema from "effect/Schema";
import * as MobileDatabase from "../persistence/mobile-database";

const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
// makes pre-pagination clients discard the record instead of decoding a
// partial thread as complete (rollback safety).
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;

Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export interface ThreadComposerProps {
readonly threadSyncPhase?: "loading" | "syncing" | null;
readonly selectedThread: OrchestrationThreadShell;
readonly serverConfig: T3ServerConfig | null;
readonly queueCount: number;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
Expand All @@ -112,7 +114,7 @@ export interface ThreadComposerProps {
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onStartNewThread: () => void;
readonly onStartNewThread?: () => void;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
Expand Down Expand Up @@ -269,6 +271,9 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
});

export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
// Upstream surface: queue depth + busy gate used by detail screen.
void props.queueCount;
void props.activeThreadBusy;
const isDarkMode = useColorScheme() === "dark";
const foregroundColor = useThemeColor("--color-foreground");
const bodyText = useScaledTextRole("body");
Expand Down Expand Up @@ -530,7 +535,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const handleSend = useCallback(async () => {
if (parseStandaloneComposerSlashCommand(draftMessage) === "new") {
onChangeDraftMessage("");
onStartNewThread();
onStartNewThread?.();
return;
}
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
Expand Down Expand Up @@ -588,7 +593,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
);
setComposerSelection({ start: result.cursor, end: result.cursor });
onChangeDraftMessage(result.text);
onStartNewThread();
onStartNewThread?.();
return;
}

Expand Down
193 changes: 86 additions & 107 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, StyleSheet, View, type GestureResponderEvent } from "react-native";
import { Platform, 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 All @@ -32,7 +32,6 @@ import type {
PendingUserInputDraftAnswer,
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { ComposerQueuedMessages } from "./ComposerQueuedMessages";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
Expand Down Expand Up @@ -62,14 +61,15 @@ export interface ThreadDetailScreenProps {
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
/** A send made now would be held in the steering queue, not open a turn. */
readonly sendEntersQueue: boolean;
readonly hasMoreOlderActivities: boolean;
readonly loadingOlderActivities: boolean;
readonly onLoadOlderActivities: () => void;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
/** True when the next send will be held in the server steering queue. */
readonly sendEntersQueue?: boolean;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
readonly selectedThreadQueueCount: number;
readonly serverConfig: T3ServerConfig | null;
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
Expand All @@ -81,16 +81,6 @@ export interface ThreadDetailScreenProps {
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly composerQueueItems: ReadonlyArray<{
readonly messageId: MessageId;
readonly text: string;
readonly attachmentCount: number;
readonly deliveryState: "waiting" | "sending" | "queued";
readonly queueSource: "local" | "server";
}>;
readonly onSteerQueuedMessage: (messageId: MessageId) => Promise<void>;
readonly onEditQueuedMessage: (messageId: MessageId, source: "local" | "server") => Promise<void>;
readonly onStartNewThread: () => void;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
Expand Down Expand Up @@ -257,11 +247,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, [freeze, selectedThreadKey]);

useEffect(() => {
// Anchor as soon as the target row exists in the feed — including local
// outbox "Sending" bubbles painted before thread detail has finished loading.
if (
anchorMessageId === null ||
lastScrolledAnchorMessageIdRef.current === anchorMessageId ||
contentPresentationKind !== "ready" ||
!selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId)
) {
return;
Expand Down Expand Up @@ -300,9 +289,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
});
});
return () => cancelAnimationFrame(frame);
}, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]);
}, [
anchorMessageId,
freeze,
contentPresentationKind,
selectedThreadFeed,
scrollMessageToEnd,
selectedThreadKey,
]);

const sendEntersQueue = props.sendEntersQueue;
const sendEntersQueue = props.sendEntersQueue === true;
const handleSendMessage = useCallback(async () => {
const targetThreadKey = selectedThreadKey;
const sendWillQueue = sendEntersQueue;
Expand Down Expand Up @@ -391,100 +387,83 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
skills={selectedProviderSkills}
hasMoreOlder={props.hasMoreOlderActivities}
loadingOlder={props.loadingOlderActivities}
onLoadOlder={props.onLoadOlderActivities}
loadEarlier={props.loadEarlier ?? null}
/>
</View>
) : (
<View className="flex-1" />
)}

{/*
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.
*/}
{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
{showContent ? (
<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}
<ComposerQueuedMessages
items={props.composerQueueItems}
disabled={props.connectionStateLabel !== "connected"}
onSteer={(messageId) => {
void props.onSteerQueuedMessage(messageId);
}}
onEdit={(messageId, source) => {
void props.onEditQueuedMessage(messageId, source);
}}
/>
</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}
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}
/>
<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>
</KeyboardStickyView>
</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}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
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}
onReconnectEnvironment={props.onReconnectEnvironment}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
/>
</View>
</KeyboardStickyView>
) : null}
</View>
);
Expand Down
Loading
Loading