Files
hapi/web/src/components/AssistantChat/HappyComposer.tsx
T
weishu f16ccb2759 fix(web): handle null values in permissionMode and modelMode using nullish coalescing
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.
2025-12-22 12:25:01 +08:00

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>
)
}