import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider, useAssistantApi } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { AttachmentMetadata, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SlashCommand } from '@/types/api' import type { ChatBlock, NormalizedMessage } from '@/chat/types' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { normalizeDecryptedMessage } from '@/chat/normalize' import { reduceChatBlocks } from '@/chat/reducer' import { reconcileChatBlocks } from '@/chat/reconcile' import { buildConversationOutline } from '@/chat/outline' import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups' import { isQueuedForInvocation, mergeMessages } from '@/lib/messages' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { HappyThread } from '@/components/AssistantChat/HappyThread' import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar' import { ScratchlistPanel } from '@/components/AssistantChat/ScratchlistPanel' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { useTranslation } from '@/lib/use-translation' import { SessionHeader } from '@/components/SessionHeader' import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModels } from '@/hooks/queries/useCursorModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { buildCursorCatalogFromSources, buildCursorPickerState, resolveCursorBaseFromWire } from '@/lib/cursorPickerState' import { resolveSessionCursorBaseSelectValue, resolveSessionCursorModelChange, resolveSessionCursorVariantSelectValue } from '@/lib/sessionChatCursorModel' import { buildCursorEffortPickerOptions, resolveCursorVariantOptions } from '@/lib/cursorModelOptions' import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' import { useVoiceOptional } from '@/lib/voice-context' import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' import { isRemoteTerminalSupported } from '@/utils/terminalSupport' /** * Returns whether a PendingSchedule should trigger an auto-clear timer. * * Only 'absolute' schedules expire (the chosen instant passes). * 'preset' schedules are relative to send time and have no fixed expiry. * * Used both by the auto-clear useEffect and by unit tests, so a future * variant of PendingSchedule only needs to update this single helper. */ export function shouldAutoClearPendingSchedule(pending: PendingSchedule | null): boolean { return pending !== null && pending.type === 'absolute' } function isUninvokedScheduledMessage(message: DecryptedMessage): boolean { return message.invokedAt == null && message.scheduledAt != null } /** * Mounts the per-session scratchlist (issue #11) inside the AssistantUI * runtime so promote-to-composer can call `composer().setText(...)`. * Promote-to-queue routes to the same `onSend` path as a normal composer * send, so a promoted entry shows up immediately in `QueuedMessagesBar`. */ function ScratchlistHost({ sessionId, onSend, }: { sessionId: string onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise }) { const assistantApi = useAssistantApi() const handlePromoteToComposer = useCallback((text: string) => { assistantApi.composer().setText(text) }, [assistantApi]) const handlePromoteToQueue = useCallback(async (text: string) => { return await onSend(text) }, [onSend]) return ( ) } export function buildGoalStateMessages( messages: DecryptedMessage[], pendingMessages: DecryptedMessage[] = [] ): DecryptedMessage[] { const eligibleMessages = messages.filter((message) => !isUninvokedScheduledMessage(message)) const eligiblePendingMessages = pendingMessages.filter((message) => !isUninvokedScheduledMessage(message)) return eligiblePendingMessages.length > 0 ? mergeMessages(eligibleMessages, eligiblePendingMessages) : eligibleMessages } function getOutlineTitle(session: Session): string { if (session.metadata?.name) { return session.metadata.name } if (session.metadata?.summary?.text) { return session.metadata.summary.text } if (session.metadata?.path) { return session.metadata.path } return session.id.slice(0, 8) } function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean { for (const block of blocks) { if (block.kind === 'tool-call') { if ( block.tool.name === 'CodexAgent' && (block.tool.state === 'running' || block.tool.state === 'pending') ) { return true } if (hasAbortableAgentRun(block.children)) { return true } } } return false } export function SessionChat(props: { api: ApiClient session: Session messages: DecryptedMessage[] pendingMessages?: DecryptedMessage[] messagesWarning: string | null hasMoreMessages: boolean isLoadingMessages: boolean isLoadingMoreMessages: boolean isSending: boolean pendingCount: number messagesVersion: number onBack: () => void onRefresh: () => void onLoadMore: () => Promise // Resolves true when the send was accepted by the underlying mutation, false when // pre-mutation guards (no-api / no-session / pending) rejected the call OR async // inactive-session resume failed. Composer state that should only be cleared on // actual send (pendingSchedule) must await this — see handleSend below. onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise onFlushPending: () => void onAtBottomChange: (atBottom: boolean) => void onRetryMessage?: (localId: string) => void autocompleteSuggestions?: (query: string) => Promise availableSlashCommands?: readonly SlashCommand[] }) { const { haptic } = usePlatform() const { t } = useTranslation() const navigate = useNavigate() const sessionInactive = !props.session.active const inactiveCanResume = inactiveSessionCanResume(props.session, props.messages.length) const terminalSupported = isRemoteTerminalSupported(props.session.metadata) const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) const visibleGroupsRef = useRef([]) const [forceScrollToken, setForceScrollToken] = useState(0) const [outlineOpen, setOutlineOpen] = useState(false) const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const lastSyncedCursorModelRef = useRef(undefined) const agentFlavor = props.session.metadata?.flavor ?? null const controlledByUser = props.session.agentState?.controlledByUser === true const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser const codexModelsState = useCodexModels({ api: props.api, sessionId: props.session.id, enabled: agentFlavor === 'codex' && props.session.active && !controlledByUser }) const codexModelOptions = useMemo(() => { if (agentFlavor !== 'codex') { return undefined } const options: Array<{ value: string | null; label: string }> = [] for (const codexModel of codexModelsState.models) { options.push({ value: codexModel.id, label: codexModel.displayName }) } return options }, [agentFlavor, codexModelsState.models]) const opencodeModelsState = useOpencodeModels({ api: props.api, sessionId: props.session.id, enabled: agentFlavor === 'opencode' && props.session.active }) const opencodeModelOptions = useMemo(() => { if (agentFlavor !== 'opencode') { return undefined } return opencodeModelsState.availableModels.map((opencodeModel) => ({ value: opencodeModel.modelId, label: opencodeModel.name ?? opencodeModel.modelId })) }, [agentFlavor, opencodeModelsState.availableModels]) const cursorModelsState = useCursorModels({ api: props.api, sessionId: props.session.id, enabled: agentFlavor === 'cursor' && props.session.active }) const sessionMachineId = props.session.metadata?.machineId ?? null const machineCursorModelsState = useCursorModelsForMachine({ api: props.api, machineId: sessionMachineId, enabled: agentFlavor === 'cursor' && props.session.active && Boolean(sessionMachineId) }) const sessionCliModelSkus = useMemo(() => { if (cursorModelsState.cliModelSkus.length > 0) { return cursorModelsState.cliModelSkus } return machineCursorModelsState.cliModelSkus }, [cursorModelsState.cliModelSkus, machineCursorModelsState.cliModelSkus]) const cursorPicker = useMemo(() => { if (agentFlavor !== 'cursor') { return null } const catalog = buildCursorCatalogFromSources({ sessionModels: cursorModelsState.availableModels, machineModels: machineCursorModelsState.availableModels, cliModelSkus: sessionCliModelSkus, currentWireId: cursorModelsState.currentModelId ?? props.session.model, sessionModelFromHub: props.session.model, defaultValue: null }) return buildCursorPickerState({ catalog, currentWireId: props.session.model ?? cursorModelsState.currentModelId, defaultValue: null }) }, [ agentFlavor, cursorModelsState.availableModels, cursorModelsState.cliModelSkus, cursorModelsState.currentModelId, machineCursorModelsState.availableModels, sessionCliModelSkus, props.session.model ]) useEffect(() => { if (agentFlavor !== 'cursor' || !cursorPicker) { lastSyncedCursorModelRef.current = undefined return } const sessionModel = props.session.model ?? null const baseFromSession = sessionModel ? resolveCursorBaseFromWire(sessionModel, cursorPicker.catalog) : 'auto' if (lastSyncedCursorModelRef.current === sessionModel) { if (!sessionModel) { return } setCursorSelectedBase((prev) => (prev === 'auto' ? baseFromSession : prev)) return } lastSyncedCursorModelRef.current = sessionModel setCursorSelectedBase(baseFromSession) }, [agentFlavor, props.session.model, cursorPicker]) const cursorSelectedBaseValue = useMemo(() => ( agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' ? resolveSessionCursorBaseSelectValue(cursorPicker, cursorSelectedBase) : undefined ), [agentFlavor, cursorPicker, cursorSelectedBase]) const cursorModelEffortOptions = useMemo(() => { if (agentFlavor !== 'cursor' || !cursorPicker) { return undefined } if (cursorPicker.mode !== 'dual') { return cursorPicker.effortOptions } const baseKey = cursorSelectedBaseValue && cursorSelectedBaseValue !== 'auto' ? cursorSelectedBaseValue : cursorPicker.baseKey return buildCursorEffortPickerOptions(resolveCursorVariantOptions(baseKey ?? null, cursorPicker.catalog)) }, [agentFlavor, cursorPicker, cursorSelectedBaseValue]) const cursorVariantSelectValue = useMemo(() => ( agentFlavor === 'cursor' && cursorModelEffortOptions ? resolveSessionCursorVariantSelectValue(props.session.model, cursorModelEffortOptions) : null ), [agentFlavor, cursorModelEffortOptions, props.session.model]) const { abortSession, switchSession, setPermissionMode, setCollaborationMode, setModel, setModelReasoningEffort, setEffort } = useSessionActions( props.api, props.session.id, agentFlavor, codexCollaborationModeSupported ) // Voice assistant integration const voice = useVoiceOptional() const [voiceBackendReady, setVoiceBackendReady] = useState(false) // Register session store for voice client tools useEffect(() => { registerSessionStore({ getSession: () => props.session as { agentState?: { requests?: Record } } | null, sendMessage: (_sessionId: string, message: string) => props.onSend(message), approvePermission: async (_sessionId: string, requestId: string) => { await props.api.approvePermission(props.session.id, requestId) props.onRefresh() }, denyPermission: async (_sessionId: string, requestId: string) => { await props.api.denyPermission(props.session.id, requestId) props.onRefresh() } }) }, [props.session, props.api, props.onSend, props.onRefresh]) useEffect(() => { registerVoiceHooksStore( (sessionId) => (sessionId === props.session.id ? props.session : null), (sessionId) => (sessionId === props.session.id ? props.messages : []) ) }, [props.session, props.messages]) // Track and report new messages to voice assistant // Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here const prevMessagesRef = useRef([]) useEffect(() => { const prevIds = new Set(prevMessagesRef.current.map(m => m.id)) const newMessages = props.messages.filter(m => !prevIds.has(m.id)) if (newMessages.length > 0) { voiceHooks.onMessages(props.session.id, newMessages) } prevMessagesRef.current = props.messages }, [props.messages, props.session.id]) // Report ready event when thinking stops // Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here const prevThinkingRef = useRef(props.session.thinking) useEffect(() => { // Detect transition: thinking → not thinking if (prevThinkingRef.current && !props.session.thinking) { voiceHooks.onReady(props.session.id) } prevThinkingRef.current = props.session.thinking }, [props.session.thinking, props.session.id]) // Report permission requests to voice assistant // Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here const prevRequestIdsRef = useRef>(new Set()) useEffect(() => { const requests = props.session.agentState?.requests ?? {} const currentIds = new Set(Object.keys(requests)) for (const [requestId, request] of Object.entries(requests)) { if (!prevRequestIdsRef.current.has(requestId)) { voiceHooks.onPermissionRequested( props.session.id, requestId, (request as { tool?: string }).tool ?? 'unknown', (request as { arguments?: unknown }).arguments ) } } prevRequestIdsRef.current = currentIds }, [props.session.agentState?.requests, props.session.id]) const handleVoiceToggle = useCallback(async () => { if (!voice) return if (voice.status === 'connected' || voice.status === 'connecting') { await voice.stopVoice() } else { await voice.startVoice(props.session.id) } }, [voice, props.session.id]) const handleVoiceMicToggle = useCallback(() => { if (!voice) return voice.toggleMic() }, [voice]) // Track session id to clear caches when it changes const prevSessionIdRef = useRef(null) useEffect(() => { normalizedCacheRef.current.clear() blocksByIdRef.current.clear() visibleGroupsRef.current = [] setOutlineOpen(false) }, [props.session.id]) // Exclude user messages that haven't been invoked yet — those appear in the // QueuedMessagesBar above the composer, not in the thread timeline. The // `isQueuedForInvocation` predicate is shared with the window store and the // floating bar so the three views never disagree about queued state. const visibleMessages = useMemo( () => props.messages.filter((m) => !isQueuedForInvocation(m)), [props.messages] ) const normalizedMessages: NormalizedMessage[] = useMemo(() => { // Clear caches immediately when session changes (before useEffect runs) if (prevSessionIdRef.current !== null && prevSessionIdRef.current !== props.session.id) { normalizedCacheRef.current.clear() blocksByIdRef.current.clear() visibleGroupsRef.current = [] } prevSessionIdRef.current = props.session.id const cache = normalizedCacheRef.current const normalized: NormalizedMessage[] = [] const seen = new Set() for (const message of visibleMessages) { if (seen.has(message.id)) { continue } seen.add(message.id) const cached = cache.get(message.id) if (cached && cached.source === message) { if (cached.normalized) normalized.push(cached.normalized) continue } const next = normalizeDecryptedMessage(message) cache.set(message.id, { source: message, normalized: next }) if (next) normalized.push(next) } for (const id of cache.keys()) { if (!seen.has(id)) { cache.delete(id) } } return normalized }, [visibleMessages]) const goalStateSourceMessages = useMemo( () => buildGoalStateMessages(props.messages, props.pendingMessages ?? []), [props.messages, props.pendingMessages] ) const normalizedGoalStateMessages: NormalizedMessage[] = useMemo(() => { const normalized: NormalizedMessage[] = [] for (const message of goalStateSourceMessages) { const next = normalizeDecryptedMessage(message) if (next) normalized.push(next) } return normalized }, [goalStateSourceMessages]) const reduced = useMemo( () => reduceChatBlocks(normalizedMessages, props.session.agentState, { goalStateMessages: normalizedGoalStateMessages }), [normalizedMessages, normalizedGoalStateMessages, props.session.agentState] ) const reconciled = useMemo( () => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current), [reduced.blocks] ) const hasRunningChildAgent = useMemo( () => hasAbortableAgentRun(reduced.blocks), [reduced.blocks] ) useEffect(() => { blocksByIdRef.current = reconciled.byId }, [reconciled.byId]) const visibleBlocks = useMemo( () => buildVisibleChatBlocks(reconciled.blocks, { hasMoreMessages: props.hasMoreMessages, previousGroups: visibleGroupsRef.current }), [reconciled.blocks, props.hasMoreMessages] ) useEffect(() => { visibleGroupsRef.current = visibleBlocks.filter(isToolGroupBlock) }, [visibleBlocks]) const outlineItems = useMemo( () => buildConversationOutline(reconciled.blocks), [reconciled.blocks] ) const outlineTitle = useMemo( () => getOutlineTitle(props.session), [props.session] ) // Permission mode change handler const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { try { await setPermissionMode(mode) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set permission mode:', e) } }, [setPermissionMode, props.onRefresh, haptic]) const handleCollaborationModeChange = useCallback(async (mode: CodexCollaborationMode) => { try { await setCollaborationMode(mode) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set collaboration mode:', e) } }, [setCollaborationMode, props.onRefresh, haptic]) // Model mode change handler const handleModelChange = useCallback(async (model: string | null) => { try { await setModel(model) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set model:', e) } }, [setModel, props.onRefresh, haptic]) const handleCursorBaseModelChange = useCallback(async (baseKey: string | null) => { if (!cursorPicker) { await handleModelChange(baseKey) return } const plan = resolveSessionCursorModelChange({ picker: cursorPicker, sessionModel: props.session.model, cursorSelectedBase, kind: cursorPicker.mode === 'flat' ? 'flat' : 'base', value: baseKey }) if (!plan.ok) { return } setCursorSelectedBase(plan.nextSelectedBase) if (plan.shouldApply) { await handleModelChange(plan.wireId) } }, [cursorPicker, cursorSelectedBase, handleModelChange, props.session.model]) const handleCursorEffortChange = useCallback(async (wireId: string | null) => { if (!cursorPicker) { await handleModelChange(wireId) return } const plan = resolveSessionCursorModelChange({ picker: cursorPicker, sessionModel: props.session.model, cursorSelectedBase, kind: 'effort', value: wireId }) if (!plan.ok) { console.error(plan.reason) return } setCursorSelectedBase(plan.nextSelectedBase) await handleModelChange(plan.wireId) }, [cursorPicker, cursorSelectedBase, handleModelChange, props.session.model]) const handleModelReasoningEffortChange = useCallback(async (modelReasoningEffort: string | null) => { try { await setModelReasoningEffort(modelReasoningEffort) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set model reasoning effort:', e) } }, [setModelReasoningEffort, props.onRefresh, haptic]) const handleEffortChange = useCallback(async (effort: string | null) => { try { await setEffort(effort) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set effort:', e) } }, [setEffort, props.onRefresh, haptic]) // Abort handler const handleAbort = useCallback(async () => { await abortSession() props.onRefresh() }, [abortSession, props.onRefresh]) // Switch to remote handler const handleSwitchToRemote = useCallback(async () => { await switchSession() props.onRefresh() }, [switchSession, props.onRefresh]) const handleViewFiles = useCallback(() => { navigate({ to: '/sessions/$sessionId/files', params: { sessionId: props.session.id } }) }, [navigate, props.session.id]) const handleViewTerminal = useCallback(() => { navigate({ to: '/sessions/$sessionId/terminal', params: { sessionId: props.session.id } }) }, [navigate, props.session.id]) // Scheduled message state — lifted here so useHappyRuntime can read the ref. // // pendingSchedule holds what the user selected (preset or absolute ms). // The ref is read at send time; resolvePendingSchedule converts it to an // absolute epoch-ms using Date.now() at that moment (send-time base for presets). const [pendingSchedule, setPendingSchedule] = useState(null) const pendingScheduleRef = useRef(null) // Keep render ref in sync so onNew can snapshot at send time pendingScheduleRef.current = pendingSchedule // Auto-clear absolute-type pendingSchedule when the chosen time expires so // the composer clock button doesn't stay active past the scheduled instant. // Preset-type schedules are relative so they don't expire until send — the // shouldAutoClearPendingSchedule predicate is the single source of truth so // adding a new PendingSchedule variant only needs to update that helper. useEffect(() => { if (!shouldAutoClearPendingSchedule(pendingSchedule)) return // Narrowed to 'absolute' by the predicate above. const ms = (pendingSchedule as Extract).ms const remaining = ms - Date.now() if (remaining <= 0) { setPendingSchedule(null) return } const timer = setTimeout(() => setPendingSchedule(null), remaining) return () => clearTimeout(timer) }, [pendingSchedule]) const handleSend = useCallback(async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => { const accepted = await props.onSend(text, attachments, scheduledAt) if (!accepted) return // Clear pendingSchedule only after the mutation is actually accepted — // covers both pre-mutation guards AND async inactive-session resume // failure. SessionChat is the single owner of schedule clear (HappyComposer // no longer clears on its own send path). setPendingSchedule(null) setForceScrollToken((token) => token + 1) }, [props.onSend]) const attachmentAdapter = useMemo(() => { if (!props.session.active) { return undefined } return createAttachmentAdapter(props.api, props.session.id) }, [props.api, props.session.id, props.session.active]) const runtime = useHappyRuntime({ session: props.session, blocks: visibleBlocks, isSending: props.isSending, isRunning: props.session.thinking || hasRunningChildAgent, onSendMessage: handleSend, onAbort: handleAbort, attachmentAdapter, allowSendWhenInactive: true, pendingScheduleRef }) return (
setOutlineOpen(true)} api={props.api} onSessionDeleted={props.onBack} /> {props.session.teamState && ( )} {sessionInactive ? (
{inactiveCanResume ? t('session.inactive.autoResume') : t('session.inactive.cannotResume')}
) : null}
{codexCollaborationModeSupported && codexModelsState.error ? (
{t('session.codexModelsLoadFailed')}: {codexModelsState.error}
) : null}
{/* * Key by session id so React unmounts/remounts when * the operator switches sessions without remounting * SessionChat (e.g. same-route navigation A -> B). * Without this, ScratchlistPanel's useState * initializer reads sessionId once at mount; the * useEffect rehydrate then races against the persist * effect, briefly rendering A's entries under B and * writing them into B's localStorage before * correcting. Keying makes the first render for B * read B's storage directly. Cleaner than chasing * the race inside the panel. */} { // Restore the schedule so the clock button re-activates setPendingSchedule(restored) }} />
setPendingSchedule(null)} permissionMode={props.session.permissionMode} collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined} threadGoal={reduced.latestGoal} model={props.session.model} modelReasoningEffort={agentFlavor === 'codex' || agentFlavor === 'opencode' ? props.session.modelReasoningEffort : undefined} effort={props.session.effort} agentFlavor={agentFlavor} availableModelOptions={ agentFlavor === 'codex' ? codexModelOptions : agentFlavor === 'cursor' ? ( cursorModelsState.isLoading || !cursorPicker || cursorPicker.modelOptions.length === 0 ? undefined : cursorPicker.modelOptions ) : agentFlavor === 'opencode' ? opencodeModelOptions : undefined } active={props.session.active} allowSendWhenInactive thinking={props.session.thinking} agentState={props.session.agentState} backgroundTaskCount={props.session.backgroundTaskCount} contextSize={reduced.latestUsage?.contextSize} contextCacheRead={reduced.latestUsage?.cacheRead} contextWindow={reduced.latestUsage?.contextWindow} controlledByUser={controlledByUser} onCollaborationModeChange={ codexCollaborationModeSupported && props.session.active && !controlledByUser ? handleCollaborationModeChange : undefined } onPermissionModeChange={handlePermissionModeChange} selectedModelBase={ agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' ? cursorSelectedBaseValue : undefined } selectedModelVariant={ agentFlavor === 'cursor' ? cursorVariantSelectValue : undefined } modelEffortOptions={ agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' && cursorModelEffortOptions && cursorModelEffortOptions.length > 1 ? cursorModelEffortOptions : undefined } onModelChange={ agentFlavor === 'codex' ? (props.session.active && !controlledByUser && !codexModelsState.error ? handleModelChange : undefined) : agentFlavor === 'cursor' ? (props.session.active && !controlledByUser && !cursorModelsState.isLoading && !cursorModelsState.error && cursorPicker && cursorPicker.modelOptions.length > 0 ? handleCursorBaseModelChange : undefined) : handleModelChange } onModelEffortChange={ agentFlavor === 'cursor' && props.session.active && !controlledByUser && !cursorModelsState.error ? handleCursorEffortChange : undefined } onModelReasoningEffortChange={ (agentFlavor === 'codex' || agentFlavor === 'opencode') && props.session.active && !controlledByUser ? handleModelReasoningEffortChange : undefined } onEffortChange={handleEffortChange} onSwitchToRemote={handleSwitchToRemote} onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined} terminalUnsupported={props.session.active && !terminalSupported} autocompleteSuggestions={props.autocompleteSuggestions} voiceStatus={voice?.status} voiceMicMuted={voice?.micMuted} onVoiceToggle={voice && voiceBackendReady ? handleVoiceToggle : undefined} onVoiceMicToggle={voice && voiceBackendReady ? handleVoiceMicToggle : undefined} />
{/* Voice session component - renders nothing but initializes voice backend */} {voice && ( )}
) }