mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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).
This commit is contained in:
@@ -77,6 +77,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^2.5.5"
|
"tailwind-merge": "^2.5.5"
|
||||||
|
|||||||
@@ -1,46 +1,536 @@
|
|||||||
import { useState } from 'react'
|
import {
|
||||||
import { Button } from '@/components/ui/button'
|
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 { getTelegramWebApp } from '@/hooks/useTelegram'
|
||||||
|
import { FloatingOverlay } from './ChatInput/FloatingOverlay'
|
||||||
|
import { Autocomplete } from './ChatInput/Autocomplete'
|
||||||
|
|
||||||
export function ChatInput(props: {
|
// Types
|
||||||
disabled?: boolean
|
export type SupportedKey = 'Enter' | 'Escape' | 'ArrowUp' | 'ArrowDown' | 'Tab'
|
||||||
onSend: (text: string) => void
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
|
|
||||||
const trimmed = text.trim()
|
export interface TextInputState {
|
||||||
|
text: string
|
||||||
const send = () => {
|
selection: { start: number; end: number }
|
||||||
if (!trimmed) return
|
|
||||||
getTelegramWebApp()?.HapticFeedback?.impactOccurred('light')
|
|
||||||
props.onSend(trimmed)
|
|
||||||
setText('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
export interface ChatInputHandle {
|
||||||
<div className="bg-[var(--app-bg)] p-2">
|
focus: () => void
|
||||||
<div className="flex items-end gap-2">
|
blur: () => void
|
||||||
<textarea
|
}
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
export interface ChatInputProps {
|
||||||
placeholder="Type a message..."
|
disabled?: boolean
|
||||||
className="flex-1 resize-none rounded-xl bg-[var(--app-secondary-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]"
|
onSend: (text: string) => void
|
||||||
rows={1}
|
// Session data
|
||||||
disabled={props.disabled}
|
sessionId?: string
|
||||||
onKeyDown={(e) => {
|
permissionMode?: PermissionMode
|
||||||
if (e.key !== 'Enter') return
|
modelMode?: ModelMode
|
||||||
if (e.shiftKey) return
|
active?: boolean
|
||||||
|
// Callbacks
|
||||||
|
onPermissionModeChange?: (mode: PermissionMode) => void
|
||||||
|
onModelModeChange?: (mode: ModelMode) => void
|
||||||
|
onAbort?: () => Promise<void>
|
||||||
|
// Autocomplete
|
||||||
|
autocompletePrefixes?: string[]
|
||||||
|
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission mode display config
|
||||||
|
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const
|
||||||
|
const PERMISSION_MODE_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
default: 'Default',
|
||||||
|
sonnet: 'Sonnet',
|
||||||
|
opus: 'Opus'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default empty suggestion handler
|
||||||
|
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
|
||||||
|
|
||||||
|
export const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(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<TextInputState>({
|
||||||
|
text: '',
|
||||||
|
selection: { start: 0, end: 0 }
|
||||||
|
})
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [isAborting, setIsAborting] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(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
|
||||||
|
|
||||||
|
// 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<HTMLTextAreaElement>) => {
|
||||||
|
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()
|
e.preventDefault()
|
||||||
send()
|
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<HTMLTextAreaElement>) => {
|
||||||
|
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<HTMLTextAreaElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="bg-[var(--app-bg)] px-2 pb-2 pt-1">
|
||||||
|
<div className="relative mx-auto w-full max-w-[720px]">
|
||||||
|
{/* Autocomplete overlay */}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 z-50 mb-2">
|
||||||
|
<FloatingOverlay maxHeight={240}>
|
||||||
|
<Autocomplete
|
||||||
|
suggestions={suggestions as Suggestion[]}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={handleSuggestionSelect}
|
||||||
/>
|
/>
|
||||||
<Button
|
</FloatingOverlay>
|
||||||
onClick={send}
|
</div>
|
||||||
disabled={props.disabled || !trimmed}
|
)}
|
||||||
|
|
||||||
|
{/* Settings overlay */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="settings-panel absolute bottom-full left-0 right-0 z-50 mb-2">
|
||||||
|
<FloatingOverlay maxHeight={320}>
|
||||||
|
{/* Permission Mode Section */}
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
|
||||||
|
Permission Mode
|
||||||
|
</div>
|
||||||
|
{PERMISSION_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
controlsDisabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePermissionChange(mode)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
Send
|
<div
|
||||||
</Button>
|
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
|
||||||
|
permissionMode === mode
|
||||||
|
? 'border-[var(--app-link)]'
|
||||||
|
: 'border-[var(--app-hint)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{permissionMode === mode && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={permissionMode === mode ? 'text-[var(--app-link)]' : ''}>
|
||||||
|
{PERMISSION_MODE_LABELS[mode]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="mx-3 h-px bg-[var(--app-divider)]" />
|
||||||
|
|
||||||
|
{/* Model Mode Section */}
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
|
||||||
|
Model
|
||||||
|
</div>
|
||||||
|
{MODEL_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
controlsDisabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleModelChange(mode)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
|
||||||
|
modelMode === mode
|
||||||
|
? 'border-[var(--app-link)]'
|
||||||
|
: 'border-[var(--app-hint)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{modelMode === mode && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={modelMode === mode ? 'text-[var(--app-link)]' : ''}>
|
||||||
|
{MODEL_MODE_LABELS[mode]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FloatingOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
{(permissionMode && permissionMode !== 'default') && (
|
||||||
|
<div className="flex items-center justify-end px-2 pb-1">
|
||||||
|
<span className={`text-xs ${
|
||||||
|
permissionMode === 'acceptEdits' ? 'text-amber-500' :
|
||||||
|
permissionMode === 'bypassPermissions' ? 'text-red-500' :
|
||||||
|
permissionMode === 'plan' ? 'text-blue-500' :
|
||||||
|
'text-[var(--app-hint)]'
|
||||||
|
}`}>
|
||||||
|
{PERMISSION_MODE_LABELS[permissionMode]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unified panel */}
|
||||||
|
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="flex items-center px-3 py-2">
|
||||||
|
<TextareaAutosize
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
maxRows={5}
|
||||||
|
className="flex-1 resize-none bg-transparent text-sm leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center justify-between px-2 pb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Settings button */}
|
||||||
|
{onPermissionModeChange && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-button flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]"
|
||||||
|
onClick={handleSettingsToggle}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Abort button */}
|
||||||
|
{onAbort && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isAborting || controlsDisabled}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={handleAbort}
|
||||||
|
>
|
||||||
|
{isAborting ? (
|
||||||
|
<svg
|
||||||
|
className="animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4-2.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-4a.5.5 0 0 1-.5-.5v-4Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={controlsDisabled || !hasText}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
|
||||||
|
hasText && !controlsDisabled
|
||||||
|
? 'bg-black text-white'
|
||||||
|
: 'bg-[#C0C0C0] text-white'
|
||||||
|
} disabled:cursor-not-allowed`}
|
||||||
|
onClick={send}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5" />
|
||||||
|
<polyline points="5 12 12 5 19 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { Suggestion } from '@/hooks/useActiveSuggestions'
|
||||||
|
|
||||||
|
interface AutocompleteProps {
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
selectedIndex: number
|
||||||
|
onSelect: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocomplete suggestions list component
|
||||||
|
*/
|
||||||
|
export const Autocomplete = memo(function Autocomplete(props: AutocompleteProps) {
|
||||||
|
const { suggestions, selectedIndex, onSelect } = props
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1">
|
||||||
|
{suggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.key}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
index === selectedIndex
|
||||||
|
? 'bg-[var(--app-button)] text-[var(--app-button-text)]'
|
||||||
|
: 'text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(index)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // Prevent blur on textarea
|
||||||
|
>
|
||||||
|
<span className="font-medium">{suggestion.label}</span>
|
||||||
|
{suggestion.description && (
|
||||||
|
<span className={`truncate text-xs ${
|
||||||
|
index === selectedIndex
|
||||||
|
? 'opacity-80'
|
||||||
|
: 'text-[var(--app-hint)]'
|
||||||
|
}`}>
|
||||||
|
{suggestion.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { memo, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface FloatingOverlayProps {
|
||||||
|
children: ReactNode
|
||||||
|
maxHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A floating panel container with shadow and rounded corners
|
||||||
|
* Used for autocomplete suggestions and settings panels
|
||||||
|
*/
|
||||||
|
export const FloatingOverlay = memo(function FloatingOverlay(props: FloatingOverlayProps) {
|
||||||
|
const { children, maxHeight = 240 } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-xl border border-[var(--app-divider)] bg-[var(--app-bg)] shadow-lg"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
<div className="overflow-y-auto" style={{ maxHeight }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ApiClient } from '@/api/client'
|
import type { ApiClient } from '@/api/client'
|
||||||
import type { DecryptedMessage, Session } from '@/types/api'
|
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
|
||||||
import type { NormalizedMessage } from '@/chat/types'
|
import type { NormalizedMessage } from '@/chat/types'
|
||||||
import { normalizeDecryptedMessage } from '@/chat/normalize'
|
import { normalizeDecryptedMessage } from '@/chat/normalize'
|
||||||
import { reduceChatBlocks } from '@/chat/reducer'
|
import { reduceChatBlocks } from '@/chat/reducer'
|
||||||
@@ -10,6 +10,7 @@ import { MessageBubble } from '@/components/MessageBubble'
|
|||||||
import { ChatBlockList } from '@/components/ChatBlockList'
|
import { ChatBlockList } from '@/components/ChatBlockList'
|
||||||
import { ChatInput } from '@/components/ChatInput'
|
import { ChatInput } from '@/components/ChatInput'
|
||||||
import { useScrollToBottom } from '@/hooks/useScrollToBottom'
|
import { useScrollToBottom } from '@/hooks/useScrollToBottom'
|
||||||
|
import { getTelegramWebApp } from '@/hooks/useTelegram'
|
||||||
|
|
||||||
export function SessionChat(props: {
|
export function SessionChat(props: {
|
||||||
api: ApiClient
|
api: ApiClient
|
||||||
@@ -62,13 +63,41 @@ export function SessionChat(props: {
|
|||||||
const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced'
|
const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced'
|
||||||
const scrollRef = useScrollToBottom([props.messages.length, reduced.blocks.length, viewMode])
|
const scrollRef = useScrollToBottom([props.messages.length, reduced.blocks.length, viewMode])
|
||||||
|
|
||||||
|
// Permission mode change handler
|
||||||
|
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
|
||||||
|
try {
|
||||||
|
await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan')
|
||||||
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
|
||||||
|
props.onRefresh()
|
||||||
|
} catch (e) {
|
||||||
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
|
||||||
|
console.error('Failed to set permission mode:', e)
|
||||||
|
}
|
||||||
|
}, [props.api, props.session.id, props.onRefresh])
|
||||||
|
|
||||||
|
// Model mode change handler
|
||||||
|
const handleModelModeChange = useCallback(async (mode: ModelMode) => {
|
||||||
|
try {
|
||||||
|
await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus')
|
||||||
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
|
||||||
|
props.onRefresh()
|
||||||
|
} catch (e) {
|
||||||
|
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
|
||||||
|
console.error('Failed to set model mode:', e)
|
||||||
|
}
|
||||||
|
}, [props.api, props.session.id, props.onRefresh])
|
||||||
|
|
||||||
|
// Abort handler
|
||||||
|
const handleAbort = useCallback(async () => {
|
||||||
|
await props.api.abortSession(props.session.id)
|
||||||
|
props.onRefresh()
|
||||||
|
}, [props.api, props.session.id, props.onRefresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<SessionHeader
|
<SessionHeader
|
||||||
api={props.api}
|
|
||||||
session={props.session}
|
session={props.session}
|
||||||
onBack={props.onBack}
|
onBack={props.onBack}
|
||||||
onRefresh={props.onRefresh}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{controlsDisabled ? (
|
{controlsDisabled ? (
|
||||||
@@ -159,6 +188,13 @@ export function SessionChat(props: {
|
|||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={props.isSending || controlsDisabled}
|
disabled={props.isSending || controlsDisabled}
|
||||||
onSend={props.onSend}
|
onSend={props.onSend}
|
||||||
|
sessionId={props.session.id}
|
||||||
|
permissionMode={props.session.permissionMode}
|
||||||
|
modelMode={props.session.modelMode}
|
||||||
|
active={props.session.active}
|
||||||
|
onPermissionModeChange={handlePermissionModeChange}
|
||||||
|
onModelModeChange={handleModelModeChange}
|
||||||
|
onAbort={handleAbort}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { ApiClient } from '@/api/client'
|
import type { Session } from '@/types/api'
|
||||||
import type { ModelMode, PermissionMode, Session } from '@/types/api'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
|
||||||
import { getTelegramWebApp } from '@/hooks/useTelegram'
|
|
||||||
|
|
||||||
function getSessionTitle(session: Session): string {
|
function getSessionTitle(session: Session): string {
|
||||||
if (session.metadata?.name) {
|
if (session.metadata?.name) {
|
||||||
@@ -16,49 +12,36 @@ function getSessionTitle(session: Session): string {
|
|||||||
return session.id.slice(0, 8)
|
return session.id.slice(0, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPermissionMode(mode: PermissionMode): 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' {
|
|
||||||
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan') return mode
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toModelMode(mode: ModelMode): 'default' | 'sonnet' | 'opus' {
|
|
||||||
if (mode === 'sonnet' || mode === 'opus') return mode
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionHeader(props: {
|
export function SessionHeader(props: {
|
||||||
api: ApiClient
|
|
||||||
session: Session
|
session: Session
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onRefresh: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const title = useMemo(() => getSessionTitle(props.session), [props.session])
|
const title = useMemo(() => getSessionTitle(props.session), [props.session])
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const permissionMode = toPermissionMode(props.session.permissionMode)
|
|
||||||
const modelMode = toModelMode(props.session.modelMode)
|
|
||||||
const controlsDisabled = isWorking || !props.session.active
|
|
||||||
|
|
||||||
async function run(action: () => Promise<void>) {
|
|
||||||
setIsWorking(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await action()
|
|
||||||
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
|
|
||||||
props.onRefresh()
|
|
||||||
setSettingsOpen(false)
|
|
||||||
} catch (e) {
|
|
||||||
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
|
|
||||||
setError(e instanceof Error ? e.message : 'Request failed')
|
|
||||||
} finally {
|
|
||||||
setIsWorking(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 bg-[var(--app-bg)] p-3">
|
<div className="flex items-center gap-2 bg-[var(--app-bg)] p-3">
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onBack}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span
|
<span
|
||||||
@@ -81,80 +64,6 @@ export function SessionHeader(props: {
|
|||||||
{props.session.metadata?.host ? `Host: ${props.session.metadata.host}` : props.session.id}
|
{props.session.metadata?.host ? `Host: ${props.session.metadata.host}` : props.session.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
⚙️
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Session Settings</DialogTitle>
|
|
||||||
<DialogDescription className="truncate">
|
|
||||||
{props.session.metadata?.path ?? props.session.id}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 text-sm font-medium">Permission Mode</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(['default', 'acceptEdits', 'bypassPermissions', 'plan'] as const).map((mode) => (
|
|
||||||
<Button
|
|
||||||
key={mode}
|
|
||||||
size="sm"
|
|
||||||
variant={permissionMode === mode ? 'default' : 'secondary'}
|
|
||||||
disabled={controlsDisabled}
|
|
||||||
onClick={() => run(() => props.api.setPermissionMode(props.session.id, mode))}
|
|
||||||
>
|
|
||||||
{mode}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 text-sm font-medium">Model</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(['default', 'sonnet', 'opus'] as const).map((model) => (
|
|
||||||
<Button
|
|
||||||
key={model}
|
|
||||||
size="sm"
|
|
||||||
variant={modelMode === model ? 'default' : 'secondary'}
|
|
||||||
disabled={controlsDisabled}
|
|
||||||
onClick={() => run(() => props.api.setModelMode(props.session.id, model))}
|
|
||||||
>
|
|
||||||
{model}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-[var(--app-subtle-bg)] p-2 text-xs text-[var(--app-hint)]">
|
|
||||||
<div>Path: {props.session.metadata?.path ?? '(unknown)'}</div>
|
|
||||||
<div>Host: {props.session.metadata?.host ?? '(unknown)'}</div>
|
|
||||||
<div>Status: {props.session.active ? 'Active' : 'Inactive'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="text-sm text-red-600">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={controlsDisabled}
|
|
||||||
onClick={() => run(() => props.api.abortSession(props.session.id))}
|
|
||||||
>
|
|
||||||
🛑 Abort Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
key: string
|
||||||
|
text: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuggestionOptions {
|
||||||
|
clampSelection?: boolean // If true, clamp instead of preserving exact position
|
||||||
|
autoSelectFirst?: boolean // If true, automatically select first item when suggestions appear
|
||||||
|
wrapAround?: boolean // If true, wrap around when reaching top/bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple value sync class that processes the latest value
|
||||||
|
* Ensures only the most recent query is processed
|
||||||
|
*/
|
||||||
|
class ValueSync<T> {
|
||||||
|
private latestValue: T | undefined
|
||||||
|
private hasValue = false
|
||||||
|
private processing = false
|
||||||
|
private stopped = false
|
||||||
|
private command: (value: T) => Promise<void>
|
||||||
|
|
||||||
|
constructor(command: (value: T) => Promise<void>) {
|
||||||
|
this.command = command
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(value: T) {
|
||||||
|
if (this.stopped) return
|
||||||
|
this.latestValue = value
|
||||||
|
this.hasValue = true
|
||||||
|
if (!this.processing) {
|
||||||
|
this.processing = true
|
||||||
|
this.doSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopped = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSync() {
|
||||||
|
while (this.hasValue && !this.stopped) {
|
||||||
|
const value = this.latestValue!
|
||||||
|
this.hasValue = false
|
||||||
|
try {
|
||||||
|
await this.command(value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ValueSync error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that manages autocomplete suggestions based on an active word query
|
||||||
|
* Returns: [suggestions, selectedIndex, moveUp, moveDown]
|
||||||
|
*/
|
||||||
|
export function useActiveSuggestions(
|
||||||
|
query: string | null,
|
||||||
|
handler: (query: string) => Promise<Suggestion[]>,
|
||||||
|
options: SuggestionOptions = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
clampSelection = true,
|
||||||
|
autoSelectFirst = true,
|
||||||
|
wrapAround = true
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// State for suggestions
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
selected: number
|
||||||
|
}>({
|
||||||
|
suggestions: [],
|
||||||
|
selected: -1
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveUp = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.suggestions.length === 0) return prev
|
||||||
|
|
||||||
|
if (prev.selected <= 0) {
|
||||||
|
// At top or nothing selected
|
||||||
|
if (wrapAround) {
|
||||||
|
return { ...prev, selected: prev.suggestions.length - 1 }
|
||||||
|
} else {
|
||||||
|
return { ...prev, selected: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Move up
|
||||||
|
return { ...prev, selected: prev.selected - 1 }
|
||||||
|
})
|
||||||
|
}, [wrapAround])
|
||||||
|
|
||||||
|
const moveDown = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.suggestions.length === 0) return prev
|
||||||
|
|
||||||
|
if (prev.selected >= prev.suggestions.length - 1) {
|
||||||
|
// At bottom
|
||||||
|
if (wrapAround) {
|
||||||
|
return { ...prev, selected: 0 }
|
||||||
|
} else {
|
||||||
|
return { ...prev, selected: prev.suggestions.length - 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If nothing selected, select first
|
||||||
|
if (prev.selected < 0) {
|
||||||
|
return { ...prev, selected: 0 }
|
||||||
|
}
|
||||||
|
// Move down
|
||||||
|
return { ...prev, selected: prev.selected + 1 }
|
||||||
|
})
|
||||||
|
}, [wrapAround])
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setState({ suggestions: [], selected: -1 })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Sync query to suggestions
|
||||||
|
const handlerRef = useRef(handler)
|
||||||
|
handlerRef.current = handler
|
||||||
|
|
||||||
|
const sync = useMemo(() => {
|
||||||
|
return new ValueSync<string | null>(async (query) => {
|
||||||
|
if (!query) return
|
||||||
|
|
||||||
|
const suggestions = await handlerRef.current(query)
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
if (clampSelection) {
|
||||||
|
// Simply clamp the selection to valid range
|
||||||
|
let newSelected = prev.selected
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
newSelected = -1
|
||||||
|
} else if (autoSelectFirst && prev.suggestions.length === 0) {
|
||||||
|
// First time showing suggestions, auto-select first
|
||||||
|
newSelected = 0
|
||||||
|
} else if (prev.selected >= suggestions.length) {
|
||||||
|
// Selection is out of bounds, clamp to last item
|
||||||
|
newSelected = suggestions.length - 1
|
||||||
|
} else if (prev.selected < 0 && suggestions.length > 0 && autoSelectFirst) {
|
||||||
|
// No selection but we have suggestions
|
||||||
|
newSelected = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions, selected: newSelected }
|
||||||
|
} else {
|
||||||
|
// Try to preserve selection by key (old behavior)
|
||||||
|
if (prev.selected >= 0 && prev.selected < prev.suggestions.length) {
|
||||||
|
const previousKey = prev.suggestions[prev.selected].key
|
||||||
|
const newIndex = suggestions.findIndex(s => s.key === previousKey)
|
||||||
|
if (newIndex !== -1) {
|
||||||
|
// Found the same key, keep it selected
|
||||||
|
return { suggestions, selected: newIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key not found or no previous selection, clamp the selection
|
||||||
|
const clampedSelection = Math.min(prev.selected, suggestions.length - 1)
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
selected: clampedSelection < 0 && suggestions.length > 0 && autoSelectFirst ? 0 : clampedSelection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [clampSelection, autoSelectFirst])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sync.setValue(query)
|
||||||
|
}, [query, sync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => sync.stop()
|
||||||
|
}, [sync])
|
||||||
|
|
||||||
|
// If no query return empty suggestions
|
||||||
|
if (!query) {
|
||||||
|
return [[], -1, moveUp, moveDown, clear] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return state suggestions
|
||||||
|
return [state.suggestions, state.selected, moveUp, moveDown, clear] as const
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { findActiveWord } from '@/utils/findActiveWord'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that detects the active word at the cursor position
|
||||||
|
* Returns the active word string if cursor is on a word starting with one of the prefixes
|
||||||
|
*/
|
||||||
|
export function useActiveWord(
|
||||||
|
text: string,
|
||||||
|
selection: { start: number; end: number },
|
||||||
|
prefixes: string[] = ['@', '/']
|
||||||
|
) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const w = findActiveWord(text, selection, prefixes)
|
||||||
|
if (w) {
|
||||||
|
return w.activeWord
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [text, selection.start, selection.end, prefixes])
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
/* Theme-aware colors (light mode defaults) */
|
/* Theme-aware colors (light mode defaults) */
|
||||||
--app-border: rgba(0, 0, 0, 0.1);
|
--app-border: rgba(0, 0, 0, 0.1);
|
||||||
|
--app-divider: rgba(0, 0, 0, 0.08);
|
||||||
--app-subtle-bg: rgba(0, 0, 0, 0.05);
|
--app-subtle-bg: rgba(0, 0, 0, 0.05);
|
||||||
--app-code-bg: #f6f8fa;
|
--app-code-bg: #f6f8fa;
|
||||||
--app-inline-code-bg: rgba(0, 0, 0, 0.08);
|
--app-inline-code-bg: rgba(0, 0, 0, 0.08);
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--app-border: rgba(255, 255, 255, 0.1);
|
--app-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--app-divider: rgba(255, 255, 255, 0.08);
|
||||||
--app-subtle-bg: rgba(255, 255, 255, 0.05);
|
--app-subtle-bg: rgba(255, 255, 255, 0.05);
|
||||||
--app-code-bg: #282c34;
|
--app-code-bg: #282c34;
|
||||||
--app-inline-code-bg: rgba(255, 255, 255, 0.1);
|
--app-inline-code-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { findActiveWord } from '@/utils/findActiveWord'
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a suggestion by replacing the active word with the provided suggestion text
|
||||||
|
* @param content The full text content
|
||||||
|
* @param selection The current cursor position/selection
|
||||||
|
* @param suggestion The suggestion text to insert (e.g., "@john_smith")
|
||||||
|
* @param prefixes Array of prefix characters to look for (e.g., ['@', '/'])
|
||||||
|
* @param addSpace Whether to add a space after the suggestion (default: true)
|
||||||
|
* @returns An object containing the new text and cursor position
|
||||||
|
*/
|
||||||
|
export function applySuggestion(
|
||||||
|
content: string,
|
||||||
|
selection: Selection,
|
||||||
|
suggestion: string,
|
||||||
|
prefixes: string[] = ['@', '/'],
|
||||||
|
addSpace: boolean = true
|
||||||
|
): { text: string; cursorPosition: number } {
|
||||||
|
// Find the active word at the current position
|
||||||
|
const activeWord = findActiveWord(content, selection, prefixes)
|
||||||
|
|
||||||
|
if (!activeWord) {
|
||||||
|
// No active word found, just insert the suggestion at cursor position
|
||||||
|
const beforeCursor = content.substring(0, selection.start)
|
||||||
|
const afterCursor = content.substring(selection.end)
|
||||||
|
const suggestionWithSpace = addSpace ? suggestion + ' ' : suggestion
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: beforeCursor + suggestionWithSpace + afterCursor,
|
||||||
|
cursorPosition: selection.start + suggestionWithSpace.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the complete word (from offset to endOffset) with the suggestion
|
||||||
|
const beforeWord = content.substring(0, activeWord.offset)
|
||||||
|
const afterWord = content.substring(activeWord.endOffset)
|
||||||
|
|
||||||
|
// Add space after suggestion if requested
|
||||||
|
let suggestionToInsert = suggestion
|
||||||
|
if (addSpace) {
|
||||||
|
// Add space if:
|
||||||
|
// 1. There's no text after (end of string)
|
||||||
|
// 2. There's text after but no space
|
||||||
|
if (afterWord.length === 0 || afterWord[0] !== ' ') {
|
||||||
|
suggestionToInsert += ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newText = beforeWord + suggestionToInsert + afterWord
|
||||||
|
const newCursorPosition = activeWord.offset + suggestionToInsert.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: newText,
|
||||||
|
cursorPosition: newCursorPosition
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Finds the active word at the cursor position that starts with one of the given prefixes
|
||||||
|
* @param content The full text content
|
||||||
|
* @param selection The current cursor position/selection
|
||||||
|
* @param prefixes Array of prefix characters to look for (e.g., ['@', '/'])
|
||||||
|
* @returns An object containing word info, or undefined if no prefixed word is found
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Characters that stop the active word search
|
||||||
|
const STOP_CHARACTERS = ['\n', ',', '(', ')', '[', ']', '{', '}', '<', '>', ';', '!', '?', '.']
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveWord {
|
||||||
|
word: string // Full word from prefix to end (e.g., "@username")
|
||||||
|
activeWord: string // Part from prefix to cursor (e.g., "@use")
|
||||||
|
offset: number // Starting position of the word
|
||||||
|
length: number // Total length of the complete word
|
||||||
|
activeLength: number // Length from prefix to cursor
|
||||||
|
endOffset: number // Position where the word ends (offset + length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveWordStart(
|
||||||
|
content: string,
|
||||||
|
selection: Selection,
|
||||||
|
prefixes: string[]
|
||||||
|
): number {
|
||||||
|
let startIndex = selection.start - 1
|
||||||
|
let spaceIndex = -1
|
||||||
|
let foundPrefix = false
|
||||||
|
let prefixIndex = -1
|
||||||
|
|
||||||
|
while (startIndex >= 0) {
|
||||||
|
const char = content.charAt(startIndex)
|
||||||
|
|
||||||
|
// Check if we hit a space
|
||||||
|
if (char === ' ') {
|
||||||
|
if (foundPrefix) {
|
||||||
|
// We found a prefix earlier, return its position
|
||||||
|
return prefixIndex
|
||||||
|
}
|
||||||
|
if (spaceIndex >= 0) {
|
||||||
|
// Multiple spaces, stop here
|
||||||
|
return spaceIndex + 1
|
||||||
|
} else {
|
||||||
|
spaceIndex = startIndex
|
||||||
|
startIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if this is a prefix character at word boundary
|
||||||
|
else if (
|
||||||
|
prefixes.includes(char) &&
|
||||||
|
(startIndex === 0 || content.charAt(startIndex - 1) === ' ' || content.charAt(startIndex - 1) === '\n')
|
||||||
|
) {
|
||||||
|
// For @ prefix, continue searching backwards to include the entire file path
|
||||||
|
if (char === '@') {
|
||||||
|
foundPrefix = true
|
||||||
|
prefixIndex = startIndex
|
||||||
|
// Return immediately for @ at word boundary
|
||||||
|
return startIndex
|
||||||
|
} else {
|
||||||
|
return startIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if we hit a stop character
|
||||||
|
else if (STOP_CHARACTERS.includes(char)) {
|
||||||
|
if (foundPrefix) {
|
||||||
|
return prefixIndex
|
||||||
|
}
|
||||||
|
return startIndex + 1
|
||||||
|
}
|
||||||
|
// Continue searching backwards
|
||||||
|
else {
|
||||||
|
startIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reached beginning of text
|
||||||
|
if (foundPrefix) {
|
||||||
|
return prefixIndex
|
||||||
|
}
|
||||||
|
return (spaceIndex >= 0 ? spaceIndex : startIndex) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveWordEnd(
|
||||||
|
content: string,
|
||||||
|
cursorPos: number,
|
||||||
|
wordStartPos?: number
|
||||||
|
): number {
|
||||||
|
let endIndex = cursorPos
|
||||||
|
|
||||||
|
// Check if this is a file path (starts with @ and may contain /)
|
||||||
|
let isFilePath = false
|
||||||
|
if (wordStartPos !== undefined && wordStartPos >= 0 && wordStartPos < content.length) {
|
||||||
|
isFilePath = content.charAt(wordStartPos) === '@'
|
||||||
|
}
|
||||||
|
|
||||||
|
while (endIndex < content.length) {
|
||||||
|
const char = content.charAt(endIndex)
|
||||||
|
|
||||||
|
// For file paths starting with @, don't stop at / or .
|
||||||
|
if (isFilePath && (char === '/' || char === '.')) {
|
||||||
|
endIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop at spaces or stop characters
|
||||||
|
if (char === ' ' || STOP_CHARACTERS.includes(char)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiveWord(
|
||||||
|
content: string,
|
||||||
|
selection: Selection,
|
||||||
|
prefixes: string[] = ['@', '/']
|
||||||
|
): ActiveWord | undefined {
|
||||||
|
// Only detect when cursor is at a single point (no text selected)
|
||||||
|
if (selection.start !== selection.end) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't detect if cursor is at the very beginning
|
||||||
|
if (selection.start === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = findActiveWordStart(content, selection, prefixes)
|
||||||
|
const activeWordPart = content.substring(startIndex, selection.end)
|
||||||
|
|
||||||
|
// Check if the active word ends with a space - if so, no active word
|
||||||
|
if (activeWordPart.endsWith(' ')) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the word starts with one of our prefixes
|
||||||
|
if (activeWordPart.length > 0) {
|
||||||
|
const firstChar = activeWordPart.charAt(0)
|
||||||
|
if (prefixes.includes(firstChar)) {
|
||||||
|
// Find where the word ends after the cursor
|
||||||
|
// Pass the start position to help determine if this is a file path
|
||||||
|
const endIndex = findActiveWordEnd(content, selection.end, startIndex)
|
||||||
|
const fullWord = content.substring(startIndex, endIndex)
|
||||||
|
|
||||||
|
// Don't return just the prefix character alone
|
||||||
|
if (activeWordPart.length === 1 && fullWord.length === 1) {
|
||||||
|
return {
|
||||||
|
word: fullWord,
|
||||||
|
activeWord: activeWordPart,
|
||||||
|
offset: startIndex,
|
||||||
|
length: fullWord.length,
|
||||||
|
activeLength: activeWordPart.length,
|
||||||
|
endOffset: endIndex
|
||||||
|
} // Return single prefix to show suggestions immediately
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
word: fullWord,
|
||||||
|
activeWord: activeWordPart,
|
||||||
|
offset: startIndex,
|
||||||
|
length: fullWord.length,
|
||||||
|
activeLength: activeWordPart.length,
|
||||||
|
endOffset: endIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts just the query part without the prefix
|
||||||
|
* @param activeWord The active word including prefix
|
||||||
|
* @returns The query string without prefix
|
||||||
|
*/
|
||||||
|
export function getActiveWordQuery(activeWord: string): string {
|
||||||
|
if (activeWord.length > 1) {
|
||||||
|
return activeWord.substring(1)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user