import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate,} from "@t3tools/client-runtime/codex-artifact-templates";import type { CodexFeedbackSubmission, EnvironmentThreadStatus,} from "@t3tools/client-runtime/state/threads";import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";import type { LegendListRef } from "@legendapp/list/react-native";import { HeaderHeightContext } from "@react-navigation/elements";import type { ApprovalRequestId, EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, UsageLimitsReport, UserInputQuestion,} from "@t3tools/contracts";import * as Haptics from "expo-haptics";import { BlurTargetView } from "expo-blur";import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";import { Alert, AppState, Keyboard, Platform, useWindowDimensions, View, type GestureResponderEvent,} from "react-native";import { KeyboardController, KeyboardStickyView, useKeyboardState,} from "react-native-keyboard-controller";import Animated, { Easing, FadeInDown, FadeOut, ReduceMotion, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";import type { ComposerEditorHandle } from "../../components/ComposerEditor";import type { StatusTone } from "../../components/StatusPill";import type { DraftComposerAttachment } from "../../lib/composerImages";import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";import type { QueuedThreadMessage } from "../../state/thread-outbox-model";import { scopedThreadKey } from "../../lib/scopedEntities";import type { PendingApproval, PendingUserInput, PendingUserInputDraftAnswer, ThreadFeedEntry,} from "../../lib/threadActivity";import { PendingApprovalCard } from "./PendingApprovalCard";import { ComposerFeedback } from "./ComposerFeedback";import { ComposerUsageLimits } from "./ComposerUsageLimits";import { PendingUserInputCard } from "./PendingUserInputCard";import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl,} from "./floating-working-control";import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, USER_INPUT_TOGGLE_DURATION_MS,} from "./pendingUserInputLayout";import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, COMPOSER_LAYOUT_TRANSITION, COMPOSER_TRANSITION_DURATION_MS, ThreadComposer,} from "./ThreadComposer";import { ThreadFeed } from "./ThreadFeed";import type { ThreadContentPresentation } from "./threadContentPresentation";import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; readonly environmentLabel: string | null; readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>; readonly onDismissFeedback: (id: MessageId) => void; readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; readonly activeWorkStartedAt: string | null; readonly isCompacting: boolean; /** * The server has not created this thread yet. "preparing" runs while the * queued creation is delivered (a worktree may be checking out); "failed" * is a rejected creation whose content went back to the project draft. */ readonly creationState: | { readonly kind: "preparing"; readonly preparingWorktree: boolean } | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; readonly dispatchingMessageId: MessageId | null; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise<void>; readonly onPickDraftFiles: () => 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, question: UserInputQuestion, value: string, ) => void; readonly onChangeUserInputCustomAnswer: ( requestId: ApprovalRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise<unknown>; readonly onDismissUserInput: () => 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 USER_INPUT_TOGGLE_TIMING = { duration: USER_INPUT_TOGGLE_DURATION_MS, easing: Easing.out(Easing.cubic),}; export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); // Android can swallow the IME hide callbacks when the app is backgrounded // mid keyboard-hide (the reported repro: send — which blurs and starts the // hide — then Home within a second). The keyboard library's height AND // visibility then stay frozen open, so gating the sticky translation on // visibility alone still strands the composer after resume. Quarantine the // translation on every Android resume instead; any sign of a live keyboard // stream — an owned input gaining focus, or any visibility/height movement — // lifts it. A healthy resume sees no visual difference (the translation is // already zero while the keyboard is closed). const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); useEffect(() => { if (Platform.OS !== "android") { return; } const subscription = AppState.addEventListener("change", (state) => { if (state === "active") { setKeyboardStateSuspect(true); } }); return () => { subscription.remove(); }; }, []); useEffect(() => { setKeyboardStateSuspect(false); }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = useCallback((focused: boolean) => { if (focused) { setKeyboardStateSuspect(false); } }, []); const windowHeight = useWindowDimensions().height; const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef<ComposerEditorHandle>(null); const draftMessageRef = useRef(props.draftMessage); draftMessageRef.current = props.draftMessage; const composerOverlayRef = useRef<View>(null); const listRef = useRef<LegendListRef>(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const [composerFocused, setComposerFocused] = useState(false); const handleComposerFocusChange = useCallback( (focused: boolean) => { setComposerFocused(focused); handleOwnedInputFocusChange(focused); }, [handleOwnedInputFocusChange], ); const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a // focus-keyed inset would leave the toolbar under the gesture bar. iOS must // NOT use visibility — it only flips on keyboardDidHide, after the hide // animation, so the composer would ride down flush to the screen edge and // then snap up into the inset. On iOS blur precedes the hide, so the // focus-keyed inset is already in place while the composer rides down. // Dictation keeps that focus while the composer switches to its compact pill. const composerBottomInset = ( Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused ) ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". const threadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { return "Syncing messages..."; } return contentPresentationKind === "loading" ? "Loading messages..." : null; default: return null; } })(); // One floating pill above the composer: it reads the connection phase while // disconnected, the sync state while messages load, then the working timer // once the feed is settled. const floatingStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ connectionError: props.connectionError, connectionState: props.connectionStateLabel, environmentLabel: props.environmentLabel, onReconnect: props.onReconnectEnvironment, }); if (connectionStatus !== null) { return connectionStatus; } if (props.activePendingApproval !== null || props.activePendingUserInput !== null) { return null; } if (props.creationState?.kind === "preparing") { return { kind: "preparing", label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", }; } if (props.creationState?.kind === "failed") { return null; } if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } return null; })(); const showWorkingControl = floatingStatus !== null; // Connection and working status occupy the same space. Keep the feed inset // stable when reconnecting hands off to syncing and then to a running turn. const showFloatingStatus = showWorkingControl || props.connectionStateLabel !== "connected" || props.queuedMessages.length > 0 || props.selectedThreadFeed.some( (entry) => "acknowledged" in entry && entry.acknowledged === true, ); const selectedThreadFeed = props.selectedThreadFeed; const hasCompactableConversation = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user" && ((entry.message.attachments?.length ?? 0) > 0 || entry.message.text.trim().toLowerCase() !== "/compact"), ) || (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; // While a user-input request is pending, the questionnaire owns the // composer slot outright: expanded it is the full card, collapsed it is a // composer-style bar in the same place (with its own stop control). The // composer never mounts into the transition, which keeps the collapse and // keyboard animations coherent. Collapse state is keyed by request id so a // new request re-expands automatically. const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState<ApprovalRequestId | null>(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; // The open /usage-limits panel for this thread, model and turn. Only the open // moment is stored: the rows read live provider data, so a redeemed reset // credit or refreshed probe shows through. Anything that spends quota closes // it: a new turn from any source, or the agent resuming after an approval or // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly threadKey: string; readonly now: number; } | null>(null); // A pending approval or question is part of the key: once it is answered, // from this client or any other, the agent resumes and spends quota. const usageLimitsKey = [ selectedThreadKey, props.selectedThread.modelSelection.instanceId, props.selectedThread.latestTurn?.turnId ?? "", props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", ].join(":"); // Drop the snapshot as soon as the key changes so it cannot resurface stale. if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { setUsageLimitsPanel(null); } const usageLimitsReport = useMemo( () => usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey ? collectProviderUsageLimits( props.selectedThread.modelSelection.instanceId, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], usageLimitsPanel.now, ) : null, [ props.selectedThread.modelSelection.instanceId, props.serverConfig, usageLimitsKey, usageLimitsPanel, ], ); const showUsageLimits = useCallback( (report: UsageLimitsReport | null) => setUsageLimitsPanel( report === null ? null : { key: usageLimitsKey, threadKey: selectedThreadKey, now: Date.parse(report.createdAt), }, ), [selectedThreadKey, usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); // A send may resolve after navigating away, so only the originating // thread's panel is cleared; a panel opened elsewhere in the meantime stays. const clearUsageLimitsFor = useCallback( (threadKey: string) => setUsageLimitsPanel((current) => current !== null && current.threadKey === threadKey ? null : current, ), [], ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of // tracking the keyboard: transforms (the sticky translation) apply // same-frame on the UI thread while layout props lag a Yoga pass behind, // so any height that follows the keyboard flashes the card over the nav // header on the way up. With a constant height the keyboard transition is // pure translation — frame-perfect by construction — and the resting card // stays compact over the transcript. Before the first open the reserve is // an estimate; once a real height is known the card corrects once, // discretely. const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); useEffect(() => { if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { setLastKnownKeyboardHeight(liveKeyboardHeight); } }, [lastKnownKeyboardHeight, liveKeyboardHeight]); const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ windowHeight, keyboardHeight: lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT, navigationHeaderHeight, // The questionnaire owns the composer slot, so only the composer's // bottom inset still overlaps.Lines 1–500 of 1070. Load more
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; import type { CodexFeedbackSubmission, EnvironmentThreadStatus, } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; import { HeaderHeightContext } from "@react-navigation/elements"; import type { ApprovalRequestId, EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, UsageLimitsReport, UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; import { BlurTargetView } from "expo-blur"; import { GlassBlurTargetContext } from "../../lib/glassBlurTarget"; import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { Alert, AppState, Keyboard, Platform, useWindowDimensions, View, type GestureResponderEvent, } from "react-native"; import { KeyboardController, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; import Animated, { Easing, FadeInDown, FadeOut, ReduceMotion, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, PendingUserInput, PendingUserInputDraftAnswer, ThreadFeedEntry, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, } from "./floating-working-control"; import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, USER_INPUT_TOGGLE_DURATION_MS, } from "./pendingUserInputLayout"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, COMPOSER_LAYOUT_TRANSITION, COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; readonly environmentLabel: string | null; readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>; readonly onDismissFeedback: (id: MessageId) => void; readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; readonly activeWorkStartedAt: string | null; readonly isCompacting: boolean; /** * The server has not created this thread yet. "preparing" runs while the * queued creation is delivered (a worktree may be checking out); "failed" * is a rejected creation whose content went back to the project draft. */ readonly creationState: | { readonly kind: "preparing"; readonly preparingWorktree: boolean } | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; readonly dispatchingMessageId: MessageId | null; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise<void>; readonly onPickDraftFiles: () => 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, question: UserInputQuestion, value: string, ) => void; readonly onChangeUserInputCustomAnswer: ( requestId: ApprovalRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise<unknown>; readonly onDismissUserInput: () => 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 USER_INPUT_TOGGLE_TIMING = { duration: USER_INPUT_TOGGLE_DURATION_MS, easing: Easing.out(Easing.cubic), }; export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); // Android can swallow the IME hide callbacks when the app is backgrounded // mid keyboard-hide (the reported repro: send — which blurs and starts the // hide — then Home within a second). The keyboard library's height AND // visibility then stay frozen open, so gating the sticky translation on // visibility alone still strands the composer after resume. Quarantine the // translation on every Android resume instead; any sign of a live keyboard // stream — an owned input gaining focus, or any visibility/height movement — // lifts it. A healthy resume sees no visual difference (the translation is // already zero while the keyboard is closed). const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); useEffect(() => { if (Platform.OS !== "android") { return; } const subscription = AppState.addEventListener("change", (state) => { if (state === "active") { setKeyboardStateSuspect(true); } }); return () => { subscription.remove(); }; }, []); useEffect(() => { setKeyboardStateSuspect(false); }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = useCallback((focused: boolean) => { if (focused) { setKeyboardStateSuspect(false); } }, []); const windowHeight = useWindowDimensions().height; const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef<ComposerEditorHandle>(null); const draftMessageRef = useRef(props.draftMessage); draftMessageRef.current = props.draftMessage; const composerOverlayRef = useRef<View>(null); const listRef = useRef<LegendListRef>(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const [composerFocused, setComposerFocused] = useState(false); const handleComposerFocusChange = useCallback( (focused: boolean) => { setComposerFocused(focused); handleOwnedInputFocusChange(focused); }, [handleOwnedInputFocusChange], ); const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a // focus-keyed inset would leave the toolbar under the gesture bar. iOS must // NOT use visibility — it only flips on keyboardDidHide, after the hide // animation, so the composer would ride down flush to the screen edge and // then snap up into the inset. On iOS blur precedes the hide, so the // focus-keyed inset is already in place while the composer rides down. // Dictation keeps that focus while the composer switches to its compact pill. const composerBottomInset = ( Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused ) ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". const threadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { return "Syncing messages..."; } return contentPresentationKind === "loading" ? "Loading messages..." : null; default: return null; } })(); // One floating pill above the composer: it reads the connection phase while // disconnected, the sync state while messages load, then the working timer // once the feed is settled. const floatingStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ connectionError: props.connectionError, connectionState: props.connectionStateLabel, environmentLabel: props.environmentLabel, onReconnect: props.onReconnectEnvironment, }); if (connectionStatus !== null) { return connectionStatus; } if (props.activePendingApproval !== null || props.activePendingUserInput !== null) { return null; } if (props.creationState?.kind === "preparing") { return { kind: "preparing", label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", }; } if (props.creationState?.kind === "failed") { return null; } if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } return null; })(); const showWorkingControl = floatingStatus !== null; // Connection and working status occupy the same space. Keep the feed inset // stable when reconnecting hands off to syncing and then to a running turn. const showFloatingStatus = showWorkingControl || props.connectionStateLabel !== "connected" || props.queuedMessages.length > 0 || props.selectedThreadFeed.some( (entry) => "acknowledged" in entry && entry.acknowledged === true, ); const selectedThreadFeed = props.selectedThreadFeed; const hasCompactableConversation = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user" && ((entry.message.attachments?.length ?? 0) > 0 || entry.message.text.trim().toLowerCase() !== "/compact"), ) || (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; // While a user-input request is pending, the questionnaire owns the // composer slot outright: expanded it is the full card, collapsed it is a // composer-style bar in the same place (with its own stop control). The // composer never mounts into the transition, which keeps the collapse and // keyboard animations coherent. Collapse state is keyed by request id so a // new request re-expands automatically. const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState<ApprovalRequestId | null>(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; // The open /usage-limits panel for this thread, model and turn. Only the open // moment is stored: the rows read live provider data, so a redeemed reset // credit or refreshed probe shows through. Anything that spends quota closes // it: a new turn from any source, or the agent resuming after an approval or // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly threadKey: string; readonly now: number; } | null>(null); // A pending approval or question is part of the key: once it is answered, // from this client or any other, the agent resumes and spends quota. const usageLimitsKey = [ selectedThreadKey, props.selectedThread.modelSelection.instanceId, props.selectedThread.latestTurn?.turnId ?? "", props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", ].join(":"); // Drop the snapshot as soon as the key changes so it cannot resurface stale. if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { setUsageLimitsPanel(null); } const usageLimitsReport = useMemo( () => usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey ? collectProviderUsageLimits( props.selectedThread.modelSelection.instanceId, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], usageLimitsPanel.now, ) : null, [ props.selectedThread.modelSelection.instanceId, props.serverConfig, usageLimitsKey, usageLimitsPanel, ], ); const showUsageLimits = useCallback( (report: UsageLimitsReport | null) => setUsageLimitsPanel( report === null ? null : { key: usageLimitsKey, threadKey: selectedThreadKey, now: Date.parse(report.createdAt), }, ), [selectedThreadKey, usageLimitsKey], ); const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); // A send may resolve after navigating away, so only the originating // thread's panel is cleared; a panel opened elsewhere in the meantime stays. const clearUsageLimitsFor = useCallback( (threadKey: string) => setUsageLimitsPanel((current) => current !== null && current.threadKey === threadKey ? null : current, ), [], ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of // tracking the keyboard: transforms (the sticky translation) apply // same-frame on the UI thread while layout props lag a Yoga pass behind, // so any height that follows the keyboard flashes the card over the nav // header on the way up. With a constant height the keyboard transition is // pure translation — frame-perfect by construction — and the resting card // stays compact over the transcript. Before the first open the reserve is // an estimate; once a real height is known the card corrects once, // discretely. const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); useEffect(() => { if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { setLastKnownKeyboardHeight(liveKeyboardHeight); } }, [lastKnownKeyboardHeight, liveKeyboardHeight]); const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ windowHeight, keyboardHeight: lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT, navigationHeaderHeight, // The questionnaire owns the composer slot, so only the composer's // bottom inset still overlaps.