1import { useAtomValue } from "@effect/atom-react";
2import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";
3import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
4import { ScriptProgress } from "./ScriptProgress";
5import { useNavigation } from "@react-navigation/native";
6import type { WorktreeSetupCardProps } from "./worktree-setup-card";
7import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
8import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
10import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
11import {
12 childThreadInputsTitle,
13 type CodexFeedbackSubmission,
14 type EnvironmentThreadStatus,
15} from "@t3tools/client-runtime/state/threads";
16import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
17import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
18import type { LegendListRef } from "@legendapp/list/react-native";
19import { HeaderHeightContext } from "@react-navigation/elements";
20import { NativeLayoutObserver } from "../../native/NativeLayoutObserver";
21import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions";
22import type {
23 EnvironmentId,
24 MessageId,
25 ModelSelection,
26 ProviderApprovalDecision,
27 ProviderInteractionMode,
28 RuntimeMode,
29 RuntimeRequestId,
30 ServerConfig as T3ServerConfig,
31 ThreadId,
32 UsageLimitsReport,
33} from "@t3tools/contracts";
34import {
35 appendCodexArtifactTemplateUsePrompt,
36 type CodexArtifactTemplate,
37} from "@t3tools/shared/codexArtifactTemplates";
38import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
39import {
40 presentPendingBackgroundWork,
41 presentProviderGoal,
42} from "@t3tools/client-runtime/state/thread-execution";
43import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
44import {
45 formatModelSelectionEffort,
46 type ProviderSubagentStatus,
47} from "@t3tools/client-runtime/state/thread-execution";
48import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
49import { isProviderNativeSubagentThread } from "@t3tools/contracts";
50import type { QueuedRunEdit } from "../../state/queued-run-edit";
51import type { FollowUpBehavior } from "../../lib/followUpBehavior";
52import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
53import * as Haptics from "expo-haptics";
54import {
55 memo,
56 useCallback,
57 useContext,
58 useEffect,
59 useLayoutEffect,
60 useMemo,
61 useRef,
62 useState,
63} from "react";
64import {
65 Alert,
66 AppState,
67 Keyboard,
68 Platform,
69 useWindowDimensions,
70 View,
71 type GestureResponderEvent,
72 type ViewInstance,
73} from "react-native";
74import {
75 KeyboardController,
76 KeyboardStickyView,
77 useKeyboardState,
78} from "react-native-keyboard-controller";
79import Animated, {
80 Easing,
81 FadeInDown,
82 FadeOut,
83 ReduceMotion,
84 useAnimatedReaction,
85 useAnimatedStyle,
86 useDerivedValue,
87 useSharedValue,
88 withTiming,
89} from "react-native-reanimated";
90import { useSafeAreaInsets } from "react-native-safe-area-context";
91import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
92import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
93import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
94import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
95import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
96import type { ComposerEditorHandle } from "../../components/ComposerEditor";
97import type { StatusTone } from "../../components/StatusPill";
98import type { DraftComposerAttachment } from "../../lib/composerImages";
99import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
100import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
101import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
102import { deviceEnvironment } from "../../state/device";
103import { useEnvironmentQuery } from "../../state/query";
104import { threadDevicePreviews } from "../devices/threadDevicePreviews";
105import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat";
106import { useThreadServerBrowserTabs } from "../../state/preview";
107import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
108import { scopedThreadKey } from "../../lib/scopedEntities";
109import {
110 clearThreadComposerError,
111 threadComposerErrorsAtom,
112} from "../../state/thread-composer-error";
113import { threadEnvironment } from "../../state/threads";
114import { useAtomCommand } from "../../state/use-atom-command";
115import { useDelayedStatus } from "../../lib/useDelayedStatus";
116import type {
117 PendingApproval,
118 PendingUserInput,
119 PendingUserInputDraftAnswer,
120 ThreadFeedEntry,
121 ThreadFeedLatestRun,
122} from "../../lib/threadActivity";
123import { PendingApprovalCard } from "./PendingApprovalCard";
124import { ComposerErrorNotice } from "./ComposerErrorNotice";
125import { ComposerFeedback } from "./ComposerFeedback";
126import { ComposerUsageLimits } from "./ComposerUsageLimits";
127import { PendingUserInputCard } from "./PendingUserInputCard";
128import { ProviderSubagentBar } from "./ProviderSubagentBar";
129import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
130import {
131 FLOATING_WORKING_CONTROL_COVERAGE,
132 FloatingWorkingControl,
133} from "./floating-working-control";
134import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";
135import {
136 derivePendingUserInputMaxHeight,
137 ESTIMATED_KEYBOARD_HEIGHT,
138 USER_INPUT_TOGGLE_DURATION_MS,
139} from "./pendingUserInputLayout";
140import {
141 COMPOSER_COLLAPSED_CHROME,
142 COMPOSER_EXPANDED_CHROME,
143 COMPOSER_LAYOUT_TRANSITION,
144 COMPOSER_TRANSITION_DURATION_MS,
145 ThreadComposer,
146} from "./ThreadComposer";
147import { ComposerPopoverHost } from "./ComposerPopoverHost";
148import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
149import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
150import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
151import { useThreadQueuedCount } from "./ThreadQueueControl";
152import type { ThreadContentPresentation } from "./threadContentPresentation";
153import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
154import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider";
155
156export interface ThreadDetailScreenProps {
157 readonly worktreeSetup?: WorktreeSetupCardProps | null;
158 readonly setupWorkingStartedAt?: string | null;
159 readonly canOperateThread: boolean;
160 readonly selectedThread: EnvironmentThreadShell;
161 readonly contentPresentation: ThreadContentPresentation;
162 readonly screenTone: StatusTone;
163 readonly connectionError: string | null;
164 readonly environmentLabel: string | null;
165 readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
166 readonly onDismissFeedback: (id: MessageId) => void;
167 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
168 readonly pendingHandoff?: HandoffEndpoints | null;
169 readonly activityRun: ThreadFeedLatestRun | null;
170 readonly activeWorkStartedAt: string | null;
171 /** The live work is a provider-native subagent's runless root turn. */
172 readonly runlessWorkActive?: boolean;
173 /** Set on a provider-native subagent thread, which shows status instead of a composer. */
174 readonly providerSubagentStatus?: ProviderSubagentStatus | null;
175 readonly isCompacting: boolean;
176 /**
177 * The server has not created this thread yet. "preparing" runs while the
178 * queued creation is delivered (a worktree may be checking out); "failed"
179 * is a rejected creation whose content went back to the project draft.
180 * `serverOwned` marks a thread the server already created that is still
181 * setting up its worktree; follow-ups queue on the server behind that setup.
182 */
183 readonly creationState:
184 | {
185 readonly kind: "preparing";
186 readonly preparingWorktree: boolean;
187 readonly serverOwned: boolean;
188 }
189 | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void }
190 | null;
191 readonly activePendingApproval: PendingApproval | null;
192 readonly respondingApprovalId: RuntimeRequestId | null;
193 readonly activePendingUserInput: PendingUserInput | null;
194 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
195 readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
196 readonly respondingUserInputId: RuntimeRequestId | null;
197 readonly draftMessage: string;
198 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
199 readonly connectionStateLabel: EnvironmentConnectionPhase;
200 /** Message sync status for the selected thread (drives the composer status pill). */
201 readonly threadSyncStatus?: EnvironmentThreadStatus;
202 /** Progressive history controls for oversized mobile thread opens. */
203 readonly historyControls?: ThreadFeedHistoryControls;
204 readonly activeThreadBusy: boolean;
205 readonly canStopThread: boolean;
206 /** Set while a queued message is open in the composer for editing. */
207 readonly queuedRunEdit: QueuedRunEdit | null;
208 readonly composerDraftKey: string | null;
209 readonly followUpBehavior: FollowUpBehavior;
210 readonly canSteerActiveTurn: boolean;
211 readonly isSavingQueuedEdit: boolean;
212 readonly onCancelQueuedRunEdit: () => void;
213 readonly onRemoveQueuedEditAttachment: (attachmentId: string) => void;
214 readonly environmentId: EnvironmentId;
215 readonly projectWorkspaceRoot: string | null;
216 readonly threadCwd: string | null;
217 readonly selectedThreadQueueCount: number;
218 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
219 readonly dispatchingMessageId: MessageId | null;
220 readonly serverConfig: T3ServerConfig | null;
221 readonly layoutVariant?: LayoutVariant;
222 readonly usesAutomaticContentInsets?: boolean;
223 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
224 readonly onOpenConnectionEditor: () => void;
225 readonly onChangeDraftMessage: (value: string) => void;
226 readonly onPickDraftMedia: () => Promise<void>;
227 readonly onPickDraftFiles: () => Promise<void>;
228 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
229 readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
230 readonly onRemoveDraftImage: (imageId: string) => void;
231 readonly onStopThread: () => void;
232 readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise<MessageId | null>;
233 readonly onReconnectEnvironment: () => void;
234 /** Whether the model picker may offer providers other than this thread's. */
235 readonly canSwitchThreadProvider: boolean;
236 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
237 readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
238 readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void;
239 readonly onRespondToApproval: (
240 requestId: RuntimeRequestId,
241 decision: ProviderApprovalDecision,
242 ) => Promise<unknown>;
243 readonly onSelectUserInputOption: (
244 requestId: RuntimeRequestId,
245 question: ThreadUserInputQuestion,
246 value: string,
247 ) => void;
248 readonly onChangeUserInputCustomAnswer: (
249 requestId: RuntimeRequestId,
250 questionId: string,
251 customAnswer: string,
252 ) => void;
253 readonly onSubmitUserInput: () => Promise<unknown>;
254 readonly onDismissUserInput: () => Promise<unknown>;
255 readonly showContent?: boolean;
256}
257
258function latestStreamingAssistantMessage(
259 feed: ReadonlyArray<ThreadFeedEntry>,
260): { readonly id: string; readonly textLength: number } | null {
261 for (let index = feed.length - 1; index >= 0; index -= 1) {
262 const entry = feed[index];
263 if (entry?.type !== "message") {
264 continue;
265 }
266 if (entry.message.role !== "assistant" || !entry.message.streaming) {
267 continue;
268 }
269 return {
270 id: entry.message.id,
271 textLength: entry.message.text.length,
272 };
273 }
274
275 return null;
276}
277
278function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) {
279 const lastStreamingAssistantRef = useRef<{
280 readonly id: string;
281 readonly textLength: number;
282 } | null>(null);
283 const lastStreamHapticAtRef = useRef(0);
284 const hydratedRef = useRef(false);
285 const previousThreadIdRef = useRef(threadId);
286
287 useEffect(() => {
288 if (previousThreadIdRef.current !== threadId) {
289 previousThreadIdRef.current = threadId;
290 hydratedRef.current = false;
291 }
292
293 const latestStreamingMessage = latestStreamingAssistantMessage(feed);
294
295 if (!hydratedRef.current) {
296 hydratedRef.current = true;
297 lastStreamingAssistantRef.current = latestStreamingMessage;
298 return;
299 }
300
301 if (!latestStreamingMessage) {
302 lastStreamingAssistantRef.current = null;
303 return;
304 }
305
306 const previousStreamingMessage = lastStreamingAssistantRef.current;
307 lastStreamingAssistantRef.current = latestStreamingMessage;
308
309 const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id;
310 const textGrew =
311 previousStreamingMessage?.id === latestStreamingMessage.id &&
312 latestStreamingMessage.textLength > previousStreamingMessage.textLength;
313
314 if (!isNewStream && !textGrew) {
315 return;
316 }
317
318 const now = Date.now();
319 if (!isNewStream && now - lastStreamHapticAtRef.current < 320) {
320 return;
321 }
322
323 lastStreamHapticAtRef.current = now;
324 void Haptics.selectionAsync();
325 }, [threadId, feed]);
326}
327
328const USER_INPUT_TOGGLE_TIMING = {
329 duration: USER_INPUT_TOGGLE_DURATION_MS,
330 easing: Easing.out(Easing.cubic),
331};
332
333export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
334 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
335 const navigation = useNavigation();
336 const { session: voiceInputSession } = useGlobalVoiceInput();
337 const reportedModelSelection = useThreadReportedModelSelection({
338 environmentId: props.environmentId,
339 threadId: props.selectedThread.id,
340 });
341 const deviceState = useEnvironmentQuery(
342 deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
343 );
344 const devicePreviews = useMemo(
345 () => threadDevicePreviews(deviceState.data, props.selectedThread.id),
346 [deviceState.data, props.selectedThread.id],
347 );
348 const openDevicePreview = useCallback(() => {
349 Keyboard.dismiss();
350 navigation.navigate("ThreadDevicePreview", {
351 environmentId: props.environmentId,
352 threadId: props.selectedThread.id,
353 });
354 }, [navigation, props.environmentId, props.selectedThread.id]);
355 const browserTabs = useThreadServerBrowserTabs({
356 environmentId: props.environmentId,
357 threadId: props.selectedThread.id,
358 enabled: props.serverConfig?.environment.capabilities.serverBrowser === true,
359 });
360 const openBrowserPreview = useCallback(
361 (tabId?: string) => {
362 Keyboard.dismiss();
363 navigation.navigate("ThreadBrowserPreview", {
364 environmentId: props.environmentId,
365 threadId: props.selectedThread.id,
366 ...(tabId === undefined ? {} : { tabId }),
367 });
368 },
369 [navigation, props.environmentId, props.selectedThread.id],
370 );
371 const insets = useSafeAreaInsets();
372 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
373 const liveKeyboardHeight = useKeyboardState((state) => state.height);
374 // Android can swallow the IME hide callbacks when the app is backgrounded
375 // mid keyboard-hide (the reported repro: send — which blurs and starts the
376 // hide — then Home within a second). The keyboard library's height AND
377 // visibility then stay frozen open, so gating the sticky translation on
378 // visibility alone still strands the composer after resume. Quarantine the
379 // translation on every Android resume instead; any sign of a live keyboard
380 // stream — an owned input gaining focus, or any visibility/height movement —
381 // lifts it. A healthy resume sees no visual difference (the translation is
382 // already zero while the keyboard is closed).
383 const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false);
384 useEffect(() => {
385 if (Platform.OS !== "android") {
386 return;
387 }
388 const subscription = AppState.addEventListener("change", (state) => {
389 if (state === "active") {
390 setKeyboardStateSuspect(true);
391 }
392 });
393 return () => {
394 subscription.remove();
395 };
396 }, []);
397 useEffect(() => {
398 setKeyboardStateSuspect(false);
399 }, [isKeyboardVisible, liveKeyboardHeight]);
400 const handleOwnedInputFocusChange = useCallback((focused: boolean) => {
401 if (focused) {
402 setKeyboardStateSuspect(false);
403 }
404 }, []);
405 const windowHeight = useWindowDimensions().height;
406 const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 44;
407 const [screenMetrics, setScreenMetrics] = useState<NativeLayoutMetrics | null>(null);
408 const columnMetrics = useNativeColumnLayoutMetrics();
409 const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics;
410 const controlInsets = deriveBottomControlInsets(nativeMetrics);
411 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
412 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
413 const childThreadInputs = useChildThreadInputs({
414 environmentId: props.environmentId,
415 threadId: props.selectedThread.id,
416 });
417 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
418 const queuedCount = useThreadQueuedCount({
419 environmentId: props.environmentId,
420 threadId: props.selectedThread.id,
421 });
422 const turnSubagents = useThreadTurnSubagents({
423 environmentId: props.environmentId,
424 threadId: props.selectedThread.id,
425 });
426 const agentsSegment = resolveSubagentPillSegment(turnSubagents);
427 const composerEditorRef = useRef<ComposerEditorHandle>(null);
428 // A provider-native subagent shows status instead of a composer.
429 const isProviderSubagent = isProviderNativeSubagentThread(props.selectedThread.source);
430 // Entering edit mode from the queue sheet should land in a ready composer,
431 // not require a second tap on a composer already holding the message.
432 const editingRunId = props.queuedRunEdit?.runId ?? null;
433 useEffect(() => {
434 if (editingRunId === null) return;
435 const frame = requestAnimationFrame(() => composerEditorRef.current?.focus());
436 return () => cancelAnimationFrame(frame);
437 }, [editingRunId]);
438 const draftMessageRef = useRef(props.draftMessage);
439 draftMessageRef.current = props.draftMessage;
440 const composerOverlayRef = useRef<ViewInstance>(null);
441 const listRef = useRef<LegendListRef>(null);
442 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
443 const selectedThreadKeyRef = useRef(selectedThreadKey);
444 const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null);
445 const [composerExpanded, setComposerExpanded] = useState(false);
446 const [composerFocused, setComposerFocused] = useState(false);
447 const handleComposerFocusChange = useCallback(
448 (focused: boolean) => {
449 setComposerFocused(focused);
450 handleOwnedInputFocusChange(focused);
451 },
452 [handleOwnedInputFocusChange],
453 );
454 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
455 const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null);
456 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
457 // Android keys the safe-area padding on keyboard visibility (#5988): the
458 // back gesture closes the keyboard while the editor stays focused, and a
459 // focus-keyed inset would leave the toolbar under the gesture bar. iOS must
460 // NOT use visibility — it only flips on keyboardDidHide, after the hide
461 // animation, so the composer would ride down flush to the screen edge and
462 // then snap up into the inset. On iOS blur precedes the hide, so the
463 // focus-keyed inset is already in place while the composer rides down.
464 // Dictation keeps that focus while the composer switches to its compact pill.
465 const composerBottomInset = (
466 Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused
467 )
468 ? 0
469 : Math.max(insets.bottom, 12);
470 const contentPresentationKind = props.contentPresentation.kind;
471 // The raw sync status enters "synchronizing" on every full fetch, cached or
472 // not. Whether messages are already on screen decides the pill label: no
473 // data yet → "Loading messages", cached data reconciling → "Syncing".
474 const realThreadSyncLabel = (() => {
475 switch (props.threadSyncStatus) {
476 case "empty":
477 case "cached":
478 case "synchronizing":
479 if (contentPresentationKind === "ready") {
480 return "Syncing messages...";
481 }
482 return contentPresentationKind === "loading" ? "Loading messages..." : null;
483 default:
484 return null;
485 }
486 })();
487 // Opening a running thread resyncs for a few frames. The pill shows the
488 // sync label only when the sync lasts, so it does not flash before the timer.
489 const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel);
490 // One floating pill above the composer: it reads the connection phase while
491 // disconnected, the sync state while messages load, then the working timer
492 // once the feed is settled.
493 // The shell's roster is the server's post-settlement view of what still runs.
494 const pendingBackgroundWork = presentPendingBackgroundWork(
495 props.selectedThread.pendingBackgroundTasks,
496 );
497 const floatingStatus = ((): FloatingWorkingStatus | null => {
498 const connectionStatus = connectionFloatingStatus({
499 connectionError: props.connectionError,
500 connectionState: props.connectionStateLabel,
501 environmentLabel: props.environmentLabel,
502 onReconnect: props.onReconnectEnvironment,
503 });
504 if (connectionStatus !== null) {
505 return connectionStatus;
506 }
507 if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
508 return null;
509 }
510 if (props.creationState?.kind === "preparing") {
511 // The setup header already reports progress in the feed.
512 if (props.worktreeSetup) return null;
513 return {
514 kind: "preparing",
515 label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…",
516 };
517 }
518 if (props.creationState?.kind === "failed") {
519 return null;
520 }
521 if (threadSyncLabel !== null) {
522 return { kind: "syncing", label: threadSyncLabel };
523 }
524 const childInput = childThreadInputs[0];
525 if (childInput && contentPresentationKind === "ready") {
526 return {
527 kind: "child-input",
528 label: childThreadInputsTitle(childThreadInputs),
529 accessibilityLabel: `Open ${childInput.hasPendingApprovals ? "approval" : "question"} from ${childInput.title}`,
530 onPress: () => {
531 Keyboard.dismiss();
532 navigation.navigate("Thread", {
533 environmentId: props.environmentId,
534 threadId: childInput.id,
535 });
536 },
537 };
538 }
539 if (props.isCompacting && contentPresentationKind === "ready") {
540 return { kind: "compacting" };
541 }
542 if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
543 return { kind: "working", startedAt: props.activeWorkStartedAt };
544 }
545 if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
546 return {
547 kind: "background",
548 label: pendingBackgroundWork.title,
549 accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items
550 .map((item) => item.label)
551 .join(", ")}`,
552 waiting: pendingBackgroundWork.waiting,
553 };
554 }
555 if (props.selectedThread.goal !== null && contentPresentationKind === "ready") {
556 const goal = presentProviderGoal(props.selectedThread.goal, false);
557 return {
558 kind: "goal",
559 label: goal.title,
560 accessibilityLabel: `${goal.title}: ${goal.objective}`,
561 };
562 }
563 return null;
564 })();
565 const showWorkingControl = floatingStatus !== null;
566 // Connection and working status occupy the same space. Keep the feed inset
567 // stable when reconnecting hands off to syncing and then to a running turn.
568 const showFloatingStatus =
569 showWorkingControl ||
570 queuedCount > 0 ||
571 agentsSegment !== null ||
572 devicePreviews.length > 0 ||
573 browserTabs.tabs.length > 0 ||
574 props.connectionStateLabel !== "connected" ||
575 props.queuedMessages.length > 0 ||
576 props.selectedThreadFeed.some(
577 (entry) => "acknowledged" in entry && entry.acknowledged === true,
578 );
579 const selectedThreadFeed = props.selectedThreadFeed;
580 const hasCompactableConversation =
581 selectedThreadFeed.some(
582 (entry) =>
583 entry.type === "message" &&
584 entry.message.role === "user" &&
585 ((entry.message.attachments?.length ?? 0) > 0 ||
586 entry.message.text.trim().toLowerCase() !== "/compact"),
587 ) ||
588 (props.historyControls?.hasMoreHistory === true &&
589 props.selectedThread.latestUserMessageAt !== null);
590 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
591 const composerOverlapHeight = composerChrome + composerBottomInset;
592 // While a user-input request is pending, the questionnaire owns the
593 // composer slot outright: expanded it is the full card, collapsed it is a
594 // composer-style bar in the same place (with its own stop control). The
595 // composer never mounts into the transition, which keeps the collapse and
596 // keyboard animations coherent. Collapse state is keyed by request id so a
597 // new request re-expands automatically.
598 const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
599 useState<RuntimeRequestId | null>(null);
600 const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
601 // A pending user-input request or a failed creation owns the composer slot.
602 const composerSlotHidden =
603 activeUserInputRequestId !== null || props.creationState?.kind === "failed";
604 // The open /usage-limits panel for this thread, model and turn. Only the open
605 // moment is stored: the rows read live provider data, so a redeemed reset
606 // credit or refreshed probe shows through. Anything that spends quota closes
607 // it: a new turn from any source, or the agent resuming after an approval or
608 // answered question.
609 const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
610 readonly key: string;
611 readonly threadKey: string;
612 readonly now: number;
613 } | null>(null);
614 // A pending approval or question is part of the key: once it is answered,
615 // from this client or any other, the agent resumes and spends quota.
616 const usageLimitsKey = [
617 selectedThreadKey,
618 props.selectedThread.modelSelection.instanceId,
619 props.selectedThread.latestRun?.runId ?? "",
620 props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "",
621 ].join(":");
622 // Drop the snapshot as soon as the key changes so it cannot resurface stale.
623 if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) {
624 setUsageLimitsPanel(null);
625 }
626 const usageLimitsReport = useMemo(
627 () =>
628 usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey
629 ? collectProviderUsageLimits(
630 props.selectedThread.modelSelection.instanceId,
631 props.serverConfig?.providers ?? [],
632 props.serverConfig?.usageLimitSources ?? [],
633 usageLimitsPanel.now,
634 )
635 : null,
636 [
637 props.selectedThread.modelSelection.instanceId,
638 props.serverConfig,
639 usageLimitsKey,
640 usageLimitsPanel,
641 ],
642 );
643 const showUsageLimits = useCallback(
644 (report: UsageLimitsReport | null) =>
645 setUsageLimitsPanel(
646 report === null
647 ? null
648 : {
649 key: usageLimitsKey,
650 threadKey: selectedThreadKey,
651 now: Date.parse(report.createdAt),
652 },
653 ),
654 [selectedThreadKey, usageLimitsKey],
655 );
656 const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []);
657 // A send may resolve after navigating away, so only the originating
658 // thread's panel is cleared; a panel opened elsewhere in the meantime stays.
659 const clearUsageLimitsFor = useCallback(
660 (threadKey: string) =>
661 setUsageLimitsPanel((current) =>
662 current !== null && current.threadKey === threadKey ? null : current,
663 ),
664 [],
665 );
666 const userInputCollapsed =
667 activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;
668 // The card's height RESERVES keyboard space at all times instead of
669 // tracking the keyboard: transforms (the sticky translation) apply
670 // same-frame on the UI thread while layout props lag a Yoga pass behind,
671 // so any height that follows the keyboard flashes the card over the nav
672 // header on the way up. With a constant height the keyboard transition is
673 // pure translation — frame-perfect by construction — and the resting card
674 // stays compact over the transcript. Before the first open the reserve is
675 // an estimate; once a real height is known the card corrects once,
676 // discretely.
677 const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0);
678 useEffect(() => {
679 if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) {
680 setLastKnownKeyboardHeight(liveKeyboardHeight);
681 }
682 }, [lastKnownKeyboardHeight, liveKeyboardHeight]);
683 const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({
684 windowHeight,
685 keyboardHeight:
686 lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT,
687 navigationHeaderHeight,
688 // The questionnaire owns the composer slot, so only the composer's
689 // bottom inset still overlaps.
690 composerOverlapHeight: composerBottomInset,
691 });
692 const estimatedOverlayHeight = composerOverlapHeight;
693 // The overlay's measured height includes the home-indicator inset (the
694 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
695 // UIKit add the safe-area bottom to the content inset AGAIN — leaving a
696 // dead strip between the resting content and the composer. Report the
697 // overlay height minus the safe area; UIKit adds it back, and ThreadFeed
698 // hands LegendList the same delta via contentInsetEndStaticAdjustment so
699 // its end-scroll math matches the real resting position.
700 const nativeInsetOvercount =
701 props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;
702 const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
703 listRef,
704 composerOverlayRef,
705 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
706 -nativeInsetOvercount,
707 Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0,
708 );
709 // The expanded questionnaire is an absolute overlay on iOS, so it never
710 // changes the measured overlay height (that constancy is what keeps the
711 // feed from snapping on collapse/expand). The toggle choreography runs on
712 // SHARED VALUES set directly in the tap handler — one JS hop, then the
713 // card's rise/sink and the feed's end-inset glide animate in lockstep on
714 // the UI thread, keyboard-style, instead of waiting on React mount +
715 // onLayout + state round trips. Coverage (how far the card extends above
716 // the bar) is measured straight into a shared value by the card's
717 // onLayout, with no re-render.
718 const userInputCardProgress = useSharedValue(1);
719 const userInputInsetProgress = useSharedValue(1);
720 const userInputCardCoverage = useSharedValue(0);
721 const floatingControlCoverage = useSharedValue(
722 showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
723 );
724 useEffect(() => {
725 floatingControlCoverage.value = withTiming(
726 showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
727 { duration: 180, reduceMotion: ReduceMotion.System },
728 );
729 }, [floatingControlCoverage, showFloatingStatus]);
730 // Android renders the expanded card in-flow (it cannot hit-test the iOS
731 // overlay outside the bar's bounds), so its measured overlay height already
732 // includes the card — the coverage extra is iOS-only.
733 const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null;
734 const combinedContentInsetEndAdjustment = useSharedValue(
735 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
736 );
737 useAnimatedReaction(
738 () =>
739 contentInsetEndAdjustment.value +
740 floatingControlCoverage.value +
741 (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0),
742 (value) => {
743 combinedContentInsetEndAdjustment.value = value;
744 },
745 [userInputCoverageApplies],
746 );
747 // The floating control is anchored to the bar's top edge, so ride it up
748 // with the expanded card instead of drawing it over the questions.
749 const floatingControlLift = useDerivedValue(
750 () =>
751 userInputCoverageApplies ? userInputCardProgress.value * userInputCardCoverage.value : 0,
752 [userInputCoverageApplies],
753 );
754 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
755 const endFollowEnabledRef = useRef(true);
756 endFollowEnabledRef.current = endFollowEnabled;
757 const overlayRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
758 const previousWorkingControlStateRef = useRef({
759 threadKey: selectedThreadKey,
760 visible: false,
761 });
762 // The list's own corrections for these inset changes drift on short
763 // content (and the error compounds across toggles), so deterministically
764 // re-pin the end once a toggle settles: a no-op when the resting position
765 // is already right, corrective when it is not. Follow state is re-checked
766 // inside the callback — the user may grab the list during the settle
767 // window, and yanking them back would override a live gesture.
768 const scheduleOverlayRepin = useCallback(
769 (delayMs: number) => {
770 if (overlayRepinTimerRef.current !== null) {
771 clearTimeout(overlayRepinTimerRef.current);
772 }
773 overlayRepinTimerRef.current = setTimeout(() => {
774 overlayRepinTimerRef.current = null;
775 if (!endFollowEnabledRef.current) {
776 return;
777 }
778 void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {
779 freeze.set(false);
780 });
781 }, delayMs);
782 },
783 [freeze, scrollMessageToEnd],
784 );
785 useEffect(
786 () => () => {
787 if (overlayRepinTimerRef.current !== null) {
788 clearTimeout(overlayRepinTimerRef.current);
789 }
790 },
791 [],
792 );
793 useEffect(() => {
794 const previous = previousWorkingControlStateRef.current;
795 const threadChanged = previous.threadKey !== selectedThreadKey;
796 const visibilityChanged = previous.visible !== showFloatingStatus;
797 previousWorkingControlStateRef.current = {
798 threadKey: selectedThreadKey,
799 visible: showFloatingStatus,
800 };
801 if ((!threadChanged && !visibilityChanged) || (threadChanged && !showFloatingStatus)) {
802 return;
803 }
804 // LegendList applies the larger inset but does not re-anchor short
805 // followed conversations when this floating coverage changes after the
806 // initial load. Re-pin after the finite inset transition; the callback
807 // checks follow state again so a user who scrolled up stays put.
808 scheduleOverlayRepin(230);
809 }, [scheduleOverlayRepin, selectedThreadKey, showFloatingStatus]);
810 const handleToggleUserInputCollapsed = useCallback(() => {
811 if (activeUserInputRequestId === null) {
812 return;
813 }
814 if (userInputCollapsed) {
815 // Expanding: card and feed glide start NOW, on the UI thread.
816 userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
817 userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
818 setCollapsedUserInputRequestId(null);
819 scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);
820 } else {
821 // Collapsing hides the custom-answer inputs; release the keyboard with
822 // them instead of leaving it up over a dead responder.
823 Keyboard.dismiss();
824 userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING);
825 // Instant: the sinking card still covers the strip being revealed, and
826 // animating the inset downward is what drifted the short-content end
827 // anchor.
828 userInputInsetProgress.value = 0;
829 setCollapsedUserInputRequestId(activeUserInputRequestId);
830 scheduleOverlayRepin(60);
831 }
832 }, [
833 activeUserInputRequestId,
834 scheduleOverlayRepin,
835 userInputCardProgress,
836 userInputCollapsed,
837 userInputInsetProgress,
838 ]);
839 useEffect(() => {
840 // A new request always arrives expanded.
841 userInputCardProgress.value = 1;
842 userInputInsetProgress.value = 1;
843 }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]);
844 const showContent = props.showContent ?? true;
845 const layoutVariant = props.layoutVariant ?? "compact";
846 const isSplitLayout = layoutVariant === "split";
847 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
848 const providerSubagentProvider = props.serverConfig?.providers.find(
849 (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,
850 );
851 // Providers can report a dated id or alias (claude-haiku-4-5-20251001).
852 const providerSubagentModelSlug = providerSubagentProvider
853 ? resolveSelectableModel(
854 providerSubagentProvider.driver,
855 props.selectedThread.modelSelection.model,
856 providerSubagentProvider.models,
857 )
858 : null;
859 const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
860 (model) => model.slug === providerSubagentModelSlug,
861 );
862 const workspaceContentWidth = useWorkspaceContentWidth();
863 // Clearing animated width can retain the unfolded width after Android resumes folded.
864 // Assign both layouts explicitly so the dock always follows its current parent.
865 const composerWidthStyle = useAnimatedStyle(() =>
866 isSplitLayout && workspaceContentWidth !== null
867 ? {
868 width: Math.max(
869 0,
870 workspaceContentWidth.value - controlInsets.left - controlInsets.right,
871 ),
872 right: undefined,
873 }
874 : { width: undefined, right: controlInsets.right },
875 );
876 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
877 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
878 const selectedProviderSkills = useMemo(() => {
879 const provider = props.serverConfig?.providers.find(
880 (candidate) => candidate.instanceId === selectedInstanceId,
881 );
882 return provider
883 ? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot)
884 : [];
885 }, [props.projectWorkspaceRoot, props.serverConfig, props.threadCwd, selectedInstanceId]);
886
887 useLayoutEffect(() => {
888 selectedThreadKeyRef.current = selectedThreadKey;
889 // A replaced or unmounted native editor may not emit a blur event.
890 setComposerFocused(false);
891 }, [selectedThreadKey, showContent]);
892
893 const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
894 const lastDispatchedVisitRef = useRef<string | null>(null);
895 const lastVisitDispatchRef = useRef({ threadKey: selectedThreadKey, at: 0 });
896 const selectedThreadId = props.selectedThread.id;
897 const selectedThreadUpdatedAt = props.selectedThread.updatedAt;
898 const selectedThreadLastVisitedAt = props.selectedThread.lastVisitedAt;
899 const selectedThreadCompletedAt = props.selectedThread.latestRun?.completedAt;
900 useEffect(() => {
901 // Records the server-side visited watermark while the thread is on
902 // screen (mirror of web ChatView), so the "Done" marker clears on every
903 // device. Field absent → the server predates visited tracking.
904 if (!showContent || selectedThreadLastVisitedAt === undefined) return;
905 const threadUpdatedAtMs = Date.parse(selectedThreadUpdatedAt);
906 if (Number.isNaN(threadUpdatedAtMs)) return;
907 const lastVisitedAtMs = selectedThreadLastVisitedAt
908 ? Date.parse(selectedThreadLastVisitedAt)
909 : NaN;
910 if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= threadUpdatedAtMs) return;
911 // Dedupe per watermark — the effect re-runs before the command echo lands.
912 const dispatchKey = `${selectedThreadKey}:${selectedThreadUpdatedAt}`;
913 if (lastDispatchedVisitRef.current === dispatchKey) return;
914 const dispatch = () => {
915 lastDispatchedVisitRef.current = dispatchKey;
916 lastVisitDispatchRef.current = { threadKey: selectedThreadKey, at: Date.now() };
917 void visitThread({
918 environmentId: props.environmentId,
919 input: { threadId: selectedThreadId, visitedAt: selectedThreadUpdatedAt },
920 });
921 };
922 // Completion clears unread state immediately; streaming watermarks use the
923 // same ten-second trailing throttle as web, keeping the newest update.
924 const completedAtMs = selectedThreadCompletedAt ? Date.parse(selectedThreadCompletedAt) : NaN;
925 const hasUnseenCompletion =
926 !Number.isNaN(completedAtMs) &&
927 (Number.isNaN(lastVisitedAtMs) || completedAtMs > lastVisitedAtMs);
928 const previous = lastVisitDispatchRef.current;
929 const elapsed = Date.now() - previous.at;
930 if (previous.threadKey !== selectedThreadKey || hasUnseenCompletion || elapsed >= 10_000) {
931 dispatch();
932 return;
933 }
934 const timer = setTimeout(dispatch, 10_000 - elapsed);
935 return () => clearTimeout(timer);
936 }, [
937 props.environmentId,
938 selectedThreadId,
939 selectedThreadKey,
940 selectedThreadLastVisitedAt,
941 selectedThreadCompletedAt,
942 selectedThreadUpdatedAt,
943 showContent,
944 visitThread,
945 ]);
946
947 useEffect(() => {
948 setAnchorMessageId(null);
949 setSubmittedMessageId(null);
950 lastScrolledSubmittedMessageIdRef.current = null;
951 setEndFollowEnabled(true);
952 freeze.set(false);
953 }, [freeze, selectedThreadKey]);
954
955 useEffect(() => {
956 if (
957 submittedMessageId === null ||
958 anchorMessageId !== submittedMessageId ||
959 lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||
960 contentPresentationKind !== "ready" ||
961 (!selectedThreadFeed.some(
962 (entry) => entry.type === "message" && entry.id === submittedMessageId,
963 ) &&
964 !props.queuedMessages.some((message) => message.messageId === submittedMessageId))
965 ) {
966 return;
967 }
968
969 const targetThreadKey = selectedThreadKey;
970 const frame = requestAnimationFrame(() => {
971 if (selectedThreadKeyRef.current !== targetThreadKey) {
972 return;
973 }
974 lastScrolledSubmittedMessageIdRef.current = submittedMessageId;
975 // Wait for the keyboard dismissal (started by blur() on send) to finish
976 // before scrolling: scrollMessageToEnd freezes keyboard-driven inset
977 // updates while it runs, and a close event swallowed by that freeze
978 // leaves the keyboard padding permanently applied — overshooting the
979 // anchor and leaving a phantom bottom inset once the reply streams in.
980 void KeyboardController.dismiss()
981 .then(() => {
982 if (
983 selectedThreadKeyRef.current !== targetThreadKey ||
984 lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
985 ) {
986 return;
987 }
988 return scrollMessageToEnd({ animated: true, closeKeyboard: false });
989 })
990 .catch(() => {
991 if (
992 selectedThreadKeyRef.current !== targetThreadKey ||
993 lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
994 ) {
995 return;
996 }
997 lastScrolledSubmittedMessageIdRef.current = null;
998 freeze.set(false);
999 });
1000 });
1001 return () => cancelAnimationFrame(frame);
1002 }, [
1003 anchorMessageId,
1004 submittedMessageId,
1005 freeze,
1006 contentPresentationKind,
1007 props.queuedMessages,
1008 selectedThreadFeed,
1009 scrollMessageToEnd,
1010 selectedThreadKey,
1011 ]);
1012
1013 const handleSendMessage = useCallback(
1014 async (followUp?: ActiveTurnComposerAction) => {
1015 const targetThreadKey = selectedThreadKey;
1016 const hasUserMessage = selectedThreadFeed.some(
1017 (entry) => entry.type === "message" && entry.message.role === "user",
1018 );
1019 const messageId = await props.onSendMessage(followUp);
1020 if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
1021 return messageId;
1022 }
1023
1024 // A sent message makes the snapshot stale; a refused send leaves it in place.
1025 clearUsageLimitsFor(targetThreadKey);
1026
1027 setSubmittedMessageId(messageId);
1028 setAnchorMessageId(
1029 resolveThreadFeedSubmissionAnchor({
1030 currentAnchorMessageId: anchorMessageId,
1031 submittedMessageId: messageId,
1032 hasStartedTurn: props.selectedThread.latestRun !== null,
1033 hasUserMessage,
1034 queuedMessageCount: props.selectedThreadQueueCount,
1035 }),
1036 );
1037 composerEditorRef.current?.blur();
1038 return messageId;
1039 },
1040 [
1041 anchorMessageId,
1042 clearUsageLimitsFor,
1043 props.onSendMessage,
1044 props.selectedThread.latestRun,
1045 props.selectedThreadQueueCount,
1046 selectedThreadFeed,
1047 selectedThreadKey,
1048 ],
1049 );
1050
1051 const handleEditPendingMessage = useCallback(async (message: QueuedThreadMessage) => {
1052 try {
1053 if (
1054 (await editPendingThreadMessage(message)) &&
1055 selectedThreadKeyRef.current === scopedThreadKey(message.environmentId, message.threadId)
1056 ) {
1057 composerEditorRef.current?.focus();
1058 }
1059 } catch (error) {
1060 Alert.alert(
1061 "Could not edit message",
1062 error instanceof Error ? error.message : "Please try again.",
1063 );
1064 }
1065 }, []);
1066
1067 const collapseComposer = useCallback(() => {
1068 composerEditorRef.current?.blur();
1069 }, []);
1070
1071 const handleUseArtifactTemplate = useCallback(
1072 (template: CodexArtifactTemplate) => {
1073 const currentDraft = draftMessageRef.current;
1074 const nextDraft = appendCodexArtifactTemplateUsePrompt(currentDraft, template);
1075 if (nextDraft !== currentDraft) {
1076 draftMessageRef.current = nextDraft;
1077 props.onChangeDraftMessage(nextDraft);
1078 }
1079 requestAnimationFrame(() => {
1080 composerEditorRef.current?.focus();
1081 composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length });
1082 });
1083 },
1084 [props.onChangeDraftMessage],
1085 );
1086
1087 const handleScrollToEnd = useCallback(() => {
1088 void Haptics.selectionAsync();
1089 void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
1090 freeze.set(false);
1091 });
1092 }, [freeze, scrollMessageToEnd]);
1093
1094 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
1095 const { themeAppearance } = useAppearancePreferences();
1096 const isDarkMode = themeAppearance === "dark";
1097
1098 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
1099 feedTouchStartRef.current = {
1100 pageX: event.nativeEvent.pageX,
1101 pageY: event.nativeEvent.pageY,
1102 };
1103 }, []);
1104
1105 const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => {
1106 const start = feedTouchStartRef.current;
1107 if (!start) {
1108 return;
1109 }
1110 const deltaX = event.nativeEvent.pageX - start.pageX;
1111 const deltaY = event.nativeEvent.pageY - start.pageY;
1112 if (Math.hypot(deltaX, deltaY) > 8) {
1113 feedTouchStartRef.current = null;
1114 }
1115 }, []);
1116
1117 const handleFeedTouchEnd = useCallback(() => {
1118 if (feedTouchStartRef.current) {
1119 collapseComposer();
1120 }
1121 feedTouchStartRef.current = null;
1122 }, [collapseComposer]);
1123
1124 const handleFeedTouchCancel = useCallback(() => {
1125 feedTouchStartRef.current = null;
1126 }, []);
1127
1128 return (
1129 <View className="flex-1">
1130 {!usesNativeWorkspaceColumns ? <NativeLayoutObserver onChange={setScreenMetrics} /> : null}
1131 {showContent ? (
1132 <View
1133 style={{ flex: 1 }}
1134 onTouchStart={handleFeedTouchStart}
1135 onTouchMove={handleFeedTouchMove}
1136 onTouchEnd={handleFeedTouchEnd}
1137 onTouchCancel={handleFeedTouchCancel}
1138 >
1139 <View
1140 pointerEvents="none"
1141 className={
1142 Platform.OS === "android"
1143 ? "absolute inset-0 bg-thread-canvas"
1144 : "absolute inset-0 bg-screen"
1145 }
1146 />
1147 <RenderErrorBoundary
1148 key={selectedThreadKey}
1149 resetKeys={[props.threadCwd]}
1150 renderFallback={(fallback) => (
1151 <RenderFailureView
1152 {...fallback}
1153 title="The conversation couldn't be displayed"
1154 bottomInset={estimatedOverlayHeight}
1155 />
1156 )}
1157 >
1158 <ThreadFeed
1159 environmentId={props.environmentId}
1160 threadId={props.selectedThread.id}
1161 workspaceRoot={props.threadCwd}
1162 feed={props.selectedThreadFeed}
1163 pendingHandoff={props.pendingHandoff}
1164 worktreeSetup={props.worktreeSetup}
1165 setupWorkingStartedAt={props.setupWorkingStartedAt}
1166 queuedMessages={props.queuedMessages}
1167 dispatchingMessageId={props.dispatchingMessageId}
1168 // A native subagent has no composer to edit a pending message in;
1169 // Cancel on the edit banner would discard it.
1170 onEditPendingMessage={isProviderSubagent ? null : handleEditPendingMessage}
1171 contentPresentation={props.contentPresentation}
1172 agentLabel={agentLabel}
1173 threadTitle={props.selectedThread.title}
1174 latestRun={props.activityRun}
1175 activeWorkStartedAt={props.activeWorkStartedAt}
1176 runlessWorkActive={props.runlessWorkActive ?? false}
1177 listRef={listRef}
1178 freeze={freeze}
1179 anchorMessageId={anchorMessageId}
1180 submittedMessageId={submittedMessageId}
1181 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
1182 contentTopInset={0}
1183 contentBottomInset={
1184 estimatedOverlayHeight +
1185 (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
1186 }
1187 contentMaxWidth={contentMaxWidth}
1188 historyControls={props.historyControls}
1189 contentSideInsets={nativeMetrics?.safeArea}
1190 layoutVariant={layoutVariant}
1191 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
1192 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
1193 onEndFollowEnabledChange={setEndFollowEnabled}
1194 skills={selectedProviderSkills}
1195 onUseArtifactTemplate={handleUseArtifactTemplate}
1196 />
1197 </RenderErrorBoundary>
1198 </View>
1199 ) : (
1200 <View className="flex-1" />
1201 )}
1202
1203 {showContent ? (
1204 <ThreadBrowserFloat
1205 key={selectedThreadKey}
1206 environmentId={props.environmentId}
1207 threadId={props.selectedThread.id}
1208 tabs={browserTabs.tabs}
1209 loaded={browserTabs.loaded}
1210 top={navigationHeaderHeight + 8}
1211 onOpen={openBrowserPreview}
1212 />
1213 ) : null}
1214
1215 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
1216 {showContent ? (
1217 <KeyboardStickyView
1218 // iOS emits a native animated height target on both will-show and
1219 // will-hide, so stay subscribed for the full transition. Android
1220 // retains its background/resume stale-state quarantine.
1221 enabled={Platform.OS === "ios" || (isKeyboardVisible && !keyboardStateSuspect)}
1222 pointerEvents="box-none"
1223 style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }}
1224 offset={{ closed: 0, opened: 0 }}
1225 >
1226 <ComposerPopoverHost hidden={composerSlotHidden}>
1227 {/* The fixed sticky host gives this bottom-anchored child a stable
1228 coordinate space. Its top and height can then animate together
1229 instead of the auto-sized host jumping to Yoga's destination. */}
1230 <Animated.View
1231 layout={COMPOSER_LAYOUT_TRANSITION}
1232 pointerEvents="box-none"
1233 style={[
1234 {
1235 position: "absolute",
1236 bottom: controlInsets.bottom,
1237 left: controlInsets.left,
1238 right: controlInsets.right,
1239 },
1240 composerWidthStyle,
1241 ]}
1242 >
1243 {/* No paddingTop here: the overlay's measured height becomes the
1244 list's bottom inset, so any padding above the pill/composer
1245 pushes the resting content floor up by the same amount. */}
1246 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
1247 <ScriptProgress
1248 environmentId={props.environmentId}
1249 threadId={props.selectedThread.id}
1250 />
1251 <FloatingWorkingControl
1252 colorScheme={isDarkMode ? "dark" : "light"}
1253 status={floatingStatus}
1254 lift={floatingControlLift}
1255 devicePreview={
1256 devicePreviews.length > 0
1257 ? { count: devicePreviews.length, onPress: openDevicePreview }
1258 : null
1259 }
1260 browserPreview={
1261 browserTabs.tabs.length > 0
1262 ? { count: browserTabs.tabs.length, onPress: () => openBrowserPreview() }
1263 : null
1264 }
1265 showScrollToEnd={showScrollToEndButton}
1266 onScrollToEnd={handleScrollToEnd}
1267 agents={agentsSegment}
1268 onOpenAgents={() => {
1269 Keyboard.dismiss();
1270 navigation.navigate("ThreadAgents", {
1271 environmentId: props.environmentId,
1272 threadId: props.selectedThread.id,
1273 });
1274 }}
1275 queuedCount={queuedCount}
1276 onOpenQueue={() => {
1277 Keyboard.dismiss();
1278 navigation.navigate("ThreadQueue", {
1279 environmentId: props.environmentId,
1280 threadId: props.selectedThread.id,
1281 });
1282 }}
1283 />
1284 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
1285 {props.queuedRunEdit !== null ? (
1286 <Animated.View
1287 className="shrink-0"
1288 entering={FadeInDown.duration(180)}
1289 exiting={FadeOut.duration(120)}
1290 >
1291 <ComposerQueuedEditBanner
1292 saving={props.isSavingQueuedEdit}
1293 onCancel={() => {
1294 voiceInputSession.cancel(props.composerDraftKey);
1295 props.onCancelQueuedRunEdit();
1296 }}
1297 />
1298 </Animated.View>
1299 ) : null}
1300 <UsageLimitRecoveryCard
1301 key={props.selectedThread.latestRun?.runId}
1302 thread={props.selectedThread}
1303 environmentId={props.environmentId}
1304 />
1305 {props.feedbackSubmissions.map((submission) => (
1306 <ComposerFeedback
1307 key={submission.id}
1308 submission={submission}
1309 onDismiss={() => props.onDismissFeedback(submission.id)}
1310 />
1311 ))}
1312 {composerError !== null ? (
1313 <Animated.View
1314 className="shrink-0"
1315 entering={FadeInDown.duration(180)}
1316 exiting={FadeOut.duration(120)}
1317 >
1318 <ComposerErrorNotice
1319 message={composerError}
1320 onDismiss={() => clearThreadComposerError(selectedThreadKey)}
1321 />
1322 </Animated.View>
1323 ) : null}
1324 {usageLimitsReport && activeUserInputRequestId === null ? (
1325 <Animated.View
1326 className="shrink-0 px-4 pb-3"
1327 entering={FadeInDown.duration(220)}
1328 exiting={FadeOut.duration(140)}
1329 >
1330 <ComposerUsageLimits
1331 report={usageLimitsReport}
1332 environmentId={props.environmentId}
1333 onClose={dismissUsageLimits}
1334 />
1335 </Animated.View>
1336 ) : null}
1337 {props.creationState?.kind === "failed" ? (
1338 <Animated.View
1339 className="shrink-0 px-4"
1340 style={{ paddingBottom: composerBottomInset }}
1341 entering={FadeInDown.duration(220)}
1342 exiting={FadeOut.duration(140)}
1343 >
1344 <ThreadCreationFailedCard
1345 reason={props.creationState.reason}
1346 onEditTask={props.creationState.onEditTask}
1347 />
1348 </Animated.View>
1349 ) : null}
1350 {props.activePendingApproval || props.activePendingUserInput ? (
1351 <Animated.View
1352 className="shrink-0 gap-3 px-4 pb-3"
1353 // The questionnaire replaces the composer, so it must pad
1354 // the home indicator the composer normally covers.
1355 style={
1356 activeUserInputRequestId !== null
1357 ? { paddingBottom: composerBottomInset }
1358 : undefined
1359 }
1360 entering={FadeInDown.duration(220)}
1361 exiting={FadeOut.duration(140)}
1362 >
1363 {props.activePendingApproval ? (
1364 <PendingApprovalCard
1365 canOperateThread={props.canOperateThread}
1366 approval={props.activePendingApproval}
1367 respondingApprovalId={props.respondingApprovalId}
1368 onRespond={props.onRespondToApproval}
1369 />
1370 ) : null}
1371 {props.activePendingUserInput ? (
1372 <PendingUserInputCard
1373 canOperateThread={props.canOperateThread}
1374 pendingUserInput={props.activePendingUserInput}
1375 maxHeight={pendingUserInputMaxHeight}
1376 collapsed={userInputCollapsed}
1377 onToggleCollapsed={handleToggleUserInputCollapsed}
1378 onStopThread={props.onStopThread}
1379 cardProgress={userInputCardProgress}
1380 cardCoverage={userInputCardCoverage}
1381 onInputFocusChange={handleOwnedInputFocusChange}
1382 drafts={props.activePendingUserInputDrafts}
1383 answers={props.activePendingUserInputAnswers}
1384 respondingUserInputId={props.respondingUserInputId}
1385 onSelectOption={props.onSelectUserInputOption}
1386 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
1387 onSubmit={props.onSubmitUserInput}
1388 onDismiss={props.onDismissUserInput}
1389 />
1390 ) : null}
1391 </Animated.View>
1392 ) : null}
1393 </View>
1394
1395 {/* Hidden (not unmounted) while a user-input request owns the
1396 composer slot, so composer drafts and editor state survive.
1397 A rejected creation has no thread to send to; the failure card
1398 owns the slot instead. */}
1399 <View style={composerSlotHidden ? { display: "none" } : undefined}>
1400 {isProviderSubagent ? (
1401 <View
1402 className="self-center px-3 pt-1.5"
1403 style={{
1404 width: "100%",
1405 maxWidth: contentMaxWidth,
1406 paddingBottom: composerBottomInset + 6,
1407 }}
1408 >
1409 <ProviderSubagentBar
1410 provider={providerSubagentProvider ?? null}
1411 modelLabel={
1412 providerSubagentCatalogModel?.name ??
1413 formatModelSlugName(props.selectedThread.modelSelection.model)
1414 }
1415 effortLabel={formatModelSelectionEffort(
1416 props.selectedThread.modelSelection,
1417 providerSubagentProvider?.models,
1418 reportedModelSelection,
1419 )}
1420 status={props.providerSubagentStatus ?? null}
1421 onOpenParent={
1422 props.selectedThread.lineage.parentThreadId === null
1423 ? null
1424 : () =>
1425 navigation.navigate("Thread", {
1426 environmentId: String(props.environmentId),
1427 threadId: String(props.selectedThread.lineage.parentThreadId),
1428 })
1429 }
1430 />
1431 </View>
1432 ) : (
1433 <>
1434 <ThreadComposer
1435 canOperateThread={props.canOperateThread}
1436 reportedModelSelection={reportedModelSelection}
1437 editorRef={composerEditorRef}
1438 draftMessage={props.draftMessage}
1439 draftAttachments={props.draftAttachments}
1440 placeholder="Ask the repo agent, or run a command…"
1441 contentMaxWidth={contentMaxWidth}
1442 connectionState={props.connectionStateLabel}
1443 environmentLabel={props.environmentLabel}
1444 selectedThread={props.selectedThread}
1445 hasCompactableConversation={
1446 hasCompactableConversation && !props.isCompacting
1447 }
1448 serverConfig={props.serverConfig}
1449 queueCount={props.selectedThreadQueueCount}
1450 activeThreadBusy={props.activeThreadBusy}
1451 canStopThread={props.canStopThread}
1452 environmentId={props.environmentId}
1453 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1454 // Follow-ups typed before the server creates the thread wait in
1455 // the draft: queueing them against a thread id the server may
1456 // still reject would strand them in the outbox.
1457 sendBlockedReason={
1458 props.creationState?.kind === "preparing" &&
1459 !props.creationState.serverOwned
1460 ? "Starting the task…"
1461 : null
1462 }
1463 draftKey={props.composerDraftKey ?? undefined}
1464 followUpBehavior={props.followUpBehavior}
1465 canSteerActiveTurn={props.canSteerActiveTurn}
1466 queuedEdit={
1467 props.queuedRunEdit === null
1468 ? null
1469 : {
1470 existingAttachments: props.queuedRunEdit.existingAttachments,
1471 saving: props.isSavingQueuedEdit,
1472 onRemoveExistingAttachment: props.onRemoveQueuedEditAttachment,
1473 }
1474 }
1475 bottomInset={composerBottomInset}
1476 onChangeDraftMessage={props.onChangeDraftMessage}
1477 onPickDraftMedia={props.onPickDraftMedia}
1478 onPickDraftFiles={props.onPickDraftFiles}
1479 onNativePasteImages={props.onNativePasteImages}
1480 onNativePasteText={props.onNativePasteText}
1481 onRemoveDraftImage={props.onRemoveDraftImage}
1482 onStopThread={props.onStopThread}
1483 onSendMessage={handleSendMessage}
1484 onShowUsageLimits={showUsageLimits}
1485 canSwitchProvider={props.canSwitchThreadProvider}
1486 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1487 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1488 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1489 onExpandedChange={setComposerExpanded}
1490 onEditorFocusChange={handleComposerFocusChange}
1491 />
1492 </>
1493 )}
1494 </View>
1495 </View>
1496 </Animated.View>
1497 </ComposerPopoverHost>
1498 </KeyboardStickyView>
1499 ) : null}
1500 </View>
1501 );
1502});