From ec3a849cbdb4e81e0131f69e9705bab1dd4b0092 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 17 Dec 2025 09:13:15 +0800 Subject: [PATCH] feat: refactor ChatInput with autocomplete, suggestion management, and settings integration Extract word detection and suggestion logic into reusable utilities and hooks. Add FloatingOverlay and Autocomplete components for intelligent input assistance. Integrate settings panel and abort button directly into ChatInput component. Simplify SessionHeader by removing settings dialog (now in ChatInput). --- bun.lock | 9 + web/package.json | 1 + web/src/components/ChatInput.tsx | 554 +++++++++++++++++- web/src/components/ChatInput/Autocomplete.tsx | 48 ++ .../components/ChatInput/FloatingOverlay.tsx | 25 + web/src/components/SessionChat.tsx | 44 +- web/src/components/SessionHeader.tsx | 139 +---- web/src/hooks/useActiveSuggestions.ts | 191 ++++++ web/src/hooks/useActiveWord.ts | 20 + web/src/index.css | 2 + web/src/utils/applySuggestion.ts | 61 ++ web/src/utils/findActiveWord.ts | 187 ++++++ 12 files changed, 1130 insertions(+), 151 deletions(-) create mode 100644 web/src/components/ChatInput/Autocomplete.tsx create mode 100644 web/src/components/ChatInput/FloatingOverlay.tsx create mode 100644 web/src/hooks/useActiveSuggestions.ts create mode 100644 web/src/hooks/useActiveWord.ts create mode 100644 web/src/utils/applySuggestion.ts create mode 100644 web/src/utils/findActiveWord.ts 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 ( -
-
-