853 lines · 29.4 KB · TypeScript
RawDownload
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";import type {  EnvironmentId,  MessageId,  ModelSelection,  OrchestrationThreadShell,  ProviderInteractionMode,  RuntimeMode,  ServerConfig as T3ServerConfig,} from "@t3tools/contracts";import {  detectComposerTrigger,  replaceTextRange,  serializeComposerFileLink,  type ComposerTrigger,} from "@t3tools/shared/composerTrigger";import type { ReactNode } from "react";import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";import {  ActivityIndicator,  Image,  Pressable,  useColorScheme,  View,  type ViewStyle,} from "react-native";import ImageViewing from "react-native-image-viewing";import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText";import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";import {  ComposerEditor,  type ComposerEditorHandle,  type ComposerEditorSelection,} from "../../components/ComposerEditor";import {  ComposerToolbarButton,  ComposerToolbarRow,  ComposerToolbarScroller,  ComposerToolbarTrigger,} from "../../components/ComposerToolbarTrigger";import { ControlPill, ControlPillMenu } from "../../components/ControlPill";import { ProviderIcon } from "../../components/ProviderIcon";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";import { MOBILE_TYPOGRAPHY } from "../../lib/typography";import type { RemoteClientConnectionState } from "../../lib/connection";import {  insertRankedSearchResult,  normalizeSearchQuery,  scoreQueryMatch,} from "@t3tools/shared/searchRanking";import {  applyProviderOptionMenuEvent,  buildProviderOptionMenuActions,  providerOptionsConfigurationLabel,  resolveProviderOptionDescriptors,} from "../../lib/providerOptions";import { useComposerPathSearch } from "../../state/use-composer-path-search";import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; /** * 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 = 174; export interface ThreadComposerProps {  readonly draftMessage: string;  readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;  readonly placeholder: string;  readonly bottomInset?: number;  readonly connectionState: RemoteClientConnectionState;  readonly connectionError: string | null;  readonly environmentLabel: string | null;  readonly selectedThread: OrchestrationThreadShell;  readonly serverConfig: T3ServerConfig | null;  readonly queueCount: number;  readonly activeThreadBusy: boolean;  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;} /** * The pill / card container — renders as LiquidGlassView on supported * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. */function ComposerSurface(props: {  readonly children: ReactNode;  readonly style: ViewStyle;  readonly isDarkMode: boolean;}) {  if (isLiquidGlassSupported) {    return (      <LiquidGlassView        effect="clear"        interactive        tintColor={props.isDarkMode ? "rgba(44,44,46,0.5)" : "rgba(255,255,255,0.45)"}        colorScheme={props.isDarkMode ? "dark" : "light"}        style={props.style}      >        {props.children}      </LiquidGlassView>    );  }   return (    <View      style={[        props.style,        {          backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",          borderWidth: 1,          borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",        },      ]}    >      {props.children}    </View>  );} function composerConnectionStatus(input: {  readonly connectionError: string | null;  readonly connectionState: RemoteClientConnectionState;  readonly environmentLabel: string | null;}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | 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":      return null;  }} const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: {  readonly onPress: () => void;  readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string };}) {  const isReconnecting = props.status.kind === "reconnecting";   return (    <View className="items-center pb-2">      <Pressable        accessibilityRole="button"        onPress={props.onPress}        className="max-w-full flex-row items-center gap-2 rounded-full bg-white/90 px-3 py-2 shadow-sm active:opacity-70 dark:bg-neutral-900/90"      >        {isReconnecting ? (          <ActivityIndicator size="small" color="#8e8e93" />        ) : (          <View className="h-2 w-2 rounded-full bg-red-500" />        )}        <Text          className="max-w-[260px] text-sm font-t3-bold leading-[17px] text-foreground"          numberOfLines={1}        >          {props.status.label}        </Text>      </Pressable>    </View>  );}); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {  const isDarkMode = useColorScheme() === "dark";  const foregroundColor = useThemeColor("--color-foreground");  const fallbackInputRef = useRef<ComposerEditorHandle>(null);  const inputRef = props.editorRef ?? fallbackInputRef;  const [isFocused, setIsFocused] = useState(false);  const wasExpandedBeforePreviewRef = useRef(false);  const { onExpandedChange } = props;   const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);  const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;  const isExpanded = isFocused;  const canSend = hasContent;   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 handleFocus = useCallback(() => {    setIsFocused(true);    onExpandedChange?.(true);  }, [onExpandedChange]);   const handleBlur = useCallback(() => {    setIsFocused(false);    onExpandedChange?.(false);  }, [onExpandedChange]);  const showStopAction =    props.selectedThread.session?.status === "running" ||    props.selectedThread.session?.status === "starting";   const sendLabel =    props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0      ? "Queue"      : "Send";  const currentModelSelection = props.selectedThread.modelSelection;  const currentRuntimeMode = props.selectedThread.runtimeMode;  const currentInteractionMode = props.selectedThread.interactionMode ?? "default";  const connectionStatus = composerConnectionStatus({    connectionError: props.connectionError,    connectionState: props.connectionState,    environmentLabel: props.environmentLabel,  });  const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";  const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";  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]);   // ── Trigger detection ────────────────────────────────────  const [composerSelection, setComposerSelection] = useState(() => ({    start: props.draftMessage.length,    end: props.draftMessage.length,  }));   const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => {    setComposerSelection(selection);  }, []);  useEffect(() => {    const end = props.draftMessage.length;    setComposerSelection((selection) => {      const start = Math.min(selection.start, end);      const selectionEnd = Math.min(selection.end, end);      if (start === selection.start && selectionEnd === selection.end) {        return selection;      }      return { start, end: selectionEnd };    });  }, [props.draftMessage.length]);   const composerTrigger = useMemo<ComposerTrigger | null>(() => {    if (composerSelection.start !== composerSelection.end) {      return null;    }    return detectComposerTrigger(props.draftMessage, composerSelection.end);  }, [composerSelection, props.draftMessage]);  const pathSearch = useComposerPathSearch({    environmentId: props.environmentId,    cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,    query: composerTrigger?.kind === "path" ? composerTrigger.query : null,  });   const composerMenuItems: ComposerCommandItem[] = useMemo(() => {    if (!composerTrigger) return [];     if (composerTrigger.kind === "slash-command") {      const q = composerTrigger.query.toLowerCase();      const allBuiltIn = [        {          id: "cmd:model",          type: "slash-command" as const,          command: "model",          label: "/model",          description: "Switch model",        },        {          id: "cmd:plan",          type: "slash-command" as const,          command: "plan",          label: "/plan",          description: "Switch to plan mode",        },        {          id: "cmd:default",          type: "slash-command" as const,          command: "default",          label: "/default",          description: "Switch to default mode",        },      ];      const builtIn = allBuiltIn.filter((item) => item.command.includes(q));       const providerCommands: ComposerCommandItem[] = [];      for (const cmd of selectedProviderStatus?.slashCommands ?? []) {        if (!cmd.name.toLowerCase().includes(q)) continue;        providerCommands.push({          id: `pcmd:${cmd.name}`,          type: "provider-slash-command" as const,          command: cmd,          label: `/${cmd.name}`,          description: cmd.description ?? "",        });      }       return [...builtIn, ...providerCommands];    }     if (composerTrigger.kind === "skill") {      const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);      const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {        trimLeadingPattern: /^\$+/,      });       if (!normalizedQuery) {        return enabledSkills.slice(0, 20).map((skill) => ({          id: `skill:${skill.name}`,          type: "skill" as const,          skill,          label: skill.displayName ?? skill.name,          description: skill.shortDescription ?? skill.description ?? "",        }));      }       const ranked: Array<{        item: (typeof enabledSkills)[number];        score: number;        tieBreaker: string;      }> = [];      for (const skill of enabledSkills) {        const displayLabel = (skill.displayName ?? skill.name).toLowerCase();        const scores = [          scoreQueryMatch({            value: skill.name.toLowerCase(),            query: normalizedQuery,            exactBase: 0,            prefixBase: 2,            boundaryBase: 4,            includesBase: 6,            fuzzyBase: 100,            boundaryMarkers: ["-", "_", "/"],          }),          scoreQueryMatch({            value: displayLabel,            query: normalizedQuery,            exactBase: 1,            prefixBase: 3,            boundaryBase: 5,            includesBase: 7,            fuzzyBase: 110,          }),          scoreQueryMatch({            value: skill.shortDescription?.toLowerCase() ?? "",            query: normalizedQuery,            exactBase: 20,            prefixBase: 22,            boundaryBase: 24,            includesBase: 26,          }),          scoreQueryMatch({            value: skill.description?.toLowerCase() ?? "",            query: normalizedQuery,            exactBase: 30,            prefixBase: 32,            boundaryBase: 34,            includesBase: 36,          }),        ].filter((s): s is number => s !== null);         if (scores.length > 0) {          insertRankedSearchResult(            ranked,            {              item: skill,              score: Math.min(...scores),              tieBreaker: `${displayLabel}\u0000${skill.name}`,            },            20,          );        }      }       return ranked.map(({ item: skill }) => ({        id: `skill:${skill.name}`,        type: "skill" as const,        skill,        label: skill.displayName ?? skill.name,        description: skill.shortDescription ?? skill.description ?? "",      }));    }     if (composerTrigger.kind === "path") {      return pathSearch.entries.map((entry) => {        const parts = entry.path.split("/");        return {          id: `path:${entry.path}`,          type: "path" as const,          path: entry.path,          kind: entry.kind,          label: parts[parts.length - 1] ?? entry.path,          description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",        };      });    }     return [];  }, [composerTrigger, pathSearch.entries, selectedProviderStatus]);   // ── Handle command selection ──────────────────────────────  const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;   const handleSend = useCallback(() => {    void onSendMessage();  }, [onSendMessage]);  const handleCommandSelect = useCallback(    (item: ComposerCommandItem) => {      if (!composerTrigger) return;       if (        item.type === "slash-command" &&        (item.command === "plan" || item.command === "default")      ) {        const result = replaceTextRange(          draftMessage,          composerTrigger.rangeStart,          composerTrigger.rangeEnd,          "",        );        setComposerSelection({ start: result.cursor, end: result.cursor });        onChangeDraftMessage(result.text);        onUpdateInteractionMode(item.command);        return;      }       let replacement = "";      if (item.type === "path") {        replacement = `${serializeComposerFileLink(item.path)} `;      } else if (item.type === "skill") {        replacement = `$${item.skill.name} `;      } else if (item.type === "slash-command") {        replacement = `/${item.command} `;      } else if (item.type === "provider-slash-command") {        replacement = `/${item.command.name} `;      }       const result = replaceTextRange(        draftMessage,        composerTrigger.rangeStart,        composerTrigger.rangeEnd,        replacement,      );      setComposerSelection({ start: result.cursor, end: result.cursor });      onChangeDraftMessage(result.text);    },    [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],  );   // ── Model menu ───────────────────────────────────────────  const modelOptions = useMemo(    () => buildModelOptions(props.serverConfig, currentModelSelection),    [props.serverConfig, currentModelSelection],  );

Lines 1–500 of 853.