855 lines · 33.7 KB · TypeScript
RawDownload
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { useAtomValue } from "@effect/atom-react";import type {  EnvironmentId,  MessageId,  ModelSelection,  OrchestrationThreadShell,  ProviderInteractionMode,  RuntimeMode,  ServerConfig as T3ServerConfig,  UsageLimitsReport,} from "@t3tools/contracts";import {  collectProviderUsageLimits,  hasProviderUsageLimits,  isUsageLimitsCommand,} from "@t3tools/shared/usageLimits";import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";import type { ReactNode } from "react";import {  memo,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,  type RefObject,} from "react";import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import {  composerAttachmentUploadBlockReason,  composerAttachmentsStillUploading,  composerAttachmentUploadsAtom,} from "../../state/composer-attachment-uploads";import Animated, {  FadeIn,  FadeOut,  type LayoutAnimationFunction,  ReduceMotion,  useAnimatedStyle,  useSharedValue,  withTiming,} 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 { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";import {  ComposerAttachmentStrip,  ComposerAttachmentThumbnail,} from "../../components/ComposerAttachmentStrip";import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";import { GlassSurface } from "../../components/GlassSurface";import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";import {  ComposerActionButton,  ComposerInlineControl,  ComposerToolbarRow,} from "../../components/ComposerToolbar";import { ProviderIcon } from "../../components/ProviderIcon";import type {  DraftComposerAttachment,  DraftComposerFileAttachment,} from "../../lib/composerImages";import {  buildModelOptions,  groupByProvider,  isModelSelectionUnavailable,} 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 {  ComposerDictationCancelAction,  ComposerDictationDraftContent,  ComposerDictationPrimaryAction,  ComposerDictationStartAction,  ComposerDictationStatus,  ComposerDictationToolbar,} from "../voice-input/ComposerDictationControl";import { useVoiceInputController } from "../voice-input/useVoiceInputController";import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation";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<DraftComposerAttachment>;  readonly placeholder: string;  readonly contentMaxWidth?: number;  readonly bottomInset?: number;  readonly connectionState: RemoteClientConnectionState;  readonly environmentLabel: string | null;  readonly selectedThread: OrchestrationThreadShell;  readonly hasCompactableConversation: boolean;  readonly serverConfig: T3ServerConfig | null;  readonly queueCount: number;  readonly environmentId: EnvironmentId;  readonly projectCwd: string | null;  /** Why sending is blocked right now (shown as the send button's label), or null. */  readonly sendBlockedReason?: string | null;  readonly editorRef?: RefObject<ComposerEditorHandle | null>;  readonly onChangeDraftMessage: (value: string) => void;  readonly onPickDraftMedia: () => Promise<void>;  readonly onPickDraftFiles: () => Promise<void>;  readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;  readonly onRemoveDraftImage: (imageId: string) => void;  readonly onStopThread: () => void;  readonly onSendMessage: () => Promise<MessageId | null>;  /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */  readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;  readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;  readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;  readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => 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, with a frosted blur fallback where supported. * Exported so NewTaskDraftScreen can render the same composer chrome. */// The bottom-anchored dock position and clipped surface height use the same// transition so the card grows upward without exposing its final-size content.// 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.export const COMPOSER_TRANSITION_DURATION_MS = 220;// Side panes already animate the dock's width. Nested horizontal layout// transitions would leave the surface trailing its toolbar's new position.// Keep the vertical pill/card morph while horizontal layout follows the dock.const composerHeightTransition: LayoutAnimationFunction = (values) => {  "worklet";  const timing = {    duration: COMPOSER_TRANSITION_DURATION_MS,    reduceMotion: ReduceMotion.System,  };  return {    initialValues: {      originX: values.targetOriginX,      originY: values.currentOriginY,      width: values.targetWidth,      height: values.currentHeight,    },    animations: {      originX: values.targetOriginX,      originY: withTiming(values.targetOriginY, timing),      width: values.targetWidth,      height: withTiming(values.targetHeight, timing),    },  };};export const COMPOSER_LAYOUT_TRANSITION =  Platform.OS === "android" ? undefined : composerHeightTransition; const COMPOSER_ATTACHMENT_ENTERING =  Platform.OS === "android"    ? FadeIn.duration(160)    : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System); const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); export function ComposerSurface(props: {  readonly children: ReactNode;  readonly style: ViewStyle;  /** Morphs between the compact and expanded composer layouts. */  readonly animateLayout?: boolean;}) {  const { materialYouStyleLayoutActive } = useAppearancePreferences();  const colors = useUniwindTheme();  const targetBorderRadius =    typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;  const animatedBorderRadius = useSharedValue(targetBorderRadius);  const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";  useLayoutEffect(() => {    animatedBorderRadius.value = shouldAnimate      ? withTiming(targetBorderRadius, {          duration: COMPOSER_TRANSITION_DURATION_MS,          reduceMotion: ReduceMotion.System,        })      : targetBorderRadius;  }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]);  const animatedShapeStyle = useAnimatedStyle(() => ({    borderRadius: animatedBorderRadius.value,  }));  const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined;   // Each native frame follows the same transition. Animating only the outer  // clip leaves the glass and content at their final height on the first frame.  return (    <Animated.View      className={        materialYouStyleLayoutActive          ? undefined          : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"      }      layout={layoutTransition}      style={[        animatedShapeStyle,        {          overflow: "hidden",          // Android versions before 9 do not support outset box shadows.          elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined,        },      ]}    >      <AnimatedGlassSurface        chrome="none"        fallbackColor={          materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"]        }        fallbackClassName={          materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border"        }        glassEffectStyle="regular"        // The composer is a passive material containing interactive controls.        // Keep native glass out of the interactive content's layout path.        pointerEvents="none"        tintColor="transparent"        layout={layoutTransition}        style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]}      >        {null}      </AnimatedGlassSurface>      <Animated.View        collapsable={false}        layout={layoutTransition}        style={[props.style, animatedShapeStyle]}      >        {props.children}      </Animated.View>    </Animated.View>  );} export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {  const { materialYouStyleLayoutActive, themeVariables: materialTheme } =    useAppearancePreferences();  const composerPanel = materialTheme["--color-composer-panel"];  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 [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);  const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);  const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;  const showStopAction =    !hasContent &&    (props.selectedThread.session?.status === "running" ||      props.selectedThread.session?.status === "starting");   const uploadStates = useAtomValue(composerAttachmentUploadsAtom);  const attachmentsUploading =    props.connectionState === "connected" &&    composerAttachmentsStillUploading({      environmentId: props.environmentId,      attachments: props.draftAttachments,      serverConfig: props.serverConfig,      states: uploadStates,    });  // Every send goes through the outbox; the label says whether it leaves now  // or waits (for the connection, an earlier queued message, or an upload).  const sendLabel =    props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading      ? "Queue"      : "Send";  const currentModelSelection = props.selectedThread.modelSelection;  const currentRuntimeMode = props.selectedThread.runtimeMode;  const modelUnavailable =    props.connectionState === "connected" &&    isModelSelectionUnavailable(props.serverConfig, currentModelSelection);  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 composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);  const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props;  // T3 owns /usage-limits only where Limits has data for the selected provider;  // elsewhere the name stays the provider's own and is sent through untouched.  const usageLimitsOffered =    selectedProviderStatus !== null &&    hasProviderUsageLimits(      selectedProviderStatus.driver,      props.serverConfig?.providers ?? [],      props.serverConfig?.usageLimitSources ?? [],    );  // Answered locally from the last Limits snapshot; the agent never sees it.  const openUsageLimits = useCallback(() => {    const report = collectProviderUsageLimits(      currentModelSelection.instanceId,      props.serverConfig?.providers ?? [],      props.serverConfig?.usageLimitSources ?? [],      Date.now(),    );    onShowUsageLimits(report);    if (!report) {      Alert.alert("Usage limits unavailable", "This provider does not currently report limits.");    }    return report !== null;  }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]);   const composerMenu = useComposerCommandMenu({    draftMessage: props.draftMessage,    ownerKey: composerOwnerKey,    environmentId: props.environmentId,    projectCwd: props.projectCwd,    selectedProviderStatus,    hasThread: true,    hasCompactableConversation: props.hasCompactableConversation,    onChangeDraftMessage: props.onChangeDraftMessage,    onUpdateInteractionMode:      selectedProviderStatus?.showInteractionModeToggle === false        ? undefined        : props.onUpdateInteractionMode,    offersUsageLimits: usageLimitsOffered,    // With attachments aboard the pick just inserts the text, so it sends as a prompt.    onUsageLimits:      usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined,  });  const voiceInput = useVoiceInputController({    ownerKey: composerOwnerKey,    draftMessage: props.draftMessage,    selection: composerMenu.selection,    onChangeDraftMessage: props.onChangeDraftMessage,    onChangeSelection: composerMenu.onSelectionChange,  });  const voicePresentation = resolveVoiceComposerPresentation(    voiceInput.state,    voiceInput.elapsedSeconds,  );  const isVoiceInputPresented = voicePresentation.statusLabel !== null;  // An open draft stays visible; only a collapsed composer becomes a voice strip.  const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded;  const showsCompactDictation = isVoiceInputPresented && !isExpanded;  const isToolbarVisible = isExpanded || isVoiceInputPresented;  const attachmentBlockReason = composerAttachmentUploadBlockReason({    environmentId: props.environmentId,    attachments: props.draftAttachments,    connected: props.connectionState === "connected",    serverConfig: props.serverConfig,    states: uploadStates,  });  const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason;  const canSend =    hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable;   // Keep the feed inset aligned with the card or compact dictation strip.  useEffect(() => {    onExpandedChange?.(isExpanded);  }, [isExpanded, onExpandedChange]);   const onPressPreview = useCallback(    (source: FilePreviewSource) => {      wasExpandedBeforePreviewRef.current = isFocused;      setPreviewVideo(null);      setPreviewFile((current) => current ?? source);    },    [isFocused],  );   const closePreview = useCallback(() => {    setPreviewFile(null);    setPreviewVideo(null);    if (wasExpandedBeforePreviewRef.current) {      setTimeout(() => {        if (navigation.isFocused()) inputRef.current?.focus();      }, 100);    }  }, [inputRef, navigation]);   const onPressVideo = useCallback(    (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {      wasExpandedBeforePreviewRef.current = isFocused;      setPreviewFile(null);      setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });    },    [isFocused],  );   const onEditorFocusChange = props.onEditorFocusChange;  const handleFocus = useCallback(() => {    setIsFocused(true);    onExpandedChange?.(true);    onEditorFocusChange?.(true);  }, [onEditorFocusChange, onExpandedChange]);   const handleBlur = useCallback(() => {    setIsFocused(false);    if (!settingsSheetPresentation.keepsComposerExpanded) {      onExpandedChange?.(false);    }    onEditorFocusChange?.(false);  }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]);  const handleSend = useCallback(async () => {    // Typed out in full rather than picked from the menu. Attachments mean the    // user is sending a prompt, so those go through as usual.    if (      usageLimitsOffered &&      isUsageLimitsCommand(props.draftMessage) &&      props.draftAttachments.length === 0    ) {      if (openUsageLimits()) onChangeDraftMessage("");      return;    }    if (voiceInput.blocksSubmission) return;    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);    }  }, [    props.draftMessage,    props.draftAttachments.length,    onChangeDraftMessage,    openUsageLimits,    usageLimitsOffered,    onSendMessage,    props.environmentId,    props.environmentLabel,    props.selectedThread.id,    props.selectedThread.title,    voiceInput.blocksSubmission,  ]);   // ── 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;

Lines 1–500 of 855.