import type { ApprovalRequestId, ModelSelection, OrchestrationThread, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, } 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, 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; readonly activeWorkStartedAt: string | null; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record; readonly activePendingUserInputAnswers: Record | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; readonly activeThreadBusy: boolean; readonly environmentId: string; 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; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onRefresh: () => Promise; readonly onStopThread: () => Promise; readonly onSendMessage: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise; readonly onUpdateThreadInteractionMode: ( interactionMode: ProviderInteractionMode, ) => Promise; readonly onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise; readonly onSelectUserInputOption: (requestId: string, questionId: string, label: string) => void; readonly onChangeUserInputCustomAnswer: ( requestId: string, questionId: string, customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise; readonly showContent?: boolean; } function latestStreamingAssistantMessage( feed: ReadonlyArray, ): { 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: string, feed: ReadonlyArray) { 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 ( Working for {durationLabel} ); }); export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const { onOpenDrawer, onRefresh } = props; const insets = useSafeAreaInsets(); const agentLabel = `${props.selectedThread.modelSelection.provider} 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 feedBottomInset = Math.max(estimatedOverlayHeight, measuredOverlayHeight) + 8; const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); onOpenDrawer(); }, [onOpenDrawer]); const handleRefresh = useCallback(async (): Promise => { 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 ( {showContent ? ( ) : ( )} {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( {props.activeWorkStartedAt ? ( ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( {props.activePendingApproval ? ( ) : null} {props.activePendingUserInput ? ( ) : null} ) : null} ) : null} ); });