mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-09 07:29:51 +00:00
* 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>
905 lines
38 KiB
TypeScript
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>
|
|
)
|
|
}
|