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. Load more
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 {