Files
hapi/web/src/components/SessionChat.tsx
T
3a8693f380 feat(cursor): migrate remote sessions to ACP with model/variant pickers (#799)
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers

Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants

Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor-acp): apply ACP default model when web resets to Default

Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(acp): clear stale agent-acp lock when owning process is gone

Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cursor): use live pid for ACP lock handler tests

Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(acp): scope agent CLI lock guard to Cursor agent command only

Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): reject Cursor model changes for local sessions

Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.

Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): send stable ids for Cursor ask_question replies

Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:51:35 +08:00

905 lines
38 KiB
TypeScript

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<boolean>
}) {
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 (
<ScratchlistPanel
sessionId={sessionId}
onPromoteToComposer={handlePromoteToComposer}
onPromoteToQueue={handlePromoteToQueue}
/>
)
}
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<unknown>
// 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<boolean>
onFlushPending: () => void
onAtBottomChange: (atBottom: boolean) => void
onRetryMessage?: (localId: string) => void
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
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<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
const visibleGroupsRef = useRef<ToolGroupBlock[]>([])
const [forceScrollToken, setForceScrollToken] = useState(0)
const [outlineOpen, setOutlineOpen] = useState(false)
const [cursorSelectedBase, setCursorSelectedBase] = useState('auto')
const lastSyncedCursorModelRef = useRef<string | null | undefined>(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<string, unknown> } } | 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<DecryptedMessage[]>([])
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<Set<string>>(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<string | null>(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<string>()
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<PendingSchedule | null>(null)
const pendingScheduleRef = useRef<PendingSchedule | null>(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<PendingSchedule, { type: 'absolute' }>).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 (
<div className="flex h-full min-h-0 flex-col">
<SessionHeader
session={props.session}
onBack={props.onBack}
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
onOpenOutline={() => setOutlineOpen(true)}
api={props.api}
onSessionDeleted={props.onBack}
/>
{props.session.teamState && (
<TeamPanel teamState={props.session.teamState} />
)}
{sessionInactive ? (
<div className="px-3 pt-3">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
{inactiveCanResume
? t('session.inactive.autoResume')
: t('session.inactive.cannotResume')}
</div>
</div>
) : null}
<AssistantRuntimeProvider runtime={runtime}>
<div className="relative flex min-h-0 flex-1 flex-col">
<HappyThread
key={props.session.id}
api={props.api}
sessionId={props.session.id}
metadata={props.session.metadata}
disabled={sessionInactive}
onRefresh={props.onRefresh}
onRetryMessage={props.onRetryMessage}
onFlushPending={props.onFlushPending}
onAtBottomChange={props.onAtBottomChange}
isLoadingMessages={props.isLoadingMessages}
messagesWarning={props.messagesWarning}
hasMoreMessages={props.hasMoreMessages}
isLoadingMoreMessages={props.isLoadingMoreMessages}
onLoadMore={props.onLoadMore}
pendingCount={props.pendingCount}
rawMessagesCount={visibleMessages.length}
normalizedMessagesCount={normalizedMessages.length}
messagesVersion={props.messagesVersion}
forceScrollToken={forceScrollToken}
outlineOpen={outlineOpen}
outlineTitle={outlineTitle}
outlineItems={outlineItems}
onOutlineOpenChange={setOutlineOpen}
/>
{codexCollaborationModeSupported && codexModelsState.error ? (
<div className="px-3 pb-2">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-red-600">
{t('session.codexModelsLoadFailed')}: {codexModelsState.error}
</div>
</div>
) : null}
<div className="px-3">
{/*
* 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.
*/}
<ScratchlistHost
key={props.session.id}
sessionId={props.session.id}
onSend={props.onSend}
/>
<QueuedMessagesBar
sessionId={props.session.id}
api={props.api}
onEdit={({ pendingSchedule: restored }) => {
// Restore the schedule so the clock button re-activates
setPendingSchedule(restored)
}}
/>
</div>
<HappyComposer
key={props.session.id}
sessionId={props.session.id}
disabled={props.isSending}
pendingSchedule={pendingSchedule}
onSchedule={setPendingSchedule}
onClearSchedule={() => 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}
/>
</div>
</AssistantRuntimeProvider>
{/* Voice session component - renders nothing but initializes voice backend */}
{voice && (
<VoiceBackendSession
api={props.api}
micMuted={voice.micMuted}
onStatusChange={voice.setStatus}
onReadyChange={setVoiceBackendReady}
/>
)}
</div>
)
}