mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
1456 lines
69 KiB
TypeScript
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>
|
|
)
|
|
}
|