import type { EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig,} from "@t3tools/contracts";import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";import type { ReactNode } from "react";import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native";import ImageViewing from "react-native-image-viewing";import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown, LinearTransition,} from "react-native-reanimated";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText";import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";import { GlassSurface } from "../../components/GlassSurface";import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";import { ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller,} from "../../components/ComposerToolbar";import { ControlPill } from "../../components/ControlPill";import { ProviderIcon } from "../../components/ProviderIcon";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";import type { RemoteClientConnectionState } from "../../lib/connection";import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";import { ComposerCommandPopover } from "./ComposerCommandPopover";import { useComposerCommandMenu } from "./use-composer-command-menu";import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation,} from "./ThreadSettingsSheet";import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning,} from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). * Exported so the parent can compute feed overlap / content insets. */export const COMPOSER_COLLAPSED_CHROME = 60; /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. */export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; readonly placeholder: string; readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; /** * Message sync phase for the selected thread (drives the status pill): * "loading" = first fetch, nothing to show yet; "syncing" = cached messages * are on screen while they reconcile with the server. */ readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject<ComposerEditorHandle | null>; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise<void>; readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise<MessageId | null>; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void;} /** * The pill / card container — renders with Expo's native GlassView on supported * iOS 26+ devices and keeps the existing opaque fallback elsewhere. * Exported so NewTaskDraftScreen can render the same composer chrome. */// One timing for every piece of the expanded↔compact morph so the surface,// toolbar, and siblings move together instead of popping between layouts.// Android gets NO layout transition: the composer rides the keyboard via// KeyboardStickyView (frame-synced to the IME), and a time-based morph// running alongside that translate reads as jitter. Snapping the layout and// letting the keyboard-synced slide be the only motion looks native there.const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : LinearTransition.duration(220); export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; /** Existing thread composers morph between pill and card layouts. */ readonly animateLayout?: boolean;}) { // A box shadow follows the rounded surface even when the glass is transparent. // Keep it outside the clipped content so the shadow can extend past the edge. return ( <Animated.View className="shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION} style={{ borderRadius: props.style.borderRadius, // Android versions before 9 do not support outset box shadows. elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, }} > <GlassSurface chrome="none" fallbackClassName="border border-border bg-card-translucent" glassEffectStyle="regular" // The composer is a passive material containing interactive controls. // Expo GlassView defaults to non-interactive and both layouts share it. tintColor="transparent" style={props.style} > {props.children} </GlassSurface> </Animated.View> );} type ComposerStatusPillState = { readonly kind: "unavailable" | "reconnecting" | "syncing"; readonly label: string;}; function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; readonly threadSyncPhase?: "loading" | "syncing" | null;}): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; switch (input.connectionState) { case "connecting": case "reconnecting": return { kind: "reconnecting", label: input.connectionError === null ? `Reconnecting to ${environmentLabel}...` : `Failed to connect. Retrying ${environmentLabel}...`, }; case "offline": return { kind: "unavailable", label: "You are offline" }; case "error": return { kind: "unavailable", label: input.connectionError ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` : `Failed to connect to ${environmentLabel}`, }; case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": break; } // Connected: the pill is the single loading/sync indicator. One stable // label per open — "Loading" when starting from scratch, "Syncing" when // cached messages are already visible. switch (input.threadSyncPhase) { case "loading": return { kind: "syncing", label: "Loading messages..." }; case "syncing": return { kind: "syncing", label: "Syncing messages..." }; default: return null; }} const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { readonly onPress: () => void; readonly status: ComposerStatusPillState;}) { const isReconnecting = props.status.kind !== "unavailable"; return ( <Animated.View className="absolute inset-x-0 bottom-full items-center pb-2" entering={FadeInDown.duration(180)} exiting={FadeOutDown.duration(140)} pointerEvents="box-none" > <Pressable accessibilityRole="button" onPress={props.onPress} className="max-w-full flex-row items-center gap-2 rounded-full bg-card px-3 py-2 shadow-sm active:opacity-70" > {isReconnecting ? ( <ActivityIndicator size="small" colorClassName={"accent-icon-muted"} /> ) : ( <View className="h-2 w-2 rounded-full bg-red-500" /> )} <Text className="max-w-[260px] text-sm font-t3-bold leading-snug text-foreground" numberOfLines={1} > {props.status.label} </Text> </Pressable> </Animated.View> );}); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef<ComposerEditorHandle>(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, }); const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set<string>()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; // Opening and presentation count as active so the composer stays expanded // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; // Notify the parent from the derived value, not focus events: the parent // sizes the feed inset from this, and blur-during-sheet would otherwise // report collapsed while the composer still renders expanded. useEffect(() => { onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewImageUri(uri); }, [isFocused], ); const closePreview = useCallback(() => { setPreviewImageUri(null); if (wasExpandedBeforePreviewRef.current) { setTimeout(() => inputRef.current?.focus(), 100); } }, [inputRef]); const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); onEditorFocusChange?.(true); }, [onEditorFocusChange]); const handleBlur = useCallback(() => { setIsFocused(false); onEditorFocusChange?.(false); }, [onEditorFocusChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; const sendLabel = props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( props.serverConfig.providers.find( (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, environmentId: props.environmentId, projectCwd: props.projectCwd, selectedProviderStatus, hasThread: true, onChangeDraftMessage: props.onChangeDraftMessage, onUpdateInteractionMode: props.onUpdateInteractionMode, }); const { onSendMessage } = props; const handleSend = useCallback(async () => { const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { const messageId = await onSendMessage(); if (messageId === null) { return; } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't // contend with the queued-message feedback on the tap frame. armAgentAwarenessLiveActivityForLocalWork({ environmentId: props.environmentId, threadTitle: props.selectedThread.title, projectTitle: props.environmentLabel ?? "T3 Code", }); } finally { inFlightThreadIdsRef.current.delete(threadKey); } }, [ onSendMessage, props.environmentId, props.environmentLabel, props.selectedThread.id, props.selectedThread.title, ]); // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( () => buildModelOptions(props.serverConfig, currentModelSelection), [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); // An existing thread is bound to its harness: sessions can't move between // provider instances, so the picker only offers the thread's own group. const threadProviderGroups = useMemo( () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), [providerGroups, currentModelSelection.instanceId], ); const currentModelOption = modelOptions.find( (option) => option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ capabilities: currentModelOption?.capabilities, selections: currentModelSelection.options, }), [currentModelOption?.capabilities, currentModelSelection.options], ); const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>( () => ({ ownerId: settingsOwnerId, environmentId: props.environmentId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(option.selection), optionDescriptors: providerOptionDescriptors, onUpdateOptionSelections: (options) => props.onUpdateModelSelection({ ...currentModelSelection, options }), runtimeMode: currentRuntimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, }), [ currentModelSelection, currentRuntimeMode, props.onUpdateModelSelection, props.onUpdateRuntimeMode, providerOptionDescriptors, settingsOwnerId, threadProviderGroups, ], ); const openSettings = useCallback(() => { settingsRoutePresentation.present(settingsRouteSession); settingsSheetPresentation.open(); }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]); useEffect(() => { if (settingsSheetPresentation.isActive) { settingsRoutePresentation.present(settingsRouteSession); } }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = true; navigation.dispatch(StackActions.push("ThreadSettingsSheet")); }, [navigation, settingsSheetPresentation.isVisible]); useFocusEffect( useCallback(() => { if (!settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = false; settingsSheetPresentation.onDismissed(); settingsRoutePresentation.clear(settingsOwnerId); }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]), ); useEffect( () => // UIKit's completion callback for the sheet dismissal, surfaced by the // native-stack patch. This is when the queued keyboard restore runs. (navigation as unknown as NavigationWithFinishTransitioning).addListener( "finishTransitioning", settingsSheetPresentation.onStackTransitionsFinished, ), [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); return ( <Animated.View className="px-4" layout={COMPOSER_LAYOUT_TRANSITION} style={{ paddingTop: isExpanded ? 8 : 6, paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), }} > {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View silently drops experimental_backgroundImage on Android, which left this strip fully transparent and the feed text legible through the composer. */} <View className="absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90" pointerEvents="none" /> <Animated.View className="relative w-full self-center" layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > {composerMenu.trigger && composerMenu.items.length > 0 ? ( <View className="absolute inset-x-0 bottom-full z-10 mb-2"> <ComposerCommandPopover items={composerMenu.items} triggerKind={composerMenu.trigger.kind} isLoading={composerMenu.isLoading} onSelect={composerMenu.onSelect} /> </View> ) : null} {connectionStatus ? ( <ComposerConnectionStatusPill status={connectionStatus} onPress={props.onReconnectEnvironment} /> ) : null} <ComposerSurface style={ isExpanded ? { borderRadius: 26, minHeight: 140, overflow: "hidden" as const, paddingBottom: 6, paddingHorizontal: 14, paddingTop: 14, } : { borderRadius: 999, overflow: "hidden" as const, flexDirection: "row" as const, alignItems: "center" as const, paddingLeft: 18,Lines 1–500 of 654. Load more
import type { EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native"; import ImageViewing from "react-native-image-viewing"; import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown, LinearTransition, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, } from "./ThreadSettingsSheet"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, } from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). * Exported so the parent can compute feed overlap / content insets. */ export const COMPOSER_COLLAPSED_CHROME = 60; /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. */ export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; readonly placeholder: string; readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; /** * Message sync phase for the selected thread (drives the status pill): * "loading" = first fetch, nothing to show yet; "syncing" = cached messages * are on screen while they reconcile with the server. */ readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject<ComposerEditorHandle | null>; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise<void>; readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise<MessageId | null>; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void; } /** * The pill / card container — renders with Expo's native GlassView on supported * iOS 26+ devices and keeps the existing opaque fallback elsewhere. * Exported so NewTaskDraftScreen can render the same composer chrome. */ // One timing for every piece of the expanded↔compact morph so the surface, // toolbar, and siblings move together instead of popping between layouts. // Android gets NO layout transition: the composer rides the keyboard via // KeyboardStickyView (frame-synced to the IME), and a time-based morph // running alongside that translate reads as jitter. Snapping the layout and // letting the keyboard-synced slide be the only motion looks native there. const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : LinearTransition.duration(220); export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; /** Existing thread composers morph between pill and card layouts. */ readonly animateLayout?: boolean; }) { // A box shadow follows the rounded surface even when the glass is transparent. // Keep it outside the clipped content so the shadow can extend past the edge. return ( <Animated.View className="shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION} style={{ borderRadius: props.style.borderRadius, // Android versions before 9 do not support outset box shadows. elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, }} > <GlassSurface chrome="none" fallbackClassName="border border-border bg-card-translucent" glassEffectStyle="regular" // The composer is a passive material containing interactive controls. // Expo GlassView defaults to non-interactive and both layouts share it. tintColor="transparent" style={props.style} > {props.children} </GlassSurface> </Animated.View> ); } type ComposerStatusPillState = { readonly kind: "unavailable" | "reconnecting" | "syncing"; readonly label: string; }; function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; readonly threadSyncPhase?: "loading" | "syncing" | null; }): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; switch (input.connectionState) { case "connecting": case "reconnecting": return { kind: "reconnecting", label: input.connectionError === null ? `Reconnecting to ${environmentLabel}...` : `Failed to connect. Retrying ${environmentLabel}...`, }; case "offline": return { kind: "unavailable", label: "You are offline" }; case "error": return { kind: "unavailable", label: input.connectionError ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` : `Failed to connect to ${environmentLabel}`, }; case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": break; } // Connected: the pill is the single loading/sync indicator. One stable // label per open — "Loading" when starting from scratch, "Syncing" when // cached messages are already visible. switch (input.threadSyncPhase) { case "loading": return { kind: "syncing", label: "Loading messages..." }; case "syncing": return { kind: "syncing", label: "Syncing messages..." }; default: return null; } } const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { readonly onPress: () => void; readonly status: ComposerStatusPillState; }) { const isReconnecting = props.status.kind !== "unavailable"; return ( <Animated.View className="absolute inset-x-0 bottom-full items-center pb-2" entering={FadeInDown.duration(180)} exiting={FadeOutDown.duration(140)} pointerEvents="box-none" > <Pressable accessibilityRole="button" onPress={props.onPress} className="max-w-full flex-row items-center gap-2 rounded-full bg-card px-3 py-2 shadow-sm active:opacity-70" > {isReconnecting ? ( <ActivityIndicator size="small" colorClassName={"accent-icon-muted"} /> ) : ( <View className="h-2 w-2 rounded-full bg-red-500" /> )} <Text className="max-w-[260px] text-sm font-t3-bold leading-snug text-foreground" numberOfLines={1} > {props.status.label} </Text> </Pressable> </Animated.View> ); }); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef<ComposerEditorHandle>(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, }); const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set<string>()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; // Opening and presentation count as active so the composer stays expanded // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; // Notify the parent from the derived value, not focus events: the parent // sizes the feed inset from this, and blur-during-sheet would otherwise // report collapsed while the composer still renders expanded. useEffect(() => { onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewImageUri(uri); }, [isFocused], ); const closePreview = useCallback(() => { setPreviewImageUri(null); if (wasExpandedBeforePreviewRef.current) { setTimeout(() => inputRef.current?.focus(), 100); } }, [inputRef]); const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); onEditorFocusChange?.(true); }, [onEditorFocusChange]); const handleBlur = useCallback(() => { setIsFocused(false); onEditorFocusChange?.(false); }, [onEditorFocusChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; const sendLabel = props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( props.serverConfig.providers.find( (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, environmentId: props.environmentId, projectCwd: props.projectCwd, selectedProviderStatus, hasThread: true, onChangeDraftMessage: props.onChangeDraftMessage, onUpdateInteractionMode: props.onUpdateInteractionMode, }); const { onSendMessage } = props; const handleSend = useCallback(async () => { const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { const messageId = await onSendMessage(); if (messageId === null) { return; } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't // contend with the queued-message feedback on the tap frame. armAgentAwarenessLiveActivityForLocalWork({ environmentId: props.environmentId, threadTitle: props.selectedThread.title, projectTitle: props.environmentLabel ?? "T3 Code", }); } finally { inFlightThreadIdsRef.current.delete(threadKey); } }, [ onSendMessage, props.environmentId, props.environmentLabel, props.selectedThread.id, props.selectedThread.title, ]); // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( () => buildModelOptions(props.serverConfig, currentModelSelection), [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); // An existing thread is bound to its harness: sessions can't move between // provider instances, so the picker only offers the thread's own group. const threadProviderGroups = useMemo( () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), [providerGroups, currentModelSelection.instanceId], ); const currentModelOption = modelOptions.find( (option) => option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ capabilities: currentModelOption?.capabilities, selections: currentModelSelection.options, }), [currentModelOption?.capabilities, currentModelSelection.options], ); const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>( () => ({ ownerId: settingsOwnerId, environmentId: props.environmentId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(option.selection), optionDescriptors: providerOptionDescriptors, onUpdateOptionSelections: (options) => props.onUpdateModelSelection({ ...currentModelSelection, options }), runtimeMode: currentRuntimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, }), [ currentModelSelection, currentRuntimeMode, props.onUpdateModelSelection, props.onUpdateRuntimeMode, providerOptionDescriptors, settingsOwnerId, threadProviderGroups, ], ); const openSettings = useCallback(() => { settingsRoutePresentation.present(settingsRouteSession); settingsSheetPresentation.open(); }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]); useEffect(() => { if (settingsSheetPresentation.isActive) { settingsRoutePresentation.present(settingsRouteSession); } }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = true; navigation.dispatch(StackActions.push("ThreadSettingsSheet")); }, [navigation, settingsSheetPresentation.isVisible]); useFocusEffect( useCallback(() => { if (!settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = false; settingsSheetPresentation.onDismissed(); settingsRoutePresentation.clear(settingsOwnerId); }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]), ); useEffect( () => // UIKit's completion callback for the sheet dismissal, surfaced by the // native-stack patch. This is when the queued keyboard restore runs. (navigation as unknown as NavigationWithFinishTransitioning).addListener( "finishTransitioning", settingsSheetPresentation.onStackTransitionsFinished, ), [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); return ( <Animated.View className="px-4" layout={COMPOSER_LAYOUT_TRANSITION} style={{ paddingTop: isExpanded ? 8 : 6, paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), }} > {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View silently drops experimental_backgroundImage on Android, which left this strip fully transparent and the feed text legible through the composer. */} <View className="absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90" pointerEvents="none" /> <Animated.View className="relative w-full self-center" layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > {composerMenu.trigger && composerMenu.items.length > 0 ? ( <View className="absolute inset-x-0 bottom-full z-10 mb-2"> <ComposerCommandPopover items={composerMenu.items} triggerKind={composerMenu.trigger.kind} isLoading={composerMenu.isLoading} onSelect={composerMenu.onSelect} /> </View> ) : null} {connectionStatus ? ( <ComposerConnectionStatusPill status={connectionStatus} onPress={props.onReconnectEnvironment} /> ) : null} <ComposerSurface style={ isExpanded ? { borderRadius: 26, minHeight: 140, overflow: "hidden" as const, paddingBottom: 6, paddingHorizontal: 14, paddingTop: 14, } : { borderRadius: 999, overflow: "hidden" as const, flexDirection: "row" as const, alignItems: "center" as const, paddingLeft: 18,