import type { ApprovalRequestId, EnvironmentId, ModelSelection, OrchestrationThread, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, ThreadId,} from "@t3tools/contracts";import { formatElapsed } from "@t3tools/shared/orchestrationTiming";import * as Haptics from "expo-haptics";import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";import { View, type LayoutChangeEvent } from "react-native";import { Gesture, GestureDetector } from "react-native-gesture-handler";import { KeyboardStickyView } from "react-native-keyboard-controller";import { useSafeAreaInsets } from "react-native-safe-area-context";import { runOnJS } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText";import type { StatusTone } from "../../components/StatusPill";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import type { MobileLayoutVariant } from "../../lib/mobileLayout";import type { PendingApproval, PendingUserInput, PendingUserInputDraftAnswer, ThreadFeedEntry,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { PendingUserInputCard } from "./PendingUserInputCard";import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, COMPOSER_EXPANDED_TOOLBAR_CHROME, ThreadComposer,} from "./ThreadComposer";import { ThreadFeed } from "./ThreadFeed"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThread; readonly screenTone: StatusTone; readonly connectionError: string | null; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; readonly activeWorkStartedAt: string | null; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; readonly activePendingUserInputAnswers: Record<string, string> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: MobileLayoutVariant; readonly onOpenDrawer: () => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise<void>; readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onRefresh: () => Promise<void>; readonly onStopThread: () => Promise<void>; readonly onSendMessage: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>; readonly onUpdateThreadInteractionMode: ( interactionMode: ProviderInteractionMode, ) => Promise<void>; readonly onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise<void>; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, questionId: string, label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( requestId: ApprovalRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise<void>; readonly showContent?: boolean;} function latestStreamingAssistantMessage( feed: ReadonlyArray<ThreadFeedEntry>,): { readonly id: string; readonly textLength: number } | null { for (let index = feed.length - 1; index >= 0; index -= 1) { const entry = feed[index]; if (entry?.type !== "message") { continue; } if (entry.message.role !== "assistant" || !entry.message.streaming) { continue; } return { id: entry.message.id, textLength: entry.message.text.length, }; } return null;} function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) { const lastStreamingAssistantRef = useRef<{ readonly id: string; readonly textLength: number; } | null>(null); const lastStreamHapticAtRef = useRef(0); const hydratedRef = useRef(false); const previousThreadIdRef = useRef(threadId); useEffect(() => { if (previousThreadIdRef.current !== threadId) { previousThreadIdRef.current = threadId; hydratedRef.current = false; } const latestStreamingMessage = latestStreamingAssistantMessage(feed); if (!hydratedRef.current) { hydratedRef.current = true; lastStreamingAssistantRef.current = latestStreamingMessage; return; } if (!latestStreamingMessage) { lastStreamingAssistantRef.current = null; return; } const previousStreamingMessage = lastStreamingAssistantRef.current; lastStreamingAssistantRef.current = latestStreamingMessage; const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id; const textGrew = previousStreamingMessage?.id === latestStreamingMessage.id && latestStreamingMessage.textLength > previousStreamingMessage.textLength; if (!isNewStream && !textGrew) { return; } const now = Date.now(); if (!isNewStream && now - lastStreamHapticAtRef.current < 320) { return; } lastStreamHapticAtRef.current = now; void Haptics.selectionAsync(); }, [threadId, feed]);} const WORKING_INDICATOR_HEIGHT = 44; const WorkingDurationPill = memo(function WorkingDurationPill(props: { readonly startedAt: string;}) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { const intervalId = setInterval(() => { setNowMs(Date.now()); }, 1_000); return () => clearInterval(intervalId); }, [props.startedAt]); const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; return ( <View className="px-4 pb-2" style={{ flexShrink: 0 }}> <View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]"> <View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-1"> <View className="h-1.5 w-1.5 rounded-full bg-neutral-400 dark:bg-neutral-500" /> <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" /> <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" /> </View> <Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> Working for {durationLabel} </Text> </View> </View> </View> );}); export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const { onOpenDrawer, onRefresh } = props; const insets = useSafeAreaInsets(); const selectedProvider = props.serverConfig?.providers.find( (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, ); const agentName = selectedProvider?.displayName ?? selectedProvider?.driver ?? props.selectedThread.modelSelection.instanceId; const agentLabel = `${agentName} agent`; const composerBottomInset = Math.max(insets.bottom, 12); const [composerExpanded, setComposerExpanded] = useState(false); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); const [refreshing, setRefreshing] = useState(false); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; const feedBottomInset = Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); onOpenDrawer(); }, [onOpenDrawer]); const handleRefresh = useCallback(async (): Promise<void> => { if (refreshing) { return; } setRefreshing(true); try { await onRefresh(); } finally { setRefreshing(false); } }, [onRefresh, refreshing]); const drawerGestureThreshold = 80; const headerDrawerGesture = useMemo( () => Gesture.Pan() .enabled(!isSplitLayout) .hitSlop({ left: 0, width: 40 }) .activeOffsetX([10, 999]) .failOffsetY([-24, 24]) .onEnd((event) => { const translationX = Math.max(event.translationX, 0); if (event.y < drawerGestureThreshold && translationX > 56) { runOnJS(completeDrawerGesture)(); } }), [completeDrawerGesture, isSplitLayout], ); const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { const nextHeight = Math.ceil(event.nativeEvent.layout.height); setMeasuredOverlayHeight((current) => Math.abs(current - nextHeight) > 1 ? nextHeight : current, ); }, []); return ( <GestureDetector gesture={headerDrawerGesture}> <View className="flex-1"> {showContent ? ( <ThreadFeed threadId={props.selectedThread.id} feed={props.selectedThreadFeed} httpBaseUrl={props.httpBaseUrl} bearerToken={props.bearerToken} agentLabel={agentLabel} contentBottomInset={feedBottomInset} layoutVariant={layoutVariant} composerExpanded={composerExpanded} refreshing={refreshing} onRefresh={handleRefresh} /> ) : ( <View style={{ flex: 1 }} /> )} {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( <KeyboardStickyView style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > <View onLayout={handleOverlayLayout}> {props.activeWorkStartedAt ? ( <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> {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} </View> ) : null} <ThreadComposer draftMessage={props.draftMessage} draftAttachments={props.draftAttachments} placeholder="Ask the repo agent, or run a command…" connectionState={props.connectionStateLabel} 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} onRefresh={props.onRefresh} onStopThread={props.onStopThread} onSendMessage={props.onSendMessage} onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onExpandedChange={setComposerExpanded} /> </View> </KeyboardStickyView> ) : null} </View> </GestureDetector> );});
import type {
ApprovalRequestId,
EnvironmentId,
ModelSelection,
OrchestrationThread,
ProviderApprovalDecision,
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
ThreadId,
} from "@t3tools/contracts";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { runOnJS } from "react-native-reanimated";
import { AppText as Text } from "../../components/AppText";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import type { MobileLayoutVariant } from "../../lib/mobileLayout";
import type {
PendingApproval,
PendingUserInput,
PendingUserInputDraftAnswer,
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
COMPOSER_COLLAPSED_CHROME,
COMPOSER_EXPANDED_CHROME,
COMPOSER_EXPANDED_TOOLBAR_CHROME,
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed } from "./ThreadFeed";
export interface ThreadDetailScreenProps {
readonly selectedThread: OrchestrationThread;
readonly screenTone: StatusTone;
readonly connectionError: string | null;
readonly httpBaseUrl: string | null;
readonly bearerToken: string | null;
readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
readonly activeWorkStartedAt: string | null;
readonly activePendingApproval: PendingApproval | null;
readonly respondingApprovalId: ApprovalRequestId | null;
readonly activePendingUserInput: PendingUserInput | null;
readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
readonly activePendingUserInputAnswers: Record<string, string> | null;
readonly respondingUserInputId: ApprovalRequestId | null;
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly selectedThreadQueueCount: number;
readonly serverConfig: T3ServerConfig | null;
readonly layoutVariant?: MobileLayoutVariant;
readonly onOpenDrawer: () => void;
readonly onOpenConnectionEditor: () => void;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftImages: () => Promise<void>;
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onRefresh: () => Promise<void>;
readonly onStopThread: () => Promise<void>;
readonly onSendMessage: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>;
readonly onUpdateThreadInteractionMode: (
interactionMode: ProviderInteractionMode,
) => Promise<void>;
readonly onRespondToApproval: (
requestId: ApprovalRequestId,
decision: ProviderApprovalDecision,
) => Promise<void>;
readonly onSelectUserInputOption: (
requestId: ApprovalRequestId,
questionId: string,
label: string,
) => void;
readonly onChangeUserInputCustomAnswer: (
requestId: ApprovalRequestId,
questionId: string,
customAnswer: string,
) => void;
readonly onSubmitUserInput: () => Promise<void>;
readonly showContent?: boolean;
}
function latestStreamingAssistantMessage(
feed: ReadonlyArray<ThreadFeedEntry>,
): { readonly id: string; readonly textLength: number } | null {
for (let index = feed.length - 1; index >= 0; index -= 1) {
const entry = feed[index];
if (entry?.type !== "message") {
continue;
}
if (entry.message.role !== "assistant" || !entry.message.streaming) {
continue;
}
return {
id: entry.message.id,
textLength: entry.message.text.length,
};
}
return null;
}
function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) {
const lastStreamingAssistantRef = useRef<{
readonly id: string;
readonly textLength: number;
} | null>(null);
const lastStreamHapticAtRef = useRef(0);
const hydratedRef = useRef(false);
const previousThreadIdRef = useRef(threadId);
useEffect(() => {
if (previousThreadIdRef.current !== threadId) {
previousThreadIdRef.current = threadId;
hydratedRef.current = false;
}
const latestStreamingMessage = latestStreamingAssistantMessage(feed);
if (!hydratedRef.current) {
hydratedRef.current = true;
lastStreamingAssistantRef.current = latestStreamingMessage;
return;
}
if (!latestStreamingMessage) {
lastStreamingAssistantRef.current = null;
return;
}
const previousStreamingMessage = lastStreamingAssistantRef.current;
lastStreamingAssistantRef.current = latestStreamingMessage;
const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id;
const textGrew =
previousStreamingMessage?.id === latestStreamingMessage.id &&
latestStreamingMessage.textLength > previousStreamingMessage.textLength;
if (!isNewStream && !textGrew) {
return;
}
const now = Date.now();
if (!isNewStream && now - lastStreamHapticAtRef.current < 320) {
return;
}
lastStreamHapticAtRef.current = now;
void Haptics.selectionAsync();
}, [threadId, feed]);
}
const WORKING_INDICATOR_HEIGHT = 44;
const WorkingDurationPill = memo(function WorkingDurationPill(props: {
readonly startedAt: string;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const intervalId = setInterval(() => {
setNowMs(Date.now());
}, 1_000);
return () => clearInterval(intervalId);
}, [props.startedAt]);
const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
return (
<View className="px-4 pb-2" style={{ flexShrink: 0 }}>
<View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]">
<View className="flex-row items-center gap-2">
<View className="flex-row items-center gap-1">
<View className="h-1.5 w-1.5 rounded-full bg-neutral-400 dark:bg-neutral-500" />
<View className="h-1.5 w-1.5 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" />
<View className="h-1.5 w-1.5 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" />
</View>
<Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
Working for {durationLabel}
</Text>
</View>
</View>
</View>
);
});
export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const { onOpenDrawer, onRefresh } = props;
const insets = useSafeAreaInsets();
const selectedProvider = props.serverConfig?.providers.find(
(provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,
);
const agentName =
selectedProvider?.displayName ??
selectedProvider?.driver ??
props.selectedThread.modelSelection.instanceId;
const agentLabel = `${agentName} agent`;
const composerBottomInset = Math.max(insets.bottom, 12);
const [composerExpanded, setComposerExpanded] = useState(false);
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const showContent = props.showContent ?? true;
const layoutVariant = props.layoutVariant ?? "compact";
const isSplitLayout = layoutVariant === "split";
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0;
const feedBottomInset =
Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8;
const completeDrawerGesture = useCallback(() => {
void Haptics.selectionAsync();
onOpenDrawer();
}, [onOpenDrawer]);
const handleRefresh = useCallback(async (): Promise<void> => {
if (refreshing) {
return;
}
setRefreshing(true);
try {
await onRefresh();
} finally {
setRefreshing(false);
}
}, [onRefresh, refreshing]);
const drawerGestureThreshold = 80;
const headerDrawerGesture = useMemo(
() =>
Gesture.Pan()
.enabled(!isSplitLayout)
.hitSlop({ left: 0, width: 40 })
.activeOffsetX([10, 999])
.failOffsetY([-24, 24])
.onEnd((event) => {
const translationX = Math.max(event.translationX, 0);
if (event.y < drawerGestureThreshold && translationX > 56) {
runOnJS(completeDrawerGesture)();
}
}),
[completeDrawerGesture, isSplitLayout],
);
const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => {
const nextHeight = Math.ceil(event.nativeEvent.layout.height);
setMeasuredOverlayHeight((current) =>
Math.abs(current - nextHeight) > 1 ? nextHeight : current,
);
}, []);
return (
<GestureDetector gesture={headerDrawerGesture}>
<View className="flex-1">
{showContent ? (
<ThreadFeed
threadId={props.selectedThread.id}
feed={props.selectedThreadFeed}
httpBaseUrl={props.httpBaseUrl}
bearerToken={props.bearerToken}
agentLabel={agentLabel}
contentBottomInset={feedBottomInset}
layoutVariant={layoutVariant}
composerExpanded={composerExpanded}
refreshing={refreshing}
onRefresh={handleRefresh}
/>
) : (
<View style={{ flex: 1 }} />
)}
{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
{showContent ? (
<KeyboardStickyView
style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
offset={{ closed: 0, opened: 0 }}
>
<View onLayout={handleOverlayLayout}>
{props.activeWorkStartedAt ? (
<WorkingDurationPill startedAt={props.activeWorkStartedAt} />
) : null}
{props.activePendingApproval || props.activePendingUserInput ? (
<View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
{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}
</View>
) : null}
<ThreadComposer
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
connectionState={props.connectionStateLabel}
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}
onRefresh={props.onRefresh}
onStopThread={props.onStopThread}
onSendMessage={props.onSendMessage}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
/>
</View>
</KeyboardStickyView>
) : null}
</View>
</GestureDetector>
);
});