933 lines · 33.2 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,  Platform,  Pressable,  useColorScheme,  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 { useThemeColor } from "../../lib/useThemeColor";import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";import { scopedThreadKey } from "../../lib/scopedEntities"; 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 { useScaledTextRole } from "../settings/appearance/useScaledTextRole";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 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 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. * 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;  readonly isDarkMode: boolean;}) {  // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself  // (needed to clip content to the pill shape) would clip the shadow on iOS.  const shadowStyle: ViewStyle = {    borderRadius: props.style.borderRadius,    shadowColor: "#000000",    shadowOpacity: props.isDarkMode ? 0.35 : 0.12,    shadowRadius: 14,    shadowOffset: { width: 0, height: 6 },    elevation: 10,  };   if (isLiquidGlassSupported) {    return (      <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>        <LiquidGlassView          effect="regular"          interactive          colorScheme={props.isDarkMode ? "dark" : "light"}          style={props.style}        >          {props.children}        </LiquidGlassView>      </Animated.View>    );  }   return (    <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}>      <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>    </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-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-snug text-foreground"          numberOfLines={1}        >          {props.status.label}        </Text>      </Pressable>    </Animated.View>  );}); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {  const isDarkMode = useColorScheme() === "dark";  const foregroundColor = useThemeColor("--color-foreground");  const bodyText = useScaledTextRole("body");  const fallbackInputRef = useRef<ComposerEditorHandle>(null);  const inputRef = props.editorRef ?? fallbackInputRef;  const [isFocused, setIsFocused] = useState(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;  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,    threadSyncPhase: props.threadSyncPhase,  });  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 {

Lines 1–500 of 933.