import { useOpenThreadInspector } from "./thread-inspector-navigation"; import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, Atom } from "effect/reactivity"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-visits"; import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { effectiveSnoozed, threadWokeAt, isThreadWokeUnseen, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { childThreadInputsTitle, type CodexFeedbackSubmission, type 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 { NativeLayoutObserver } from "../../native/NativeLayoutObserver"; import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions"; import type { EnvironmentId, MessageId, ProviderInteractionMode, RuntimeRequestId, ScopedThreadRef, ServerConfig as T3ServerConfig, UsageLimitsReport, } from "@t3tools/contracts"; import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/shared/codexArtifactTemplates"; import { declinePendingApprovals, sendApprovalFeedback, type ApprovalResponder, type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; import { presentPendingBackgroundWork, presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; import { formatModelSelectionEffort, type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; import * as Haptics from "expo-haptics"; import { useCallback, useContext, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore, } from "react"; import { AppState, Keyboard, Platform, useWindowDimensions, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewInstance, } from "react-native"; import { KeyboardController, KeyboardStickyView, useKeyboardState, useReanimatedKeyboardAnimation, } from "react-native-keyboard-controller"; import Animated, { Easing, FadeInDown, FadeOut, ReduceMotion, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useDeviceType, useDuoBottomBarInset, useNativeColumnLayoutMetrics, useNativeLayoutMetrics, } from "../../native/native-layout-metrics"; 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 { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { ANDROID_READING_MAX_WIDTH, CHAT_CONTENT_MAX_WIDTH, type LayoutVariant, } from "../../lib/layout"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import { deviceEnvironment } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat"; import { useThreadServerBrowserTabs } from "../../state/preview"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { finishThreadMessageJump, pendingThreadMessageJump, subscribeThreadMessageJump, } from "./threadMessageJump"; import { clearThreadComposerError, threadComposerErrorsAtom, } from "../../state/thread-composer-error"; import { environmentThreadDetails, environmentThreadShells, threadEnvironment, } from "../../state/threads"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { PendingApproval, PendingUserInput, PendingUserInputDraftAnswer, ThreadFeedEntry, ThreadFeedLatestRun, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { ProviderSubagentBar } from "./ProviderSubagentBar"; 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, THREAD_COMPOSER_BOTTOM_GUTTER, ThreadComposer, } from "./ThreadComposer"; import { ComposerPopoverHost } from "./ComposerPopoverHost"; import { ThreadFeed, type ThreadFeedHandle, type ThreadFeedHistoryControls } from "./ThreadFeed"; import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import { ThreadGuestShareBar } from "./ThreadGuestShareBar"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; export interface ThreadDetailScreenProps { readonly header?: React.ReactNode; readonly draftMode?: { readonly content: ( headerInset: number, bottomInset: number, availableHeight: number, ) => React.ReactNode; readonly controls: React.ReactNode; readonly editorRef: React.RefObject; readonly sendBlockedReason: string | null; readonly interactionLocked: boolean; }; readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; readonly canOperateThread: boolean; readonly selectedThread: EnvironmentThreadShell; /** Use this for detail reads; it stays null until queued creation reaches the server. */ readonly selectedThreadDetailRef: ScopedThreadRef | null; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; readonly environmentLabel: string | null; readonly feedbackSubmissions: ReadonlyArray; readonly onDismissFeedback: (id: MessageId) => void; readonly selectedThreadFeed: ReadonlyArray; readonly pendingHandoff?: HandoffEndpoints | null; readonly activityRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; readonly runlessWorkActive?: boolean; readonly providerSubagentStatus?: ProviderSubagentStatus | null; readonly isCompacting: boolean; readonly creationState: | { readonly kind: "preparing"; readonly preparingWorktree: boolean; readonly serverOwned: boolean; } | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; readonly activePendingApprovals: ReadonlyArray; readonly respondingApprovalId: RuntimeRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record; readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: RuntimeRequestId | null; readonly connectionStateLabel: EnvironmentConnectionPhase; readonly threadSyncStatus?: EnvironmentThreadStatus; readonly historyControls?: ThreadFeedHistoryControls; readonly activeThreadBusy: boolean; readonly canStopThread: boolean; readonly queuedRunEdit: QueuedRunEdit | null; readonly composerDraftKey: string | null; readonly followUpBehavior: FollowUpBehavior; readonly canSteerActiveTurn: boolean; readonly isSavingQueuedEdit: boolean; readonly onCancelQueuedRunEdit: () => void; readonly onRemoveQueuedEditAttachment: (attachmentId: string) => void; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly queuedMessages: ReadonlyArray; 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; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; readonly onSendMessage: ( followUp?: ActiveTurnComposerAction, beforeDispatch?: ( text: string, hasAttachmentsOrContext: boolean, ) => Promise, ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; readonly onUpdateThreadInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onRespondToApproval: ApprovalResponder; readonly onSelectUserInputOption: ( requestId: RuntimeRequestId, question: ThreadUserInputQuestion, value: string, ) => void; readonly onChangeUserInputCustomAnswer: ( requestId: RuntimeRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmitUserInput: () => Promise; readonly onDismissUserInput: () => Promise; readonly showContent?: boolean; } const USAGE_LIMITS_PANEL_GAP = 12; const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44; const USER_INPUT_TOGGLE_TIMING = { duration: USER_INPUT_TOGGLE_DURATION_MS, easing: Easing.out(Easing.cubic), }; // A scheduled wake can happen without a newer server update, so the tracker // is told when one takes effect. The shell's updatedAt ticks while a run // streams; the tracker follows it outside React. const wakeAwareVisitShell = Atom.family((key: string) => { const ref = parseThreadKey(key); return Atom.make((get) => { const shell = get(environmentThreadShells.threadShellAtom(ref)); if (shell === null) return null; const nowMs = Date.now(); const wokeAt = threadWokeAt(shell, { now: new Date(nowMs).toISOString() }); const wakeAtMs = shell.snoozedUntil == null ? NaN : Date.parse(shell.snoozedUntil); if (wokeAt === null && wakeAtMs > nowMs) { const timer = setTimeout( () => get.refreshSelf(), Math.min(wakeAtMs - nowMs + 50, 2_147_483_647), ); get.addFinalizer(() => clearTimeout(timer)); } return { ...shell, wokeAt }; }).pipe(Atom.withLabel(`mobile-thread-visit-shell:${key}`)); }); const threadVisitTracker = createThreadVisitTracker({ threadShellAtom: (ref) => wakeAwareVisitShell(threadKey(ref)), visit: threadEnvironment.visit, }); // Past the composer's 180 ms height animation. const COMPOSER_HEIGHT_SETTLE_MS = 220; // Anchors, the submitted message, follow state and pending scrolls belong to // one thread, so the body remounts when the thread changes. export function ThreadDetailScreen(props: ThreadDetailScreenProps) { return ( ); } function ThreadDetailBody(props: ThreadDetailScreenProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const { layout } = useAdaptiveWorkspaceLayout(); const deviceType = useDeviceType(); const workspace = useWorkspaceTabs(); const sidebarSearchActive = deviceType === "duo-unfolded" && workspace?.state.tabs[workspace.state.selectedTab].searchMode === "active"; // Keep the last input owner through keyboard dismissal, so closing sidebar // search cannot briefly lift the composer into the keyboard's closing frame. const [composerOwnsKeyboard, setComposerOwnsKeyboard] = useState(false); if (sidebarSearchActive && composerOwnsKeyboard) setComposerOwnsKeyboard(false); const navigation = useNavigation(); const openLinkedThread = useOpenLinkedThread(); const openInspector = useOpenThreadInspector(); const { session: voiceInputSession } = useGlobalVoiceInput(); const reportedModelSelection = useThreadReportedModelSelection(props.selectedThreadDetailRef); const deviceState = useEnvironmentQuery( deviceEnvironment.state({ environmentId: props.environmentId, input: {} }), ); const devicePreviews = threadDevicePreviews(deviceState.data, props.selectedThread.id); const openDevicePreview = () => { Keyboard.dismiss(); if ( openInspector( { environmentId: props.environmentId, threadId: props.selectedThread.id }, { type: "device", environmentId: props.environmentId, threadId: props.selectedThread.id }, ) ) return; navigation.navigate("ThreadDevicePreview", { environmentId: props.environmentId, threadId: props.selectedThread.id, }); }; const browserTabs = useThreadServerBrowserTabs({ environmentId: props.environmentId, threadId: props.selectedThread.id, enabled: !props.draftMode && props.serverConfig?.environment.capabilities.serverBrowser === true, }); const openBrowserPreview = (tabId?: string) => { Keyboard.dismiss(); navigation.navigate("ThreadBrowserPreview", { environmentId: props.environmentId, threadId: props.selectedThread.id, ...(tabId === undefined ? {} : { tabId }), }); }; const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); // Android can lose IME hide callbacks on backgrounding. Quarantine translation on resume unless focused input retained its keyboard. const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); const ownedInputFocusedRef = useRef(false); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to AppState on Android to mark keyboard state suspect when the app resumes useEffect(() => { if (Platform.OS !== "android") { return; } const subscription = AppState.addEventListener("change", (state) => { if (state === "active") { setKeyboardStateSuspect(!ownedInputFocusedRef.current); } }); return () => { subscription.remove(); }; }, []); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the suspect flag whenever the native keyboard reports a visibility or height change useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- Any native keyboard report clears the suspect flag set when the app resumed. setKeyboardStateSuspect(false); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Any keyboard visibility or height report clears the suspect flag set when the app resumed. }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = (focused: boolean) => { ownedInputFocusedRef.current = focused; if (focused) { if (deviceType === "duo-folded" || deviceType === "duo-unfolded") setComposerOwnsKeyboard(true); setKeyboardStateSuspect(false); } }; const windowHeight = useWindowDimensions().height; const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 44; const [screenMetrics, setScreenMetrics] = useState(null); const columnMetrics = useNativeColumnLayoutMetrics(); const windowMetrics = useNativeLayoutMetrics(); const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics; const controlInsets = deriveBottomControlInsets(nativeMetrics); const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const childThreadInputs = useChildThreadInputs({ environmentId: props.environmentId, threadId: props.selectedThread.id, }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount(props.selectedThreadDetailRef); const turnSubagents = useThreadTurnSubagents(props.selectedThreadDetailRef); const agentsSegment = resolveSubagentPillSegment(turnSubagents); const defaultComposerEditorRef = useRef(null); const composerEditorRef = props.draftMode?.editorRef ?? defaultComposerEditorRef; const isProviderSubagent = isProviderNativeSubagentThread(props.selectedThread.source); const editingRunId = props.queuedRunEdit?.runId ?? null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the composer editor on the next animation frame when a queued run edit starts useEffect(() => { if (editingRunId === null) return; const frame = requestAnimationFrame(() => composerEditorRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [editingRunId, composerEditorRef]); const composerOverlayRef = useRef(null); const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); // Aborted when this thread's body unmounts, so async work started for it // stops touching a list or composer that now shows another thread. // Aborted when the screen hides or unmounts; a hidden Activity pane keeps its state but re-runs // effects when shown, so each show arms a fresh scope. const threadScope = useRef(new AbortController()); const [composerHeight, setComposerHeight] = useState(null); // The composer animates its height and reports a layout every frame. The feed follows those // frames on the UI thread through onComposerLayout; this is only an estimate, so committing // each frame would re-render the whole screen for the length of the animation. const composerHeightSettle = useRef | null>(null); const handleComposerHeightChange = (height: number) => { setComposerHeight((current) => current ?? height); if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); composerHeightSettle.current = setTimeout(() => { composerHeightSettle.current = null; setComposerHeight(height); }, COMPOSER_HEIGHT_SETTLE_MS); }; const [anchorMessageId, setAnchorMessageId] = useState(null); const feedRef = useRef(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Keep the bottom padding fixed while the keyboard animates. Changing it at // keyboardDidHide makes the composer land low, then jump back above the safe area. const bottomBarInset = useDuoBottomBarInset(); const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; // A pushed screen temporarily inherits the outgoing tab bar's safe area. // Use the window edge so hiding that bar cannot move the composer mid-push. const bottomSafeArea = Platform.OS === "ios" ? (windowMetrics?.safeArea.bottom ?? insets.bottom) : insets.bottom; // The pill already adds its own gutter; iPad and Duo align its edge with the native bar. const alignedBottomInset = bottomBarInset ?? (Platform.OS === "ios" && Platform.isPad ? Math.max(bottomSafeArea, 12) - (layout.usesSplitView ? 5 : 0) : undefined); // The Duo's floating sidebar pill ends three points below the composer's usual gutter. const composerBottomInset = Math.max( 0, (alignedBottomInset !== undefined ? alignedBottomInset - THREAD_COMPOSER_BOTTOM_GUTTER : Math.max(bottomSafeArea, 12)) - (hasSidebarTabBar ? 3 : 0), ); const composerKeyboardOffset = composerBottomInset; const contentPresentationKind = props.contentPresentation.kind; const realThreadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { return "Syncing messages..."; } return contentPresentationKind === "loading" ? "Loading messages..." : null; default: return null; } })(); const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null); const statusPending = statusRequest?.threadKey === selectedThreadKey; const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); useFocusEffect( useCallback(() => { setLifecycleNow(new Date().toISOString()); const timer = setInterval(() => setLifecycleNow(new Date().toISOString()), 60_000); return () => clearInterval(timer); }, []), ); const configLoaded = props.serverConfig !== null; const snoozed = configLoaded && effectiveSnoozed(props.selectedThread, { now: lifecycleNow }); const snoozedUntil = props.selectedThread.snoozedUntil; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- arms a timer that refreshes the lifecycle clock when the thread's snooze expires useEffect(() => { if (!snoozed || snoozedUntil === null) return; const timer = setTimeout( () => setLifecycleNow(new Date().toISOString()), Math.min(Math.max(0, Date.parse(snoozedUntil) - Date.now()) + 50, 2_147_483_647), ); return () => clearTimeout(timer); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time. }, [snoozed, snoozedUntil, lifecycleNow]); const settled = configLoaded && props.selectedThread.settledOverride === "settled"; const wokeAt = configLoaded ? threadWokeAt(props.selectedThread, { now: lifecycleNow }) : null; const woke = isThreadWokeUnseen({ wokeAt, settled, lastVisitedAt: props.selectedThread.lastVisitedAt, latestRunCompletedAt: props.selectedThread.latestRun?.completedAt ?? null, }); const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => { if (statusPending || !props.canOperateThread) return; const request = { threadKey: selectedThreadKey }; setStatusRequest(request); const target = { environmentId: props.environmentId, input: { threadId: props.selectedThread.id }, }; const recovery = props.selectedThread.limitRecovery; const recoveryOwnsSnooze = recovery?.snooze === true && recovery.runId === props.selectedThread.latestRun?.runId && snoozedUntil !== null && Date.parse(snoozedUntil) === Date.parse(recovery.resetAt); const result = action === "stop" ? await interruptThreadTurn(target) : action === "wake" ? recoveryOwnsSnooze ? await updateMetadata({ ...target, input: { ...target.input, limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false, }, }, }) : await unsnoozeThread({ ...target, input: { ...target.input, reason: "user" } }) : action === "unsettle" ? await unsettleThread({ ...target, input: { ...target.input, reason: "user" } }) : await visitThread({ ...target, input: { ...target.input, visitedAt: wokeAt ?? lifecycleNow }, }); // An accepted interrupt can leave tasks running. Release the action for retry. setStatusRequest((current) => (current === request ? null : current)); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); Dialog.alert( "Could not update thread", error instanceof Error ? error.message : "Try again.", ); } }; const pendingBackgroundWork = presentPendingBackgroundWork( props.selectedThread.pendingBackgroundTasks, ); const floatingStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ connectionError: props.connectionError, connectionState: props.connectionStateLabel, environmentLabel: props.environmentLabel, onReconnect: props.onReconnectEnvironment, }); if (connectionStatus !== null && !props.draftMode) { return connectionStatus; } if (props.activePendingApproval !== null || props.activePendingUserInput !== null) { return null; } if (props.creationState?.kind === "preparing") { if (props.worktreeSetup) return null; 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 }; } const childInput = childThreadInputs[0]; if (childInput && contentPresentationKind === "ready") { return { kind: "child-input", label: childThreadInputsTitle(childThreadInputs), accessibilityLabel: `Open ${childInput.hasPendingApprovals ? "approval" : "question"} from ${childInput.title}`, onPress: () => { Keyboard.dismiss(); openLinkedThread({ environmentId: props.environmentId, threadId: childInput.id }); }, }; } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { kind: "background", label: pendingBackgroundWork.title, accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items .map((item) => item.label) .join(", ")}`, waiting: pendingBackgroundWork.waiting, onPress: () => { Keyboard.dismiss(); navigation.navigate("ThreadBackgroundWork", { environmentId: props.environmentId, threadId: props.selectedThread.id, }); }, action: { label: statusPending ? "Stopping..." : "Stop", accessibilityLabel: "Stop background work", disabled: !props.canOperateThread || statusPending, onPress: () => void runStatusAction("stop"), }, }; } if (contentPresentationKind === "ready" && (snoozed || settled || woke)) { const state = snoozed ? "snoozed" : settled ? "settled" : "woke"; const label = snoozed && snoozedUntil !== null ? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}` : settled ? "Settled" : "Woke from snooze"; const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss"; return { kind: "lifecycle", state, label, action: { label: statusPending ? snoozed ? "Waking..." : settled ? "Un-settling..." : "Dismissing..." : actionLabel, accessibilityLabel: actionLabel, disabled: !props.canOperateThread || statusPending, onPress: () => void runStatusAction(snoozed ? "wake" : settled ? "unsettle" : "dismiss"), }, }; } if (props.selectedThread.goal !== null && contentPresentationKind === "ready") { const goal = presentProviderGoal(props.selectedThread.goal, false); return { kind: "goal", label: goal.title, accessibilityLabel: `${goal.title}: ${goal.objective}`, }; } return null; })(); const showWorkingControl = floatingStatus !== null; const showFloatingStatus = showWorkingControl || queuedCount > 0 || agentsSegment !== null || devicePreviews.length > 0 || browserTabs.tabs.length > 0 || props.connectionStateLabel !== "connected" || props.queuedMessages.length > 0 || props.selectedThreadFeed.some( (entry) => "acknowledged" in entry && entry.acknowledged === true, ); const selectedThreadFeed = props.selectedThreadFeed; // A palette message result opens the thread at that message: earlier // history loads until the message is in the feed, which then scrolls to it. const jumpMessageId = useSyncExternalStore(subscribeThreadMessageJump, () => pendingThreadMessageJump(selectedThreadKey), ); const jumpMessageLoaded = jumpMessageId !== null && selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === jumpMessageId); const historyForJump = props.historyControls; // History down to the message in one request; another page if it is not // there yet. const loadEarlierForJump = useEffectEvent(() => { if (jumpMessageId !== null) historyForJump?.onLoadThrough(jumpMessageId); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests earlier history from the server until the jump target message loads, or ends the jump useEffect(() => { if (jumpMessageId === null || jumpMessageLoaded || contentPresentationKind !== "ready") return; if (historyForJump?.loading) return; if (!historyForJump?.hasMoreHistory || historyForJump.error !== null) { finishThreadMessageJump(selectedThreadKey); return; } loadEarlierForJump(); }, [ contentPresentationKind, historyForJump?.error, historyForJump?.hasMoreHistory, historyForJump?.loading, jumpMessageId, jumpMessageLoaded, selectedThreadKey, ]); const hasCompactableConversation = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user" && ((entry.message.attachments?.length ?? 0) > 0 || entry.message.text.trim().toLowerCase() !== "/compact"), ) || (props.historyControls?.hasMoreHistory === true && props.selectedThread.latestUserMessageAt !== null); const composerOverlapHeight = composerHeight ?? COMPOSER_COLLAPSED_CHROME + (!isProviderSubagent && (deviceType === "pad" || hasSidebarTabBar) ? 14 : 0) + composerBottomInset; // The last collapse toggle; it only applies to the request it was made on. const [userInputToggle, setUserInputToggle] = useState<{ readonly requestId: RuntimeRequestId; readonly collapsed: boolean; } | null>(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; const composerSlotHidden = activeUserInputRequestId !== null || props.creationState?.kind === "failed"; const pendingMessageEditBlocked = queuedRunEditBlockedReason({ openQuestionCount: activeUserInputRequestId === null ? 0 : 1 }) !== null; const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly threadKey: string; readonly now: number; } | null>(null); // Approval/question identity invalidates quota: answering on any client resumes spending. const usageLimitsKey = [ selectedThreadKey, props.selectedThread.modelSelection.instanceId, props.selectedThread.latestRun?.runId ?? "", props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", ].join(":"); if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { setUsageLimitsPanel(null); } const usageLimitsReport = usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey ? collectProviderUsageLimits( props.selectedThread.modelSelection.instanceId, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], usageLimitsPanel.now, ) : null; const showUsageLimits = (report: UsageLimitsReport | null) => setUsageLimitsPanel( report === null ? null : { key: usageLimitsKey, threadKey: selectedThreadKey, now: Date.parse(report.createdAt), }, ); const dismissUsageLimits = () => setUsageLimitsPanel(null); // Late sends clear only their originating thread's limits panel. const clearUsageLimitsFor = (threadKey: string) => setUsageLimitsPanel((current) => current !== null && current.threadKey === threadKey ? null : current, ); const userInputToggled = userInputToggle !== null && userInputToggle.requestId === activeUserInputRequestId; const userInputCollapsed = userInputToggled && userInputToggle.collapsed; // A height React sets from keyboard state lags the sticky translation by a Yoga pass and // flashes over the nav header on the way up, so the usage panel reserves the keyboard instead. const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { setLastKnownKeyboardHeight(liveKeyboardHeight); } const reservedKeyboardHeight = lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT; // Waiting for a visibility render misses the first keyboard frames. const keyboardStickyEnabled = Platform.OS === "ios" ? deviceType !== "duo-unfolded" || (composerOwnsKeyboard && !sidebarSearchActive) : !keyboardStateSuspect; const estimatedOverlayHeight = composerOverlapHeight; // UIKit adds safe-area bottom to overlay insets; subtract here and include it in ThreadFeed scroll math. const nativeInsetOvercount = props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0; const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( listRef, composerOverlayRef, Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, // The composer already reports its animated height on each layout. // Easing these samples again makes the feed trail behind it. 0, ); // Android lays the screen out below its opaque header and reports a zero // header height, so the window height overstates the room by the header. const [screenHeight, setScreenHeight] = useState(null); const handleScreenLayout = (event: LayoutChangeEvent) => { setScreenHeight(event.nativeEvent.layout.height); }; const screenAreaHeight = screenHeight ?? windowHeight; // The question card reads the keyboard frame on the UI thread, in the same frame the sticky // view translates, so it gets the full height while the keyboard is closed and never crosses // the header while the keyboard moves. const { height: keyboardTranslation } = useReanimatedKeyboardAnimation(); const pendingUserInputMaxHeight = useDerivedValue(() => derivePendingUserInputMaxHeight({ windowHeight: screenAreaHeight, keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, navigationHeaderHeight, composerOverlapHeight: composerBottomInset, }), ); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen. const usageLimitsOpen = usageLimitsReport !== null && activeUserInputRequestId === null; const [usageLimitsLayout, setUsageLimitsLayout] = useState({ overlay: 0, panel: 0 }); const handleComposerOverlayLayout = (event: LayoutChangeEvent) => { onComposerLayout(event); if (!usageLimitsOpen) return; const overlay = event.nativeEvent.layout.height; setUsageLimitsLayout((current) => current.overlay === overlay ? current : { ...current, overlay }, ); }; const handleUsageLimitsLayout = (event: LayoutChangeEvent) => { const panel = event.nativeEvent.layout.height; setUsageLimitsLayout((current) => (current.panel === panel ? current : { ...current, panel })); }; const overlayBesideUsageLimits = usageLimitsLayout.overlay > 0 ? usageLimitsLayout.overlay - usageLimitsLayout.panel : composerOverlapHeight; // The floor keeps the close row on screen when the stack below leaves no room at all. const usageLimitsMaxHeight = Math.max( USAGE_LIMITS_PANEL_MIN_HEIGHT, Math.min( Math.round(screenAreaHeight * 0.4), screenAreaHeight - navigationHeaderHeight - reservedKeyboardHeight - overlayBesideUsageLimits - USAGE_LIMITS_PANEL_GAP * 2, ), ); // A toggle animates the card; a new request starts expanded at once. const userInputMotion = !userInputToggled ? "fresh" : userInputCollapsed ? "collapsed" : "expanded"; const userInputCardProgress = useDerivedValue( () => userInputMotion === "fresh" ? 1 : withTiming(userInputMotion === "collapsed" ? 0 : 1, USER_INPUT_TOGGLE_TIMING), [userInputMotion], ); // Collapsing the inset is instant: the sinking card still covers the strip // being revealed, and animating the inset downward drifted the short-content // end anchor. const userInputInsetProgress = useDerivedValue(() => { if (userInputMotion === "collapsed") return 0; return userInputMotion === "fresh" ? 1 : withTiming(1, USER_INPUT_TOGGLE_TIMING); }, [userInputMotion]); const userInputCardCoverage = useSharedValue(0); const composerPopoverHeight = useSharedValue(0); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const reduceMotion = useReducedMotion(); // Repin timers fire after the render that scheduled them, so they read follow // state from the ref the writers below keep in step with it. const endFollowEnabledRef = useRef(true); const overlayRepinTimerRef = useRef | null>(null); const scheduleOverlayRepin = (delayMs: number) => { if (overlayRepinTimerRef.current !== null) { clearTimeout(overlayRepinTimerRef.current); } overlayRepinTimerRef.current = setTimeout(() => { overlayRepinTimerRef.current = null; if (!endFollowEnabledRef.current) { return; } void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { freeze.set(false); }); }, delayMs); }; // LegendList does not re-anchor short followed conversations when floating // coverage changes after load. Re-pin each time it settles at a new value; // the repin checks follow state so a user who scrolled up stays put. const settledFloatingCoverageRef = useRef(0); const repinAfterCoverageSettles = (coverage: number) => { if (coverage === settledFloatingCoverageRef.current) return; settledFloatingCoverageRef.current = coverage; scheduleOverlayRepin(50); }; const floatingCoverageTarget = showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0; const floatingControlCoverage = useDerivedValue( () => withTiming( floatingCoverageTarget, { duration: 180, reduceMotion: ReduceMotion.System }, (finished) => { if (finished) runOnJS(repinAfterCoverageSettles)(floatingCoverageTarget); }, ), [floatingCoverageTarget], ); // Android measures its expanded card in-flow; only iOS needs extra overlay coverage. const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null; const combinedContentInsetEndAdjustment = useSharedValue( Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), ); useAnimatedReaction( () => contentInsetEndAdjustment.value + floatingControlCoverage.value + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), (value) => { combinedContentInsetEndAdjustment.set(value); }, [userInputCoverageApplies], ); const composerPopoverLift = useDerivedValue(() => withTiming( !composerSlotHidden && composerPopoverHeight.value > 0 ? composerPopoverHeight.value + 8 : 0, { duration: 180, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System }, ), ); const floatingControlLift = useDerivedValue( () => Math.max( userInputCoverageApplies ? userInputCardProgress.value * userInputCardCoverage.value : 0, composerPopoverLift.value, ), [userInputCoverageApplies], ); const handleEndFollowEnabledChange = (enabled: boolean) => { endFollowEnabledRef.current = enabled; setEndFollowEnabled(enabled); }; // A jump this thread never reached lets go when it closes. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- aborts the thread scope, clears its timers and ends a pending message jump when the thread changes useEffect(() => { if (threadScope.current.signal.aborted) threadScope.current = new AbortController(); const scope = threadScope.current; return () => { scope.abort(); if (overlayRepinTimerRef.current !== null) clearTimeout(overlayRepinTimerRef.current); if (composerHeightSettle.current !== null) clearTimeout(composerHeightSettle.current); finishThreadMessageJump(selectedThreadKey); }; }, [selectedThreadKey]); const handleToggleUserInputCollapsed = () => { if (activeUserInputRequestId === null) { return; } if (userInputCollapsed) { setUserInputToggle({ requestId: activeUserInputRequestId, collapsed: false }); scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); } else { Keyboard.dismiss(); setUserInputToggle({ requestId: activeUserInputRequestId, collapsed: true }); scheduleOverlayRepin(60); } }; const showContent = props.showContent ?? true; // Showing the thread counts as viewing it, before its live shell arrives too. useAtomMount( threadVisitTracker( showContent && !props.draftMode ? { environmentId: props.environmentId, threadId: props.selectedThread.id } : null, ), ); const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = Platform.OS === "android" ? ANDROID_READING_MAX_WIDTH : isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const providerSubagentProvider = props.serverConfig?.providers.find( (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, ); const providerSubagentModelSlug = providerSubagentProvider ? resolveSelectableModel( providerSubagentProvider.driver, props.selectedThread.modelSelection.model, providerSubagentProvider.models, ) : null; const providerSubagentCatalogModel = providerSubagentProvider?.models.find( (model) => model.slug === providerSubagentModelSlug, ); const workspaceContentWidth = useWorkspaceContentWidth(); // Clearing animated width can retain the unfolded width after Android resumes folded. const composerWidthStyle = useAnimatedStyle(() => isSplitLayout && workspaceContentWidth !== null ? { width: Math.max( 0, workspaceContentWidth.value - controlInsets.left - controlInsets.right, ), right: undefined, } : { width: "auto", right: controlInsets.right }, ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useAtomMount(threadStreamHaptics(props.selectedThreadDetailRef)); const selectedProviderSkills = (() => { const provider = props.serverConfig?.providers.find( (candidate) => candidate.instanceId === selectedInstanceId, ); return provider ? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot) : []; })(); const currentPendingApprovals = () => props.selectedThreadDetailRef === null ? [] : (appAtomRegistry.get( environmentThreadDetails.pendingRequestsAtom(props.selectedThreadDetailRef), )?.approvals ?? []); const handleSendMessage = async ( followUp?: ActiveTurnComposerAction, stillSendable?: () => boolean, ) => { const scope = threadScope.current.signal; const targetThreadKey = selectedThreadKey; const hasUserMessage = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user", ); // Runs only once the send path has validated the draft and is about to deliver it, so an // empty submit, a queued-message edit, or `/feedback` leaves approvals alone. const approvalsToDecline = props.activePendingApprovals; const declineBeforeDispatch = approvalsToDecline.length > 0 && props.activePendingUserInput === null ? async (text: string, hasAttachmentsOrContext: boolean) => { if (scope.aborted || stillSendable?.() === false || props.respondingApprovalId !== null) return false; const feedbackTarget = approvalsToDecline[0]; if (feedbackTarget?.supportsFeedback) { if (hasAttachmentsOrContext) { Dialog.alert( "Approval feedback", "Send approval feedback without attachments or added context.", ); return false; } return (await sendApprovalFeedback(feedbackTarget, text, props.onRespondToApproval)) ? ("responded" as const) : false; } return ( props.respondingApprovalId === null && (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && !scope.aborted && // A draft edited or pasted into during the decline isn't what the user sent. stillSendable?.() !== false && // An approval that arrived during the decline would block the redirect; the draft // stays so the next send declines it too. !currentPendingApprovals().some( (approval) => !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), ) ); } : undefined; const messageId = await props.onSendMessage(followUp, declineBeforeDispatch); if (messageId === null || scope.aborted) { return messageId; } clearUsageLimitsFor(targetThreadKey); const nextAnchorMessageId = resolveThreadFeedSubmissionAnchor({ currentAnchorMessageId: anchorMessageId, submittedMessageId: messageId, hasStartedTurn: props.selectedThread.latestRun !== null, hasUserMessage, queuedMessageCount: props.selectedThreadQueueCount, deliveryDeferred: props.connectionStateLabel !== "connected", }); feedRef.current?.resetEndFollow(); setAnchorMessageId(nextAnchorMessageId); composerEditorRef.current?.blur(); // The send enqueued the message optimistically, so it is in the feed or // the queue by the next frame, once the new anchor has rendered. if (nextAnchorMessageId === messageId && contentPresentationKind === "ready") { requestAnimationFrame(() => { if (scope.aborted) return; // Wait for the keyboard to finish dismissing: scrollMessageToEnd freezes // keyboard-driven inset updates, and a close event swallowed by that // freeze leaves a phantom bottom inset. void KeyboardController.dismiss() .then(() => { // A scroll during the dismissal turns end-follow off; leave the reader where they are. if (scope.aborted || !endFollowEnabledRef.current) return; return scrollMessageToEnd({ animated: !reduceMotion, closeKeyboard: false }); }) .catch(() => { if (!scope.aborted) freeze.set(false); }); }); } return messageId; }; const handleEditPendingMessage = async (message: QueuedThreadMessage) => { if (pendingMessageEditBlocked) return; const scope = threadScope.current.signal; const edited = await settlePromise(() => editPendingThreadMessage(message)); if (AsyncResult.isFailure(edited)) { const error = squashAtomCommandFailure(edited); Dialog.alert( "Could not edit message", error instanceof Error ? error.message : "Please try again.", ); return; } if ( edited.value && !scope.aborted && selectedThreadKey === scopedThreadKey(message.environmentId, message.threadId) ) { composerEditorRef.current?.focus(); } }; const collapseComposer = () => { composerEditorRef.current?.blur(); }; const handleUseArtifactTemplate = (template: CodexArtifactTemplate) => { const draftMessage = props.composerDraftKey === null ? "" : getComposerDraftSnapshot(props.composerDraftKey).text; const nextDraft = appendCodexArtifactTemplateUsePrompt(draftMessage, template); if (nextDraft !== draftMessage) props.onChangeDraftMessage(nextDraft); requestAnimationFrame(() => { composerEditorRef.current?.focus(); composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length }); }); }; const handleScrollToEnd = () => { void Haptics.selectionAsync(); void scrollMessageToEnd({ animated: !reduceMotion, closeKeyboard: false }).catch(() => { freeze.set(false); }); }; const showScrollToEndButton = !props.draftMode && contentPresentationKind === "ready" && !endFollowEnabled; const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; const handleFeedTouchStart = (event: GestureResponderEvent) => { feedTouchStartRef.current = { pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY, }; }; const handleFeedTouchMove = (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 = () => { if (feedTouchStartRef.current) { collapseComposer(); } feedTouchStartRef.current = null; }; const handleFeedTouchCancel = () => { feedTouchStartRef.current = null; }; return ( {!usesNativeWorkspaceColumns ? : null} {props.draftMode ? ( props.draftMode.content( navigationHeaderHeight, liveKeyboardHeight + estimatedOverlayHeight + 24, screenAreaHeight - navigationHeaderHeight - liveKeyboardHeight - estimatedOverlayHeight - 24, ) ) : showContent ? ( ( )} > finishThreadMessageJump(selectedThreadKey)} ref={feedRef} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} keyboardOffset={composerKeyboardOffset} contentTopInset={0} contentBottomInset={ estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) } contentMaxWidth={contentMaxWidth} historyControls={props.historyControls} contentSideInsets={nativeMetrics?.safeArea} layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} onEndFollowEnabledChange={handleEndFollowEnabledChange} skills={selectedProviderSkills} onUseArtifactTemplate={handleUseArtifactTemplate} /> ) : ( )} {showContent ? ( ) : null} {showContent ? ( ) : null} ); }