mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
The settings popup in HappyComposer did not show any option selected when permissionMode or modelMode was null, since destructuring defaults only handle undefined, not null. This fix uses the ?? operator to properly handle both null and undefined cases, falling back to 'default' in both.
473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react'
|
|
import {
|
|
type ChangeEvent as ReactChangeEvent,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type SyntheticEvent as ReactSyntheticEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react'
|
|
import type { AgentState, 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 { usePlatform } from '@/hooks/usePlatform'
|
|
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
|
|
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
|
|
import { StatusBar } from '@/components/AssistantChat/StatusBar'
|
|
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
|
|
|
|
export interface TextInputState {
|
|
text: string
|
|
selection: { start: number; end: number }
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
|
|
const MODEL_MODE_LABELS: Record<string, string> = {
|
|
default: 'Default',
|
|
sonnet: 'Sonnet',
|
|
opus: 'Opus'
|
|
}
|
|
|
|
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
|
|
|
|
export function HappyComposer(props: {
|
|
disabled?: boolean
|
|
permissionMode?: PermissionMode
|
|
modelMode?: ModelMode
|
|
active?: boolean
|
|
thinking?: boolean
|
|
agentState?: AgentState | null
|
|
contextSize?: number
|
|
controlledByUser?: boolean
|
|
onPermissionModeChange?: (mode: PermissionMode) => void
|
|
onModelModeChange?: (mode: ModelMode) => void
|
|
onSwitchToRemote?: () => void
|
|
autocompletePrefixes?: string[]
|
|
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
|
|
}) {
|
|
const {
|
|
disabled = false,
|
|
permissionMode: rawPermissionMode,
|
|
modelMode: rawModelMode,
|
|
active = true,
|
|
thinking = false,
|
|
agentState,
|
|
contextSize,
|
|
controlledByUser = false,
|
|
onPermissionModeChange,
|
|
onModelModeChange,
|
|
onSwitchToRemote,
|
|
autocompletePrefixes = ['@', '/'],
|
|
autocompleteSuggestions = defaultSuggestionHandler
|
|
} = props
|
|
|
|
// Use ?? to handle both null and undefined (destructuring defaults only work for undefined)
|
|
const permissionMode = rawPermissionMode ?? 'default'
|
|
const modelMode = rawModelMode ?? 'default'
|
|
|
|
const api = useAssistantApi()
|
|
const composerText = useAssistantState(({ composer }) => composer.text)
|
|
const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning)
|
|
const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled)
|
|
|
|
const controlsDisabled = disabled || !active || threadIsDisabled
|
|
const trimmed = composerText.trim()
|
|
const hasText = trimmed.length > 0
|
|
const canSend = hasText && !controlsDisabled && !threadIsRunning
|
|
|
|
const [inputState, setInputState] = useState<TextInputState>({
|
|
text: '',
|
|
selection: { start: 0, end: 0 }
|
|
})
|
|
const [showSettings, setShowSettings] = useState(false)
|
|
const [isAborting, setIsAborting] = useState(false)
|
|
const [isSwitching, setIsSwitching] = useState(false)
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
useEffect(() => {
|
|
setInputState((prev) => {
|
|
if (prev.text === composerText) return prev
|
|
return { ...prev, text: composerText }
|
|
})
|
|
}, [composerText])
|
|
|
|
const { haptic: platformHaptic, isTouch } = usePlatform()
|
|
const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes)
|
|
const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions(
|
|
activeWord,
|
|
autocompleteSuggestions,
|
|
{ clampSelection: true, wrapAround: true }
|
|
)
|
|
|
|
const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => {
|
|
if (type === 'light') {
|
|
platformHaptic.impact('light')
|
|
} else if (type === 'success') {
|
|
platformHaptic.notification('success')
|
|
} else {
|
|
platformHaptic.notification('error')
|
|
}
|
|
}, [platformHaptic])
|
|
|
|
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
|
|
)
|
|
|
|
api.composer().setText(result.text)
|
|
setInputState({
|
|
text: result.text,
|
|
selection: { start: result.cursorPosition, end: result.cursorPosition }
|
|
})
|
|
|
|
setTimeout(() => {
|
|
const el = textareaRef.current
|
|
if (!el) return
|
|
el.setSelectionRange(result.cursorPosition, result.cursorPosition)
|
|
try {
|
|
el.focus({ preventScroll: true })
|
|
} catch {
|
|
el.focus()
|
|
}
|
|
}, 0)
|
|
|
|
haptic('light')
|
|
}, [api, suggestions, inputState, autocompletePrefixes, haptic])
|
|
|
|
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
|
|
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
|
|
const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote)
|
|
|
|
useEffect(() => {
|
|
if (!isAborting) return
|
|
if (threadIsRunning) return
|
|
setIsAborting(false)
|
|
}, [isAborting, threadIsRunning])
|
|
|
|
useEffect(() => {
|
|
if (!isSwitching) return
|
|
if (controlledByUser) return
|
|
setIsSwitching(false)
|
|
}, [isSwitching, controlledByUser])
|
|
|
|
const handleAbort = useCallback(() => {
|
|
if (abortDisabled) return
|
|
haptic('error')
|
|
setIsAborting(true)
|
|
api.thread().cancelRun()
|
|
}, [abortDisabled, api, haptic])
|
|
|
|
const handleSwitch = useCallback(async () => {
|
|
if (switchDisabled || !onSwitchToRemote) return
|
|
haptic('light')
|
|
setIsSwitching(true)
|
|
try {
|
|
await onSwitchToRemote()
|
|
} catch {
|
|
setIsSwitching(false)
|
|
}
|
|
}, [switchDisabled, onSwitchToRemote, haptic])
|
|
|
|
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
const key = e.key
|
|
|
|
// Avoid intercepting IME composition keystrokes (Enter, arrows, etc.)
|
|
if (e.nativeEvent.isComposing) {
|
|
return
|
|
}
|
|
|
|
if (suggestions.length > 0) {
|
|
if (key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
moveUp()
|
|
return
|
|
}
|
|
if (key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
moveDown()
|
|
return
|
|
}
|
|
if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) {
|
|
e.preventDefault()
|
|
const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0
|
|
handleSuggestionSelect(indexToSelect)
|
|
return
|
|
}
|
|
if (key === 'Escape') {
|
|
e.preventDefault()
|
|
clearSuggestions()
|
|
return
|
|
}
|
|
}
|
|
|
|
if (key === 'Escape' && threadIsRunning) {
|
|
e.preventDefault()
|
|
handleAbort()
|
|
return
|
|
}
|
|
|
|
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')
|
|
}
|
|
}, [
|
|
suggestions,
|
|
selectedIndex,
|
|
moveUp,
|
|
moveDown,
|
|
clearSuggestions,
|
|
handleSuggestionSelect,
|
|
threadIsRunning,
|
|
handleAbort,
|
|
onPermissionModeChange,
|
|
permissionMode,
|
|
haptic
|
|
])
|
|
|
|
useEffect(() => {
|
|
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
|
|
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])
|
|
|
|
const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => {
|
|
const selection = {
|
|
start: e.target.selectionStart,
|
|
end: e.target.selectionEnd
|
|
}
|
|
setInputState({ text: e.target.value, selection })
|
|
}, [])
|
|
|
|
const handleSelect = useCallback((e: ReactSyntheticEvent<HTMLTextAreaElement>) => {
|
|
const target = e.target as HTMLTextAreaElement
|
|
setInputState(prev => ({
|
|
...prev,
|
|
selection: { start: target.selectionStart, end: target.selectionEnd }
|
|
}))
|
|
}, [])
|
|
|
|
const handleSettingsToggle = useCallback(() => {
|
|
haptic('light')
|
|
setShowSettings(prev => !prev)
|
|
}, [haptic])
|
|
|
|
const handlePermissionChange = useCallback((mode: PermissionMode) => {
|
|
if (!onPermissionModeChange || controlsDisabled) return
|
|
onPermissionModeChange(mode)
|
|
setShowSettings(false)
|
|
haptic('light')
|
|
}, [onPermissionModeChange, controlsDisabled, haptic])
|
|
|
|
const handleModelChange = useCallback((mode: ModelMode) => {
|
|
if (!onModelModeChange || controlsDisabled) return
|
|
onModelModeChange(mode)
|
|
setShowSettings(false)
|
|
haptic('light')
|
|
}, [onModelModeChange, controlsDisabled, haptic])
|
|
|
|
const showPermissionSettings = Boolean(onPermissionModeChange)
|
|
const showModelSettings = Boolean(onModelModeChange)
|
|
const showSettingsButton = Boolean(onPermissionModeChange || onModelModeChange)
|
|
const showAbortButton = true
|
|
|
|
const overlays = useMemo(() => {
|
|
if (showSettings && (showPermissionSettings || showModelSettings)) {
|
|
return (
|
|
<div className="absolute bottom-[100%] mb-2 w-full">
|
|
<FloatingOverlay maxHeight={320}>
|
|
{showPermissionSettings ? (
|
|
<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()}
|
|
>
|
|
<div
|
|
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>
|
|
) : null}
|
|
|
|
{showPermissionSettings && showModelSettings ? (
|
|
<div className="mx-3 h-px bg-[var(--app-divider)]" />
|
|
) : null}
|
|
|
|
{showModelSettings ? (
|
|
<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>
|
|
) : null}
|
|
</FloatingOverlay>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (suggestions.length > 0) {
|
|
return (
|
|
<div className="absolute bottom-[100%] mb-2 w-full">
|
|
<FloatingOverlay>
|
|
<Autocomplete
|
|
suggestions={suggestions}
|
|
selectedIndex={selectedIndex}
|
|
onSelect={(index) => handleSuggestionSelect(index)}
|
|
/>
|
|
</FloatingOverlay>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return null
|
|
}, [
|
|
showSettings,
|
|
showPermissionSettings,
|
|
showModelSettings,
|
|
suggestions,
|
|
selectedIndex,
|
|
controlsDisabled,
|
|
permissionMode,
|
|
modelMode,
|
|
handlePermissionChange,
|
|
handleModelChange,
|
|
handleSuggestionSelect
|
|
])
|
|
|
|
return (
|
|
<div className="px-3 pb-3 pt-2 bg-[var(--app-bg)]">
|
|
<div className="mx-auto w-full max-w-[720px]">
|
|
<ComposerPrimitive.Root className="relative">
|
|
{overlays}
|
|
|
|
<StatusBar
|
|
active={active}
|
|
thinking={thinking}
|
|
agentState={agentState}
|
|
contextSize={contextSize}
|
|
modelMode={modelMode}
|
|
permissionMode={permissionMode}
|
|
/>
|
|
|
|
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
|
|
<div className="flex items-center px-4 py-3">
|
|
<ComposerPrimitive.Input
|
|
ref={textareaRef}
|
|
autoFocus={!controlsDisabled && !isTouch}
|
|
placeholder="Type a message..."
|
|
disabled={controlsDisabled}
|
|
maxRows={5}
|
|
submitOnEnter
|
|
cancelOnEscape={false}
|
|
onChange={handleChange}
|
|
onSelect={handleSelect}
|
|
onKeyDown={handleKeyDown}
|
|
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>
|
|
|
|
<ComposerButtons
|
|
canSend={canSend}
|
|
controlsDisabled={controlsDisabled}
|
|
showSettingsButton={showSettingsButton}
|
|
onSettingsToggle={handleSettingsToggle}
|
|
showAbortButton={showAbortButton}
|
|
abortDisabled={abortDisabled}
|
|
isAborting={isAborting}
|
|
onAbort={handleAbort}
|
|
showSwitchButton={showSwitchButton}
|
|
switchDisabled={switchDisabled}
|
|
isSwitching={isSwitching}
|
|
onSwitch={handleSwitch}
|
|
/>
|
|
</div>
|
|
</ComposerPrimitive.Root>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|