854 lines · 30.0 KB · TypeScript
RawDownload
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";import type {  EnvironmentId,  ModelSelection,  OrchestrationThread,  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 { 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 type { RemoteClientConnectionState } from "../../lib/connection";import {  insertRankedSearchResult,  normalizeSearchQuery,  scoreQueryMatch,} from "@t3tools/shared/searchRanking";import {  getModelSelectionBooleanOptionValue,  getModelSelectionStringOptionValue,} from "@t3tools/shared/model";import { useComposerPathSearch } from "../../state/use-composer-path-search";import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";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 selectedThread: OrchestrationThread;  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: () => Promise<void>;  readonly onSendMessage: () => void;  readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>;  readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>;  readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise<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 withModelSelectionOption(  selection: ModelSelection,  id: string,  value: string | boolean | undefined,): ModelSelection {  const options = (selection.options ?? []).filter((option) => option.id !== id);  return {    ...selection,    options: value === undefined ? options : [...options, { id, value }],  };} function formatTitleCase(value: string): string {  return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;} 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 = props.connectionState === "ready" && 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" ||    props.queueCount > 0;   const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send";  const currentModelSelection = props.selectedThread.modelSelection;  const currentRuntimeMode = props.selectedThread.runtimeMode;  const currentInteractionMode = props.selectedThread.interactionMode ?? "default";  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]);   // Extract current model options (effort, fastMode, contextWindow)  const selectedProviderDriver = selectedProviderStatus?.driver ?? null;  const currentEffort =    selectedProviderDriver === "claudeAgent"      ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high")      : "high";  const currentFastMode =    getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false;  const currentContextWindow =    selectedProviderDriver === "claudeAgent"      ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M")      : "1M";  // ── 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(() => {    onSendMessage();    inputRef.current?.blur();  }, [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);        void 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],  );  const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);  const currentModelOption =    modelOptions.find(      (option) =>        option.selection.instanceId === currentModelSelection.instanceId &&        option.selection.model === currentModelSelection.model,    ) ?? null;  const configurationLabel = useMemo(() => {    const parts = [      formatTitleCase(currentEffort),      currentFastMode ? "Fast" : null,      currentContextWindow !== "1M" ? currentContextWindow : null,    ].filter((part): part is string => Boolean(part));    return parts.length > 0 ? parts.join(" · ") : "Configuration";  }, [currentContextWindow, currentEffort, currentFastMode]);  const modelMenuActions = useMemo(    () =>      providerGroups.map((group) => ({        id: `provider:${group.providerKey}`,        title: group.providerLabel,        subtitle: group.models.find(          (model) =>            model.selection.instanceId === currentModelSelection.instanceId &&            model.selection.model === currentModelSelection.model,        )?.label,        subactions: group.models.map((option) => ({          id: `model:${option.key}`,          title: option.label,          state:            option.selection.instanceId === currentModelSelection.instanceId &&            option.selection.model === currentModelSelection.model              ? ("on" as const)              : undefined,        })),      })),    [providerGroups, currentModelSelection],  );   // ── Options menu ─────────────────────────────────────────  const optionsMenuActions = useMemo(    () => [      {        id: "options-effort",        title: "Effort",        subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`,        subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({          id: `options:effort:${level}`,          title: `${level}${level === "high" ? " (default)" : ""}`,          state: currentEffort === level ? ("on" as const) : undefined,        })),      },      {        id: "options-fast-mode",

Lines 1–500 of 854.