Files
hapi/web/src/components/AssistantChat/HappyComposer.tsx
T

1456 lines
69 KiB
TypeScript

import { getCodexCollaborationModeOptions, getPermissionModeOptionsForFlavor } from '@hapi/protocol'
import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react'
import {
type ChangeEvent as ReactChangeEvent,
type ClipboardEvent as ReactClipboardEvent,
type FormEvent as ReactFormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type SyntheticEvent as ReactSyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { isRichComposerMentionsEnabled } from '@/lib/composerSegments'
import type { SessionMentionResolveResult } from '@/components/AssistantChat/RichComposerInput'
import {
RichComposerInput,
type RichComposerInputHandle,
} from '@/components/AssistantChat/RichComposerInput'
import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary, ThreadGoal } from '@/types/api'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import type { ConversationStatus } from '@/realtime/types'
import { useActiveWord } from '@/hooks/useActiveWord'
import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion'
import { usePlatform } from '@/hooks/usePlatform'
import { usePWAInstall } from '@/hooks/usePWAInstall'
import { supportsEffort, supportsModelChange, PI_THINKING_LEVEL_LABELS } from '@hapi/protocol'
import type { PiThinkingLevel } from '@hapi/protocol'
import { markSkillUsed } from '@/lib/recent-skills'
import { useComposerDraft } from '@/hooks/useComposerDraft'
import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
import { StatusBar } from '@/components/AssistantChat/StatusBar'
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem'
import { useTranslation } from '@/lib/use-translation'
import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions'
import { getClaudeComposerEffortOptions } from './claudeEffortOptions'
import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions'
import { getDisplayedCodexServiceTier } from './codexFastMode'
import { getPiThinkingLevelOptions, getHighestThinkingLevel, isThinkingLevelSupported } from './piThinkingLevelOptions'
import { groupModelsByProvider } from './piModelGroups'
import { PiModelPanel } from './PiModelPanel'
import { PiThinkingLevelPanel } from './PiThinkingLevelPanel'
export interface TextInputState {
text: string
selection: { start: number; end: number }
}
/**
* One rejected send. `id` is bumped per failure so two failures with the
* same `text` still trigger a fresh restore (the dedupe key is the id, not
* the text).
*
* - `text` is the original input that should be put back into the composer.
* - `message` is the user-facing error string we render inline.
* - `scheduledAt` is the absolute epoch-ms the rejected send was bound for,
* or null for an immediate send. When non-null, the composer also
* restores the schedule via `onSchedule` so the operator can edit and
* retry without silently downgrading a scheduled send to immediate.
* - `action` is an optional recovery affordance rendered as a button next
* to the message. Used by the inactive-session branch (#918) to expose
* a one-click Reopen. Other failure modes (5xx, network, generic 4xx)
* leave this null and only render the message.
*
* Owned by the route component (`router.tsx`); the composer is a pure
* consumer that:
* 1. restores the text once per `id` via `api.composer().setText`,
* 2. restores the schedule (if any) via `onSchedule`, and
* 3. shows a red ring + inline message until the user types or sends.
*/
export type ComposerSendError = {
id: number
text: string
message: string
scheduledAt: number | null
action?: {
label: string
onClick: () => void
pending?: boolean
} | null
}
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
export function ModelEffortSettingsSection(props: {
agentFlavor?: string | null
options: Array<{ value: string; label: string }>
selectedValue: string | null | undefined
controlsDisabled: boolean
onChange: (value: string) => void
}) {
const { t } = useTranslation()
const { agentFlavor, options, selectedValue, controlsDisabled, onChange } = props
return (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')}
</div>
{options.map((option) => {
const isSelected = selectedValue === option.value
return (
<button
key={option.value}
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={() => onChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
isSelected
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={isSelected ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
)
})}
</div>
)
}
export function HappyComposer(props: {
sessionId?: string
disabled?: boolean
permissionMode?: PermissionMode
collaborationMode?: CodexCollaborationMode
threadGoal?: ThreadGoal | null
model?: string | null
modelReasoningEffort?: string | null
effort?: string | null
active?: boolean
allowSendWhenInactive?: boolean
thinking?: boolean
agentState?: AgentState | null
backgroundTaskCount?: number
contextSize?: number
contextCacheRead?: number
contextWindow?: number | null
controlledByUser?: boolean
agentFlavor?: string | null
availableModelOptions?: Array<{ value: string | null; label: string }>
/** Full Pi model data with thinkingLevelMap for provider grouping + thinking level filtering */
piModels?: PiModelSummary[]
/** Pi: provider-qualified selected model from metadata (survives reload;
* disambiguates when two providers share a modelId). */
piSelectedModel?: { provider: string; modelId: string } | null
availableModelReasoningEffortOptions?: Array<{ value: string; name?: string }>
availableEffortOptions?: Array<{ value: string; name?: string }>
/** Cursor: selected base model key (not wire id). */
selectedModelBase?: string | null
/** Cursor: selected variant sku/wire for highlight when session stores an ACP wire id. */
selectedModelVariant?: string | null
/** Cursor: effort/variant wire ids for the selected base model. */
modelEffortOptions?: Array<{ value: string; label: string }>
onCollaborationModeChange?: (mode: CodexCollaborationMode) => void
onPermissionModeChange?: (mode: PermissionMode) => void
onModelChange?: (model: { provider: string; modelId: string } | string | null) => void
/** Cursor: effort/variant wire id (separate from base model change). */
onModelEffortChange?: (wireId: string | null) => void
onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void
onEffortChange?: (effort: string | null) => void
/** Codex Fast mode (service tier): current value ('fast' or null/standard). */
serviceTier?: string | null
/** When provided, a Fast-mode toggle renders (Codex GPT-5.5 / GPT-5.4 only). */
onServiceTierChange?: (serviceTier: string | null) => void
onSwitchToRemote?: () => void
onTerminal?: () => void
terminalUnsupported?: boolean
autocompletePrefixes?: string[]
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
// Voice assistant props
voiceStatus?: ConversationStatus
voiceMicMuted?: boolean
onVoiceToggle?: () => void
onVoiceMicToggle?: () => void
// Schedule props (lifted from internal state when provided)
pendingSchedule?: PendingSchedule | null
onSchedule?: (pending: PendingSchedule) => void
onClearSchedule?: () => void
// Scratchlist drawer props - SessionChat owns the state. Threaded
// straight through to ComposerButtons. When undefined, the toggle
// button doesn't render (back-compat for any other consumer).
scratchlistMode?: boolean
scratchlistCount?: number
onScratchlistToggle?: () => void
// Set when the most recent send failed (4xx/5xx/network). The composer
// restores the original text once per `sendError.id` and renders an
// inline error affordance until the user dismisses or starts editing.
sendError?: ComposerSendError | null
onClearSendError?: () => void
/** Chip hover / aria-label resolver (SessionChat → useSessions). */
resolveSessionMentionTooltip?: (id: string, title: string) => SessionMentionResolveResult
}) {
const { t } = useTranslation()
const {
sessionId,
disabled = false,
permissionMode: rawPermissionMode,
collaborationMode: rawCollaborationMode,
threadGoal,
model: rawModel,
modelReasoningEffort: rawModelReasoningEffort,
effort: rawEffort,
active = true,
allowSendWhenInactive = false,
thinking = false,
agentState,
backgroundTaskCount,
contextSize,
contextCacheRead,
contextWindow,
controlledByUser = false,
agentFlavor,
availableModelOptions,
piModels,
piSelectedModel,
availableModelReasoningEffortOptions,
availableEffortOptions,
selectedModelBase,
selectedModelVariant,
modelEffortOptions,
onCollaborationModeChange,
onPermissionModeChange,
onModelChange,
onModelEffortChange,
onModelReasoningEffortChange,
onEffortChange,
serviceTier: rawServiceTier,
onServiceTierChange,
onSwitchToRemote,
onTerminal,
terminalUnsupported = false,
autocompletePrefixes = ['@', '/', '$'],
autocompleteSuggestions = defaultSuggestionHandler,
voiceStatus = 'disconnected',
voiceMicMuted = false,
onVoiceToggle,
onVoiceMicToggle,
pendingSchedule: pendingScheduleProp,
onSchedule: onScheduleProp,
onClearSchedule: onClearScheduleProp,
sendError = null,
onClearSendError,
resolveSessionMentionTooltip,
} = props
// Use ?? so missing values fall back to default (destructuring defaults only handle undefined)
const permissionMode = rawPermissionMode ?? 'default'
const collaborationMode = rawCollaborationMode ?? 'default'
const model = rawModel ?? null
const modelReasoningEffort = rawModelReasoningEffort ?? null
const effort = rawEffort ?? null
const serviceTier = rawServiceTier ?? null
const displayedServiceTier = getDisplayedCodexServiceTier(serviceTier)
const api = useAssistantApi()
const { composerEnterBehavior } = useComposerEnterBehavior()
const composerText = useAssistantState(({ composer }) => composer.text)
const attachments = useAssistantState(({ composer }) => composer.attachments)
const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning)
const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled)
const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled
const trimmed = composerText.trim()
const hasText = trimmed.length > 0
const hasAttachments = attachments.length > 0
const attachmentsReady = !hasAttachments || attachments.every((attachment) => {
if (attachment.status.type === 'complete') {
return true
}
if (attachment.status.type !== 'requires-action') {
return false
}
const path = (attachment as { path?: string }).path
return typeof path === 'string' && path.length > 0
})
const canSend = (hasText || hasAttachments) && attachmentsReady && !controlsDisabled
const [inputState, setInputState] = useState<TextInputState>({
text: '',
selection: { start: 0, end: 0 }
})
const [showSettings, setShowSettings] = useState(false)
const [showPiModelPanel, setShowPiModelPanel] = useState(false)
const [showPiThinkingPanel, setShowPiThinkingPanel] = useState(false)
const [isAborting, setIsAborting] = useState(false)
const [isSwitching, setIsSwitching] = useState(false)
const [showContinueHint, setShowContinueHint] = useState(false)
// pendingSchedule is controlled externally when onSchedule prop is provided; otherwise local state
const [pendingScheduleLocal, setPendingScheduleLocal] = useState<PendingSchedule | null>(null)
const isControlled = onScheduleProp !== undefined
const pendingSchedule = isControlled ? (pendingScheduleProp ?? null) : pendingScheduleLocal
const setPendingSchedule = isControlled ? onScheduleProp : setPendingScheduleLocal
const textareaRef = useRef<HTMLTextAreaElement>(null)
const richInputRef = useRef<RichComposerInputHandle>(null)
// Kill-switch only (?richMentions=0 / localStorage=0 / VITE=false). Mount-time
// read — hard reload required, so no per-keystroke localStorage/URL parse.
const [richMentionsEnabled] = useState(() => isRichComposerMentionsEnabled())
const prevControlledByUser = useRef(controlledByUser)
const attachmentDrafts = attachments.flatMap((attachment) => {
if (!attachment.file) return []
const upload = attachment as typeof attachment & { path?: string; previewUrl?: string }
return [{
id: attachment.id,
file: attachment.file,
path: upload.path,
previewUrl: upload.previewUrl,
}]
})
useComposerDraft(
sessionId,
composerText,
attachmentDrafts,
active,
(text) => api.composer().setText(text),
(file) => api.composer().addAttachment(file),
)
// assistant-ui clears `composer.text` synchronously the moment a send is
// invoked AND `SessionChat.handleSend` clears `pendingSchedule` the
// moment the mutation is accepted, so by the time the mutation's
// onError fires both the typed text and the schedule are gone. When
// the route hands us a `sendError`, splice both back in -- once per
// `sendError.id` so a second failure with the same text still triggers
// a fresh restore.
const restoredErrorIdRef = useRef<number | null>(null)
useEffect(() => {
if (!sendError) {
return
}
if (restoredErrorIdRef.current === sendError.id) {
return
}
restoredErrorIdRef.current = sendError.id
// Only restore when the composer is empty. If the user has already
// typed something new (rare -- composer is `disabled` during send,
// but possible if isSending toggles before this effect runs), we
// would otherwise stomp on their fresh input.
if (composerText.length === 0 && sendError.text.length > 0) {
api.composer().setText(sendError.text)
}
// Restore the pending schedule too. `scheduledAt` was already
// resolved to an absolute epoch-ms before the failed send (presets
// are computed at send time -- see `resolvePendingSchedule`), so
// we feed it back as an 'absolute' PendingSchedule. The existing
// shouldAutoClearPendingSchedule effect in SessionChat handles the
// case where the absolute time has passed by the time we restore.
if (sendError.scheduledAt !== null && onScheduleProp) {
onScheduleProp({ type: 'absolute', ms: sendError.scheduledAt })
}
}, [sendError, api, composerText, onScheduleProp])
useEffect(() => {
if (richMentionsEnabled) {
// Rich input owns mirror text + selection via onMirrorChange.
return
}
setInputState((prev) => {
if (prev.text === composerText) return prev
// When syncing from composerText, update selection to end of text
// This ensures activeWord detection works correctly
const newPos = composerText.length
return { text: composerText, selection: { start: newPos, end: newPos } }
})
}, [composerText, richMentionsEnabled])
// Track one-time "continue" hint after switching from local to remote.
useEffect(() => {
if (prevControlledByUser.current === true && controlledByUser === false) {
setShowContinueHint(true)
}
if (controlledByUser) {
setShowContinueHint(false)
}
prevControlledByUser.current = controlledByUser
}, [controlledByUser])
const { haptic: platformHaptic, isTouch } = usePlatform()
const { isStandalone, isIOS } = usePWAInstall()
const isIOSPWA = isIOS && isStandalone
const bottomPaddingClass = isIOSPWA ? 'pb-0' : 'pb-3'
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) return
if (suggestion.text.startsWith('$')) {
markSkillUsed(suggestion.text.slice(1))
}
if (richMentionsEnabled && richInputRef.current) {
// insert*/apply* emit mirror state via onMirrorChange (keep inputState in mirror space).
if (suggestion.sessionMention) {
richInputRef.current.insertSessionMention(
suggestion.sessionMention,
autocompletePrefixes
)
} else {
richInputRef.current.applyPlainSuggestion(
suggestion.text,
autocompletePrefixes
)
}
setTimeout(() => {
richInputRef.current?.focus()
}, 0)
haptic('light')
return
}
if (!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, richMentionsEnabled])
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote)
const showTerminalButton = Boolean(onTerminal || terminalUnsupported)
const terminalDisabled = controlsDisabled || terminalUnsupported
const terminalLabel = terminalUnsupported ? t('terminal.unsupportedWindows') : t('composer.terminal')
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 permissionModeOptions = useMemo(
() => getPermissionModeOptionsForFlavor(agentFlavor),
[agentFlavor]
)
const collaborationModeOptions = useMemo(
() => agentFlavor === 'codex' ? getCodexCollaborationModeOptions() : [],
[agentFlavor]
)
const modelOptions = useMemo(
() => getModelOptionsForFlavor(agentFlavor, model, availableModelOptions),
[agentFlavor, model, availableModelOptions]
)
const codexReasoningEffortOptions = useMemo(
() => agentFlavor === 'codex' || agentFlavor === 'opencode'
? getCodexComposerReasoningEffortOptions(
modelReasoningEffort,
agentFlavor,
availableModelReasoningEffortOptions
)
: [],
[agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions]
)
// Pi: group models by provider for hierarchical display
const piModelGroups = useMemo(
() => piModels && piModels.length > 0 ? groupModelsByProvider(piModels) : null,
[piModels]
)
// Pi: find the currently selected model's thinkingLevelMap for effort filtering.
// Prefer provider-qualified match (metadata.piSelectedModel) when available —
// two providers may share a modelId, and a modelId-only match would pick the
// wrong one, sending the wrong provider on the next model/effort change.
const selectedPiModel = useMemo(
() => piSelectedModel
? piModels?.find((m) => m.provider === piSelectedModel.provider && m.modelId === piSelectedModel.modelId)
: piModels?.find((m) => m.modelId === model),
[piModels, piSelectedModel, model]
)
// Pi: reset effort to highest supported level when model changes and current level is unsupported
useEffect(() => {
if (!effort || !selectedPiModel || !onEffortChange) return
// Non-reasoning model: clear stale effort so the hub does not forward
// a set_thinking_level the user can no longer see or change.
if (selectedPiModel.reasoning === false) {
onEffortChange(null)
return
}
if (!isThinkingLevelSupported(effort, selectedPiModel.thinkingLevelMap)) {
onEffortChange(getHighestThinkingLevel(selectedPiModel.thinkingLevelMap))
}
}, [selectedPiModel, effort, onEffortChange])
const claudeEffortOptions = useMemo(
() => agentFlavor === 'pi'
? getPiThinkingLevelOptions(effort, selectedPiModel?.thinkingLevelMap)
: agentFlavor === 'grok' && availableEffortOptions && availableEffortOptions.length > 0
? [
{ value: null, label: 'Default' },
...availableEffortOptions.map((option) => ({
value: option.value,
label: option.name ?? option.value
}))
]
: getClaudeComposerEffortOptions(effort),
[agentFlavor, effort, selectedPiModel, availableEffortOptions]
)
const permissionModes = useMemo(
() => permissionModeOptions.map((option) => option.mode),
[permissionModeOptions]
)
/** Flush rich chips → `[title](/sessions/<id>)` into composer.text, then send. */
const flushAndSend = useCallback(() => {
if (richMentionsEnabled && richInputRef.current) {
richInputRef.current.flushSerializedText()
}
api.composer().send()
}, [api, richMentionsEnabled])
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
const key = e.key
// Avoid intercepting IME composition keystrokes (Enter, arrows, etc.)
if (e.nativeEvent.isComposing) {
return
}
// Shift+Enter inserts a newline (textarea default; rich path inserts <br>).
if (key === 'Enter' && e.shiftKey) {
return
}
// Enter with suggestions visible: select the suggestion
if (key === 'Enter' && suggestions.length > 0) {
e.preventDefault()
const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0
handleSuggestionSelect(indexToSelect)
return
}
// Only plain Enter (no modifiers) sends; other modifier combos are ignored
if (key === 'Enter') {
if (composerEnterBehavior === 'newline') {
if ((e.ctrlKey || e.metaKey) && !e.altKey && canSend) {
e.preventDefault()
flushAndSend()
setShowContinueHint(false)
}
return
}
e.preventDefault()
if (!e.ctrlKey && !e.altKey && !e.metaKey && canSend) {
flushAndSend()
setShowContinueHint(false)
}
return
}
if (suggestions.length > 0) {
if (key === 'ArrowUp') {
e.preventDefault()
moveUp()
return
}
if (key === 'ArrowDown') {
e.preventDefault()
moveDown()
return
}
if ((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 && permissionModes.length > 0) {
e.preventDefault()
const currentIndex = permissionModes.indexOf(permissionMode)
const nextIndex = (currentIndex + 1) % permissionModes.length
const nextMode = permissionModes[nextIndex] ?? 'default'
onPermissionModeChange(nextMode)
haptic('light')
}
}, [
suggestions,
selectedIndex,
moveUp,
moveDown,
clearSuggestions,
handleSuggestionSelect,
threadIsRunning,
handleAbort,
onPermissionModeChange,
permissionMode,
permissionModes,
canSend,
api,
haptic,
composerEnterBehavior,
richMentionsEnabled,
flushAndSend,
])
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
// Pi needs { provider, modelId } to disambiguate duplicate model IDs,
// but this generic cycler only emits a bare modelId (or null), which
// would lose the provider and can pick the wrong cached match or clear
// the model. Pi model changes go only through the dedicated PiModelPanel.
if (agentFlavor === 'pi') return
if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && supportsModelChange(agentFlavor)) {
e.preventDefault()
onModelChange(getNextModelForFlavor(agentFlavor, model, availableModelOptions))
haptic('light')
}
}
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [model, onModelChange, haptic, agentFlavor, availableModelOptions])
const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => {
const selection = {
start: e.target.selectionStart,
end: e.target.selectionEnd
}
setInputState({ text: e.target.value, selection })
// Editing the restored text is the operator's "I'm handling it"
// signal -- drop the inline error so the affordance doesn't shout
// at them while they fix the message.
if (sendError && onClearSendError) {
onClearSendError()
}
}, [sendError, onClearSendError])
const handleSelect = useCallback((e: ReactSyntheticEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
setInputState(prev => ({
...prev,
selection: { start: target.selectionStart, end: target.selectionEnd }
}))
}, [])
const handlePaste = useCallback(async (e: ReactClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
const files = Array.from(e.clipboardData?.files || [])
const imageFiles = files.filter(file => file.type.startsWith('image/'))
if (imageFiles.length === 0) return
// The backend rejects scheduledAt + attachments (per-CLI upload dir is
// torn down before a mature emit could read the files). The button-based
// attachment flow is disabled by ComposerButtons.hasAttachments, but the
// paste path bypasses that — guard here so a pasted image while a
// schedule is active cannot produce a submission the hub will reject.
if (pendingSchedule != null) {
e.preventDefault()
return
}
e.preventDefault()
try {
for (const file of imageFiles) {
await api.composer().addAttachment(file)
}
} catch (error) {
console.error('Error adding pasted image:', error)
}
}, [api, pendingSchedule])
const handleSettingsToggle = useCallback(() => {
haptic('light')
setShowSettings(prev => !prev)
}, [haptic])
const handleSubmit = useCallback((event?: ReactFormEvent<HTMLFormElement>) => {
event?.preventDefault()
if (!attachmentsReady) {
return
}
setShowContinueHint(false)
}, [attachmentsReady])
const handlePermissionChange = useCallback((mode: PermissionMode) => {
if (!onPermissionModeChange || controlsDisabled) return
onPermissionModeChange(mode)
setShowSettings(false)
haptic('light')
}, [onPermissionModeChange, controlsDisabled, haptic])
const handleCollaborationChange = useCallback((mode: CodexCollaborationMode) => {
if (!onCollaborationModeChange || controlsDisabled) return
onCollaborationModeChange(mode)
setShowSettings(false)
haptic('light')
}, [onCollaborationModeChange, controlsDisabled, haptic])
const handleModelChange = useCallback((nextModel: { provider: string; modelId: string } | string | null) => {
if (!onModelChange || controlsDisabled) return
onModelChange(nextModel)
setShowSettings(false)
haptic('light')
}, [onModelChange, controlsDisabled, haptic])
const handleModelEffortChange = useCallback((nextWireId: string | null) => {
const handler = onModelEffortChange ?? onModelChange
if (!handler || controlsDisabled) return
handler(nextWireId)
setShowSettings(false)
haptic('light')
}, [onModelEffortChange, onModelChange, controlsDisabled, haptic])
const handleModelReasoningEffortChange = useCallback((nextModelReasoningEffort: string | null) => {
if (!onModelReasoningEffortChange || controlsDisabled) return
onModelReasoningEffortChange(nextModelReasoningEffort)
setShowSettings(false)
haptic('light')
}, [onModelReasoningEffortChange, controlsDisabled, haptic])
const handleEffortChange = useCallback((nextEffort: string | null) => {
if (!onEffortChange || controlsDisabled) return
onEffortChange(nextEffort)
setShowSettings(false)
haptic('light')
}, [onEffortChange, controlsDisabled, haptic])
const handleServiceTierChange = useCallback((nextServiceTier: string | null) => {
if (!onServiceTierChange || controlsDisabled) return
onServiceTierChange(nextServiceTier)
setShowSettings(false)
haptic('light')
}, [onServiceTierChange, controlsDisabled, haptic])
// 'standard' (not null) is the explicit Fast-off choice so it persists
// distinctly from an untouched/account-default session.
const fastModeOptions: Array<{ value: string; label: string }> = useMemo(() => [
{ value: 'standard', label: t('misc.fastModeStandard') },
{ value: 'fast', label: t('misc.fastModeFast') }
], [t])
const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0)
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0)
const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && (piModels && piModels.length > 0 || modelOptions.length > 0))
const showModelEffortSettings = Boolean(
(onModelEffortChange ?? onModelChange)
&& modelEffortOptions
&& modelEffortOptions.length > 0
)
const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0)
// For Pi: hide effort when selected model explicitly has reasoning: false
const piEffortHidden = piModels && selectedPiModel && selectedPiModel.reasoning === false
const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor) && !piEffortHidden)
const showFastModeSettings = Boolean(onServiceTierChange)
const showSettingsButton = Boolean(
showCollaborationSettings
|| showPermissionSettings
|| showModelSettings
|| showModelEffortSettings
|| showModelReasoningEffortSettings
|| showEffortSettings
|| showFastModeSettings
)
const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle)
const handleSend = useCallback(() => {
flushAndSend()
// SessionChat owns clearing the schedule — it clears only after awaiting
// the send hook's accepted result, which covers both pre-mutation guards
// and async inactive-session resume failure. Clearing here unconditionally
// would race ahead of that check and drop the user's schedule on every
// rejected send path.
//
// The inline send-error affordance is intentionally NOT cleared here:
// the route-level state (`onSuccess`/`onError` in router.tsx) replaces
// or clears it based on the actual mutation result, so the user keeps
// the error context while the new attempt is in flight.
}, [flushAndSend])
// Pi: selected model info for UI labels and thinking level filtering
const piModelLabel = agentFlavor === 'pi'
? (selectedPiModel?.name ?? selectedPiModel?.modelId ?? 'Model')
: undefined
const piThinkingLabel = agentFlavor === 'pi'
? (() => {
if (!selectedPiModel) return 'Thinking'
const effectiveLevel = effort && isThinkingLevelSupported(effort, selectedPiModel.thinkingLevelMap)
? effort
: getHighestThinkingLevel(selectedPiModel.thinkingLevelMap)
return effectiveLevel
? (PI_THINKING_LEVEL_LABELS[effectiveLevel as PiThinkingLevel] ?? effectiveLevel)
: 'Thinking'
})()
: undefined
const piHasModels = piModels && piModels.length > 0
const closeAllPanels = useCallback(() => {
setShowSettings(false)
setShowPiModelPanel(false)
setShowPiThinkingPanel(false)
}, [])
const handlePiModelToggle = useCallback(() => {
if (controlsDisabled) return
setShowPiModelPanel((v) => !v)
setShowSettings(false)
setShowPiThinkingPanel(false)
haptic('light')
}, [controlsDisabled, haptic])
const handlePiThinkingToggle = useCallback(() => {
if (controlsDisabled) return
setShowPiThinkingPanel((v) => !v)
setShowSettings(false)
setShowPiModelPanel(false)
haptic('light')
}, [controlsDisabled, haptic])
const overlays = useMemo(() => {
// Pi flavor: separate floating panels for model and thinking level.
// (Pi RPC mode has no runtime permission switching → no permission panel.)
if (agentFlavor === 'pi') {
const panels: React.ReactNode[] = []
// Model selection panel
if (showPiModelPanel && piModels && piModels.length > 0) {
const currentPiModel = selectedPiModel ?? null
panels.push(
<div key="model" className="absolute bottom-[100%] mb-2 left-2 w-64">
<PiModelPanel
models={piModels}
currentModel={currentPiModel ? { provider: currentPiModel.provider, modelId: currentPiModel.modelId } : null}
controlsDisabled={controlsDisabled}
onSelect={(piModel) => {
handleModelChange({ provider: piModel.provider, modelId: piModel.modelId })
}}
onClose={closeAllPanels}
/>
</div>
)
}
// Thinking level panel
if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) {
panels.push(
<div key="thinking" className="absolute bottom-[100%] mb-2 left-2 w-48">
<PiThinkingLevelPanel
currentLevel={effort}
reasoning={selectedPiModel?.reasoning}
thinkingLevelMap={selectedPiModel?.thinkingLevelMap}
controlsDisabled={controlsDisabled}
onSelect={(level) => handleEffortChange(level)}
onClose={closeAllPanels}
/>
</div>
)
}
if (panels.length > 0) return <>{panels}</>
}
// Non-Pi flavors: original unified gear menu
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) {
return (
<div className="absolute bottom-[100%] mb-2 w-full">
<FloatingOverlay maxHeight={320}>
{showCollaborationSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.collaborationMode')}
</div>
{collaborationModeOptions.map((option) => (
<button
key={option.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={() => handleCollaborationChange(option.mode)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
collaborationMode === option.mode
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{collaborationMode === option.mode && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={collaborationMode === option.mode ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
))}
</div>
) : null}
{showCollaborationSettings && (showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showPermissionSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.permissionMode')}
</div>
{permissionModeOptions.map((option) => (
<button
key={option.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(option.mode)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
permissionMode === option.mode
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{permissionMode === option.mode && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={permissionMode === option.mode ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
))}
</div>
) : null}
{(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
<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)]">
{t('misc.model')}
</div>
{piModelGroups ? (
piModelGroups.map((group) => (
<div key={group.provider}>
<div className="px-3 pt-2 pb-0.5 text-xs font-medium text-[var(--app-hint)]">
{group.label}
</div>
{group.models.map((piModel) => (
<button
key={piModel.modelId}
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({ provider: piModel.provider, modelId: piModel.modelId })}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
model === piModel.modelId
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{model === piModel.modelId && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={model === piModel.modelId ? 'text-[var(--app-link)]' : ''}>
{piModel.name ?? piModel.modelId}
</span>
</button>
))}
</div>
))
) : (
modelOptions.map((option) => {
const isSelected = selectedModelBase !== undefined
? selectedModelBase === option.value
: model === option.value
return (
<button
key={option.value ?? 'auto'}
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(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
isSelected
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={isSelected ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
)
})
)}
</div>
) : null}
{showModelSettings && showModelEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showModelEffortSettings ? (
<ModelEffortSettingsSection
agentFlavor={agentFlavor}
options={modelEffortOptions!}
selectedValue={selectedModelVariant ?? model}
controlsDisabled={controlsDisabled}
onChange={handleModelEffortChange}
/>
) : null}
{(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showModelReasoningEffortSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.reasoningEffort')}
</div>
{codexReasoningEffortOptions.map((option) => (
<button
key={option.value ?? 'default'}
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={() => handleModelReasoningEffortChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
modelReasoningEffort === option.value
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{modelReasoningEffort === option.value && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={modelReasoningEffort === option.value ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
))}
</div>
) : null}
{showModelReasoningEffortSettings && showEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showEffortSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.effort')}
</div>
{claudeEffortOptions.map((option) => (
<button
key={option.value ?? 'auto'}
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={() => handleEffortChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
effort === option.value
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{effort === option.value && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={effort === option.value ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
))}
</div>
) : null}
{(showModelReasoningEffortSettings || showEffortSettings) && showFastModeSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showFastModeSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.fastMode')}
</div>
{fastModeOptions.map((option) => (
<button
key={option.value ?? 'standard'}
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={() => handleServiceTierChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
displayedServiceTier === option.value
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{displayedServiceTier === option.value && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={displayedServiceTier === option.value ? 'text-[var(--app-link)]' : ''}>
{option.label}
</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,
showPiModelPanel,
showPiThinkingPanel,
agentFlavor,
piModels,
selectedPiModel,
closeAllPanels,
showCollaborationSettings,
showPermissionSettings,
showModelSettings,
showModelEffortSettings,
modelEffortOptions,
selectedModelBase,
selectedModelVariant,
showModelReasoningEffortSettings,
showEffortSettings,
showFastModeSettings,
modelOptions,
codexReasoningEffortOptions,
claudeEffortOptions,
fastModeOptions,
suggestions,
selectedIndex,
controlsDisabled,
collaborationMode,
permissionMode,
model,
modelReasoningEffort,
effort,
displayedServiceTier,
collaborationModeOptions,
permissionModeOptions,
handleCollaborationChange,
handlePermissionChange,
handleModelChange,
handleModelReasoningEffortChange,
handleEffortChange,
handleServiceTierChange,
handleSuggestionSelect,
t
])
return (
<div className={`px-3 ${bottomPaddingClass} pt-2 bg-[var(--app-bg)]`}>
<div className="mx-auto w-full max-w-content">
<ComposerPrimitive.Root className="relative" onSubmit={handleSubmit}>
{overlays}
<StatusBar
active={active}
thinking={thinking}
agentState={agentState}
backgroundTaskCount={backgroundTaskCount}
contextSize={contextSize}
contextCacheRead={contextCacheRead}
contextWindow={contextWindow}
model={model}
modelReasoningEffort={modelReasoningEffort}
serviceTier={serviceTier}
permissionMode={permissionMode}
collaborationMode={collaborationMode}
threadGoal={threadGoal}
agentFlavor={agentFlavor}
voiceStatus={voiceStatus}
/>
{sendError ? (
<div
role="alert"
data-testid="composer-send-error"
className="mb-2 flex items-center justify-between gap-3 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-sm text-red-600"
>
<span className="flex-1">{sendError.message}</span>
{sendError.action ? (
<button
type="button"
data-testid="composer-send-error-action"
onClick={sendError.action.onClick}
disabled={sendError.action.pending}
className="shrink-0 rounded-md border border-red-300 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{sendError.action.label}
</button>
) : null}
</div>
) : null}
<div
className={`overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)] ${
sendError ? 'ring-1 ring-red-500' : ''
}`}
>
{attachments.length > 0 ? (
<div className="flex flex-wrap gap-2 px-4 pt-3">
<ComposerPrimitive.Attachments components={{ Attachment: AttachmentItem }} />
</div>
) : null}
<div className="flex items-center px-4 py-3">
{richMentionsEnabled ? (
<RichComposerInput
ref={richInputRef}
value={composerText}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
onValueChange={(text) => api.composer().setText(text)}
onMirrorChange={(state) => setInputState(state)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
onEdit={() => {
if (sendError && onClearSendError) onClearSendError()
}}
className="max-h-[7.5rem] min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none"
/>
) : (
<ComposerPrimitive.Input
ref={textareaRef}
autoFocus={!controlsDisabled && !isTouch}
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
maxRows={5}
submitOnEnter={false}
cancelOnEscape={false}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="flex-1 resize-none bg-transparent text-base 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}
showTerminalButton={showTerminalButton}
terminalDisabled={terminalDisabled}
terminalLabel={terminalLabel}
onTerminal={onTerminal ?? (() => {})}
showAbortButton={showAbortButton}
abortDisabled={abortDisabled}
isAborting={isAborting}
onAbort={handleAbort}
showSwitchButton={showSwitchButton}
switchDisabled={switchDisabled}
isSwitching={isSwitching}
onSwitch={handleSwitch}
voiceEnabled={voiceEnabled}
voiceStatus={voiceStatus}
voiceMicMuted={voiceMicMuted}
onVoiceToggle={onVoiceToggle ?? (() => {})}
onVoiceMicToggle={onVoiceMicToggle}
onSend={handleSend}
pendingSchedule={pendingSchedule}
onSchedule={setPendingSchedule}
onClearSchedule={isControlled ? onClearScheduleProp : () => setPendingScheduleLocal(null)}
hasAttachments={hasAttachments}
piModelLabel={piModelLabel}
piModelDisabled={controlsDisabled || !piHasModels}
piModelOpen={showPiModelPanel}
onPiModelToggle={handlePiModelToggle}
piThinkingLabel={piThinkingLabel}
piThinkingDisabled={controlsDisabled || !piHasModels || !selectedPiModel || selectedPiModel.reasoning === false}
piThinkingOpen={showPiThinkingPanel}
onPiThinkingToggle={handlePiThinkingToggle}
scratchlistMode={props.scratchlistMode}
scratchlistCount={props.scratchlistCount}
onScratchlistToggle={props.onScratchlistToggle}
/>
</div>
</ComposerPrimitive.Root>
</div>
</div>
)
}