import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";import type { LegendListRef } from "@legendapp/list/react-native";import type { ApprovalRequestId, EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, ThreadId,} from "@t3tools/contracts";import { formatElapsed } from "@t3tools/shared/orchestrationTiming";import * as Haptics from "expo-haptics";import { useHeaderHeight } from "expo-router/build/react-navigation/elements";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";import { View, type GestureResponderEvent } 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 { ComposerEditorHandle } from "../../components/ComposerEditor";import type { StatusTone } from "../../components/StatusPill";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import type { LayoutVariant } from "../../lib/layout";import { scopedThreadKey } from "../../lib/scopedEntities";import type { PendingApproval, PendingUserInput, PendingUserInputDraftAnswer, ThreadFeedEntry,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { PendingUserInputCard } from "./PendingUserInputCard";import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, ThreadComposer,} from "./ThreadComposer";import { ThreadFeed } from "./ThreadFeed";import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; readonly environmentLabel: 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: EnvironmentConnectionPhase; 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 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 onStopThread: () => void; readonly onSendMessage: () => Promise<MessageId | null>; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise<unknown>; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, questionId: string, label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( requestId: ApprovalRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise<unknown>; 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 } = props; const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef<ComposerEditorHandle>(null); const composerOverlayRef = useRef<View>(null); const listRef = useRef<LegendListRef>(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; const selectedThreadFeed = props.selectedThreadFeed; 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 + 8; const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( listRef, composerOverlayRef, estimatedOverlayHeight, ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const selectedProviderSkills = useMemo( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) ?.skills ?? [], [props.serverConfig, selectedInstanceId], ); const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); onOpenDrawer(); }, [onOpenDrawer]); 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], ); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; }, [selectedThreadKey]); useEffect(() => { setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; freeze.set(false); }, [freeze, selectedThreadKey]); useEffect(() => { if ( anchorMessageId === null || lastScrolledAnchorMessageIdRef.current === anchorMessageId || contentPresentationKind !== "ready" || !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) ) { return; } const targetThreadKey = selectedThreadKey; const frame = requestAnimationFrame(() => { if (selectedThreadKeyRef.current !== targetThreadKey) { return; } lastScrolledAnchorMessageIdRef.current = anchorMessageId; void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || lastScrolledAnchorMessageIdRef.current !== anchorMessageId ) { return; } lastScrolledAnchorMessageIdRef.current = null; freeze.set(false); }); }); return () => cancelAnimationFrame(frame); }, [ anchorMessageId, freeze, contentPresentationKind, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey, ]); const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; const messageId = await props.onSendMessage(); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; } setAnchorMessageId(messageId); composerEditorRef.current?.blur(); return messageId; }, [props.onSendMessage, selectedThreadKey]); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); }, []); const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY, }; }, []); const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => { const start = feedTouchStartRef.current; if (!start) { return; } const deltaX = event.nativeEvent.pageX - start.pageX; const deltaY = event.nativeEvent.pageY - start.pageY; if (Math.hypot(deltaX, deltaY) > 8) { feedTouchStartRef.current = null; } }, []); const handleFeedTouchEnd = useCallback(() => { if (feedTouchStartRef.current) { collapseComposer(); } feedTouchStartRef.current = null; }, [collapseComposer]); const handleFeedTouchCancel = useCallback(() => { feedTouchStartRef.current = null; }, []); return ( <GestureDetector gesture={headerDrawerGesture}> <View className="flex-1"> {showContent ? ( <View style={{ flex: 1 }} onTouchStart={handleFeedTouchStart} onTouchMove={handleFeedTouchMove} onTouchEnd={handleFeedTouchEnd} onTouchCancel={handleFeedTouchCancel} > <ThreadFeed key={props.selectedThread.id} environmentId={props.environmentId} threadId={props.selectedThread.id} workspaceRoot={props.threadCwd} feed={props.selectedThreadFeed} contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} contentInsetEndAdjustment={contentInsetEndAdjustment} contentTopInset={headerHeight} contentBottomInset={estimatedOverlayHeight} layoutVariant={layoutVariant} skills={selectedProviderSkills} /> </View> ) : ( <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 ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}> {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 editorRef={composerEditorRef} draftMessage={props.draftMessage} draftAttachments={props.draftAttachments} placeholder="Ask the repo agent, or run a command…" connectionState={props.connectionStateLabel} connectionError={props.connectionError} environmentLabel={props.environmentLabel} 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> </GestureDetector> );});
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";
import type {
ApprovalRequestId,
EnvironmentId,
MessageId,
ModelSelection,
OrchestrationThreadShell,
ProviderApprovalDecision,
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
ThreadId,
} from "@t3tools/contracts";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { View, type GestureResponderEvent } 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 { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import type { LayoutVariant } from "../../lib/layout";
import { scopedThreadKey } from "../../lib/scopedEntities";
import type {
PendingApproval,
PendingUserInput,
PendingUserInputDraftAnswer,
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
COMPOSER_COLLAPSED_CHROME,
COMPOSER_EXPANDED_CHROME,
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed } from "./ThreadFeed";
import type { ThreadContentPresentation } from "./threadContentPresentation";
export interface ThreadDetailScreenProps {
readonly selectedThread: OrchestrationThreadShell;
readonly contentPresentation: ThreadContentPresentation;
readonly screenTone: StatusTone;
readonly connectionError: string | null;
readonly environmentLabel: 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: EnvironmentConnectionPhase;
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 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 onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
readonly onRespondToApproval: (
requestId: ApprovalRequestId,
decision: ProviderApprovalDecision,
) => Promise<unknown>;
readonly onSelectUserInputOption: (
requestId: ApprovalRequestId,
questionId: string,
label: string,
) => void;
readonly onChangeUserInputCustomAnswer: (
requestId: ApprovalRequestId,
questionId: string,
customAnswer: string,
) => void;
readonly onSubmitUserInput: () => Promise<unknown>;
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 } = props;
const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight();
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const composerEditorRef = useRef<ComposerEditorHandle>(null);
const composerOverlayRef = useRef<View>(null);
const listRef = useRef<LegendListRef>(null);
const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
const selectedThreadKeyRef = useRef(selectedThreadKey);
const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
const contentPresentationKind = props.contentPresentation.kind;
const selectedThreadFeed = props.selectedThreadFeed;
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 + 8;
const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
listRef,
composerOverlayRef,
estimatedOverlayHeight,
);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
const layoutVariant = props.layoutVariant ?? "compact";
const isSplitLayout = layoutVariant === "split";
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const selectedProviderSkills = useMemo(
() =>
props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
?.skills ?? [],
[props.serverConfig, selectedInstanceId],
);
const completeDrawerGesture = useCallback(() => {
void Haptics.selectionAsync();
onOpenDrawer();
}, [onOpenDrawer]);
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],
);
useLayoutEffect(() => {
selectedThreadKeyRef.current = selectedThreadKey;
}, [selectedThreadKey]);
useEffect(() => {
setAnchorMessageId(null);
lastScrolledAnchorMessageIdRef.current = null;
freeze.set(false);
}, [freeze, selectedThreadKey]);
useEffect(() => {
if (
anchorMessageId === null ||
lastScrolledAnchorMessageIdRef.current === anchorMessageId ||
contentPresentationKind !== "ready" ||
!selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId)
) {
return;
}
const targetThreadKey = selectedThreadKey;
const frame = requestAnimationFrame(() => {
if (selectedThreadKeyRef.current !== targetThreadKey) {
return;
}
lastScrolledAnchorMessageIdRef.current = anchorMessageId;
void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
if (
selectedThreadKeyRef.current !== targetThreadKey ||
lastScrolledAnchorMessageIdRef.current !== anchorMessageId
) {
return;
}
lastScrolledAnchorMessageIdRef.current = null;
freeze.set(false);
});
});
return () => cancelAnimationFrame(frame);
}, [
anchorMessageId,
freeze,
contentPresentationKind,
selectedThreadFeed,
scrollMessageToEnd,
selectedThreadKey,
]);
const handleSendMessage = useCallback(async () => {
const targetThreadKey = selectedThreadKey;
const messageId = await props.onSendMessage();
if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
return messageId;
}
setAnchorMessageId(messageId);
composerEditorRef.current?.blur();
return messageId;
}, [props.onSendMessage, selectedThreadKey]);
const collapseComposer = useCallback(() => {
composerEditorRef.current?.blur();
}, []);
const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
feedTouchStartRef.current = {
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
}, []);
const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => {
const start = feedTouchStartRef.current;
if (!start) {
return;
}
const deltaX = event.nativeEvent.pageX - start.pageX;
const deltaY = event.nativeEvent.pageY - start.pageY;
if (Math.hypot(deltaX, deltaY) > 8) {
feedTouchStartRef.current = null;
}
}, []);
const handleFeedTouchEnd = useCallback(() => {
if (feedTouchStartRef.current) {
collapseComposer();
}
feedTouchStartRef.current = null;
}, [collapseComposer]);
const handleFeedTouchCancel = useCallback(() => {
feedTouchStartRef.current = null;
}, []);
return (
<GestureDetector gesture={headerDrawerGesture}>
<View className="flex-1">
{showContent ? (
<View
style={{ flex: 1 }}
onTouchStart={handleFeedTouchStart}
onTouchMove={handleFeedTouchMove}
onTouchEnd={handleFeedTouchEnd}
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
feed={props.selectedThreadFeed}
contentPresentation={props.contentPresentation}
agentLabel={agentLabel}
latestTurn={props.selectedThread.latestTurn}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
contentInsetEndAdjustment={contentInsetEndAdjustment}
contentTopInset={headerHeight}
contentBottomInset={estimatedOverlayHeight}
layoutVariant={layoutVariant}
skills={selectedProviderSkills}
/>
</View>
) : (
<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 ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}>
{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
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
connectionState={props.connectionStateLabel}
connectionError={props.connectionError}
environmentLabel={props.environmentLabel}
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>
</GestureDetector>
);
});