932 lines · 33.9 KB · TypeScript
RawDownload
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 { 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,  StyleSheet,  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 { themeColorWithAlpha } from "../../lib/mobileTheme";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,  type ComposerEditorSelection,} 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 { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import type { RemoteClientConnectionState } from "../../lib/connection";import {  insertRankedSearchResult,  normalizeSearchQuery,  scoreQueryMatch,} from "@t3tools/shared/searchRanking";import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";import { useComposerPathSearch } from "../../state/use-composer-path-search";import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";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;  readonly isDarkMode: boolean;  /** Existing thread composers morph between pill and card layouts. */  readonly animateLayout?: boolean;}) {  const cardColor = useThemeColor("--color-card-translucent");  const borderColor = useThemeColor("--color-border");  const shadowColor = useThemeColor("--color-primary-shadow");  // 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,    shadowOpacity: props.isDarkMode ? 0.35 : 0.12,    shadowRadius: 14,    shadowOffset: { width: 0, height: 6 },    elevation: 10,  };   return (    <Animated.View      layout={props.animateLayout === false ? undefined : COMPOSER_LAYOUT_TRANSITION}      style={shadowStyle}    >      <GlassSurface        chrome="none"        fallbackStyle={{          backgroundColor: cardColor,          borderWidth: 1,          borderColor,        }}        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";  const indicatorColor = useThemeColor("--color-icon-muted");   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" color={indicatorColor} />        ) : (          <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 { themeAppearance } = useAppearancePreferences();  const isDarkMode = themeAppearance === "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 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 toolbarSurface = String(useThemeColor("--color-card"));  const backdropSurface = String(useThemeColor("--color-screen"));  const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95);  const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0);  const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`;  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 ?? "",        });      }       const skillItems = (selectedProviderStatus?.skills ?? [])        .filter((skill) => matchesSlashSkillQuery(skill, q))        .map((skill) => ({          id: `skill:${skill.name}`,          type: "skill" as const,          skill,          label: `skill:${skill.name}`,          description: skill.shortDescription ?? skill.description ?? "",        }));       return [...builtIn, ...providerCommands, ...skillItems];    }     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,

Lines 1–500 of 932.