diff --git a/bun.lock b/bun.lock index 5d7c200d..8a06e2de 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,7 @@ "react-dom": "^19.2.3", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", + "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.5.5", @@ -1363,6 +1364,8 @@ "react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="], + "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -1595,6 +1598,12 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], + + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + + "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/web/package.json b/web/package.json index d3780085..4c3eda65 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "react-dom": "^19.2.3", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", + "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.1", "tailwind-merge": "^2.5.5" diff --git a/web/src/components/ChatInput.tsx b/web/src/components/ChatInput.tsx index f8091c4f..9ce8d005 100644 --- a/web/src/components/ChatInput.tsx +++ b/web/src/components/ChatInput.tsx @@ -1,46 +1,536 @@ -import { useState } from 'react' -import { Button } from '@/components/ui/button' +import { + useState, + useCallback, + useRef, + useEffect, + useImperativeHandle, + forwardRef, + memo +} from 'react' +import TextareaAutosize from 'react-textarea-autosize' +import type { ModelMode, PermissionMode } from '@/types/api' +import type { Suggestion } from '@/hooks/useActiveSuggestions' +import { useActiveWord } from '@/hooks/useActiveWord' +import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' +import { applySuggestion } from '@/utils/applySuggestion' import { getTelegramWebApp } from '@/hooks/useTelegram' +import { FloatingOverlay } from './ChatInput/FloatingOverlay' +import { Autocomplete } from './ChatInput/Autocomplete' -export function ChatInput(props: { +// Types +export type SupportedKey = 'Enter' | 'Escape' | 'ArrowUp' | 'ArrowDown' | 'Tab' + +export interface TextInputState { + text: string + selection: { start: number; end: number } +} + +export interface ChatInputHandle { + focus: () => void + blur: () => void +} + +export interface ChatInputProps { disabled?: boolean onSend: (text: string) => void -}) { + // Session data + sessionId?: string + permissionMode?: PermissionMode + modelMode?: ModelMode + active?: boolean + // Callbacks + onPermissionModeChange?: (mode: PermissionMode) => void + onModelModeChange?: (mode: ModelMode) => void + onAbort?: () => Promise + // Autocomplete + autocompletePrefixes?: string[] + autocompleteSuggestions?: (query: string) => Promise +} + +// Permission mode display config +const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const +const PERMISSION_MODE_LABELS: Record = { + default: 'Default', + acceptEdits: 'Accept Edits', + plan: 'Plan Mode', + bypassPermissions: 'Bypass All' +} + +// Model mode display config +const MODEL_MODES = ['default', 'sonnet', 'opus'] as const +const MODEL_MODE_LABELS: Record = { + default: 'Default', + sonnet: 'Sonnet', + opus: 'Opus' +} + +// Default empty suggestion handler +const defaultSuggestionHandler = async (): Promise => [] + +export const ChatInput = memo(forwardRef(function ChatInput(props, ref) { + const { + disabled = false, + onSend, + permissionMode = 'default', + modelMode = 'default', + active = true, + onPermissionModeChange, + onModelModeChange, + onAbort, + autocompletePrefixes = ['@', '/'], + autocompleteSuggestions = defaultSuggestionHandler + } = props + + // State const [text, setText] = useState('') + const [inputState, setInputState] = useState({ + text: '', + selection: { start: 0, end: 0 } + }) + const [showSettings, setShowSettings] = useState(false) + const [isAborting, setIsAborting] = useState(false) + // Refs + const textareaRef = useRef(null) + + // Imperative handle + useImperativeHandle(ref, () => ({ + focus: () => textareaRef.current?.focus(), + blur: () => textareaRef.current?.blur() + }), []) + + // Autocomplete hooks + const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes) + const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( + activeWord, + autocompleteSuggestions, + { clampSelection: true, wrapAround: true } + ) + + // Computed values const trimmed = text.trim() + const hasText = trimmed.length > 0 + const controlsDisabled = disabled || !active - const send = () => { - if (!trimmed) return - getTelegramWebApp()?.HapticFeedback?.impactOccurred('light') - props.onSend(trimmed) + // Haptic feedback helper + const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => { + const tg = getTelegramWebApp() + if (type === 'light') { + tg?.HapticFeedback?.impactOccurred('light') + } else if (type === 'success') { + tg?.HapticFeedback?.notificationOccurred('success') + } else { + tg?.HapticFeedback?.notificationOccurred('error') + } + }, []) + + // Send message + const send = useCallback(() => { + if (!trimmed || controlsDisabled) return + haptic('light') + onSend(trimmed) setText('') - } + setInputState({ text: '', selection: { start: 0, end: 0 } }) + }, [trimmed, controlsDisabled, haptic, onSend]) + + // Handle suggestion selection + const handleSuggestionSelect = useCallback((index: number) => { + const suggestion = suggestions[index] + if (!suggestion || !textareaRef.current) return + + const result = applySuggestion( + inputState.text, + inputState.selection, + suggestion.text, + autocompletePrefixes, + true + ) + + setText(result.text) + setInputState({ + text: result.text, + selection: { start: result.cursorPosition, end: result.cursorPosition } + }) + + // Set cursor position + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.setSelectionRange(result.cursorPosition, result.cursorPosition) + textareaRef.current.focus() + } + }, 0) + + haptic('light') + }, [suggestions, inputState, autocompletePrefixes, haptic]) + + // Handle abort + const handleAbort = useCallback(async () => { + if (!onAbort || isAborting) return + + haptic('error') + setIsAborting(true) + const startTime = Date.now() + + try { + await onAbort() + // Ensure minimum 300ms loading time + const elapsed = Date.now() - startTime + if (elapsed < 300) { + await new Promise(resolve => setTimeout(resolve, 300 - elapsed)) + } + } catch (error) { + console.error('Abort failed:', error) + } finally { + setIsAborting(false) + } + }, [onAbort, isAborting, haptic]) + + // Handle keyboard events + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + const key = e.key + + // Handle autocomplete navigation first + if (suggestions.length > 0) { + if (key === 'ArrowUp') { + e.preventDefault() + moveUp() + return + } else if (key === 'ArrowDown') { + e.preventDefault() + moveDown() + return + } else if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) { + e.preventDefault() + const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0 + handleSuggestionSelect(indexToSelect) + return + } else if (key === 'Escape') { + e.preventDefault() + clearSuggestions() + return + } + } + + // Handle Escape for abort when no suggestions + if (key === 'Escape' && onAbort && !isAborting) { + e.preventDefault() + handleAbort() + return + } + + // Handle Enter to send + if (key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + return + } + + // Handle Shift+Tab for permission mode switching + if (key === 'Tab' && e.shiftKey && onPermissionModeChange) { + e.preventDefault() + const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number]) + const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length + onPermissionModeChange(PERMISSION_MODES[nextIndex]) + haptic('light') + return + } + }, [ + suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect, + onAbort, isAborting, handleAbort, send, onPermissionModeChange, permissionMode, haptic + ]) + + // Handle global keyboard for model mode switching + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + // Handle Cmd/Ctrl+M for model mode switching + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) { + e.preventDefault() + const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) + const nextIndex = (currentIndex + 1) % MODEL_MODES.length + onModelModeChange(MODEL_MODES[nextIndex]) + haptic('light') + } + } + + window.addEventListener('keydown', handleGlobalKeyDown) + return () => window.removeEventListener('keydown', handleGlobalKeyDown) + }, [modelMode, onModelModeChange, haptic]) + + // Handle text change + const handleChange = useCallback((e: React.ChangeEvent) => { + const newText = e.target.value + const selection = { + start: e.target.selectionStart, + end: e.target.selectionEnd + } + setText(newText) + setInputState({ text: newText, selection }) + }, []) + + // Handle selection change + const handleSelect = useCallback((e: React.SyntheticEvent) => { + const target = e.target as HTMLTextAreaElement + setInputState(prev => ({ + ...prev, + selection: { start: target.selectionStart, end: target.selectionEnd } + })) + }, []) + + // Handle settings toggle + const handleSettingsToggle = useCallback(() => { + haptic('light') + setShowSettings(prev => !prev) + }, [haptic]) + + // Handle permission mode change + const handlePermissionChange = useCallback((mode: PermissionMode) => { + haptic('light') + onPermissionModeChange?.(mode) + }, [haptic, onPermissionModeChange]) + + // Handle model mode change + const handleModelChange = useCallback((mode: ModelMode) => { + haptic('light') + onModelModeChange?.(mode) + }, [haptic, onModelModeChange]) + + // Close settings when clicking outside + useEffect(() => { + if (!showSettings) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.closest('.settings-panel') && !target.closest('.settings-button')) { + setShowSettings(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showSettings]) return ( -
-
-