import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ChatSendOptions, ChatSkillDraft, ModelOption } from "../types"; import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; import { useChat } from "./useChat"; import { useAutoCompactPreferences } from "./useAutoCompactPreferences"; import { useMessageQueue } from "./useMessageQueue"; import { useChatStore } from "../stores/chatStore"; import { useChatSessionStore } from "../stores/chatSessionStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { buildProjectSystemPrompt, composeSystemPrompt, resolveProjectDefaultArtifactRoot, } from "@/features/projects/lib/chatProjectContext"; import { setStoredModelPreference } from "../lib/modelPreferences"; import { shouldAutoCompactContext, supportsContextAutoCompaction, supportsContextCompactionControls, } from "../lib/autoCompact"; import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; import { useResolvedAgentModelPicker, type PreferredModelSelection, } from "./useResolvedAgentModelPicker"; interface UseChatSessionControllerOptions { sessionId: string | null; onMessageAccepted?: (sessionId: string) => void; onCreatePersonaRequested?: () => void; } const PENDING_HOME_SESSION_ID = "__home_pending__"; const EMPTY_SKILL_DRAFTS: ChatSkillDraft[] = []; export function useChatSessionController({ sessionId, onMessageAccepted, onCreatePersonaRequested, }: UseChatSessionControllerOptions) { const stateSessionId = sessionId ?? PENDING_HOME_SESSION_ID; const { providers, providersLoading, selectedProvider: globalSelectedProvider, setSelectedProvider: setGlobalSelectedProvider, } = useProviderSelection(); const personas = useAgentStore((s) => s.personas); const session = useChatSessionStore((s) => sessionId ? s.sessions.find((candidate) => candidate.id === sessionId) : undefined, ); const activeWorkspace = useChatSessionStore((s) => sessionId ? s.activeWorkspaceBySession[sessionId] : undefined, ); const clearActiveWorkspace = useChatSessionStore( (s) => s.clearActiveWorkspace, ); const projects = useProjectStore((s) => s.projects); const projectsLoading = useProjectStore((s) => s.loading); const catalogEntries = useProviderCatalogStore((s) => s.entries); const [pendingPersonaId, setPendingPersonaId] = useState(); const [pendingProjectId, setPendingProjectId] = useState(); const [pendingProviderId, setPendingProviderId] = useState(); const [pendingModelSelection, setPendingModelSelection] = useState(); const pendingDraftValue = useChatStore( (s) => s.draftsBySession[PENDING_HOME_SESSION_ID] ?? "", ); const pendingSkillDrafts = useChatStore( (s) => s.skillDraftsBySession[PENDING_HOME_SESSION_ID] ?? EMPTY_SKILL_DRAFTS, ); const pendingQueuedMessage = useChatStore( (s) => s.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null, ); const effectiveProjectId = pendingProjectId !== undefined ? pendingProjectId : (session?.projectId ?? null); const storedProject = useProjectStore((s) => effectiveProjectId ? s.projects.find((candidate) => candidate.id === effectiveProjectId) : undefined, ); const project = storedProject ?? null; const { autoCompactThreshold, isHydrated: isAutoCompactThresholdHydrated } = useAutoCompactPreferences(); const hasContextUsageSnapshot = useChatStore( (s) => s.sessionStateById[stateSessionId]?.hasUsageSnapshot ?? false, ); const selectedProvider = pendingProviderId ?? session?.providerId ?? project?.preferredProvider ?? globalSelectedProvider; const selectedPersonaId = pendingPersonaId !== undefined ? pendingPersonaId : (session?.personaId ?? null); const selectedPersona = personas.find( (persona) => persona.id === selectedPersonaId, ); const sessionCwd = activeWorkspace?.path ?? session?.workingDir ?? resolveProjectDefaultArtifactRoot(project); const projectDefaultArtifactRoot = useMemo( () => resolveProjectDefaultArtifactRoot(project), [project], ); const projectMetadataPending = Boolean( effectiveProjectId && !projectDefaultArtifactRoot && projectsLoading, ); const sessionArtifactCwd = useMemo( () => sessionCwd?.trim() || null, [sessionCwd], ); const availableProjects = useMemo( () => [...projects] .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)) .map((projectInfo) => ({ id: projectInfo.id, name: projectInfo.name, workingDirs: projectInfo.workingDirs, icon: projectInfo.icon, color: projectInfo.color, })), [projects], ); const projectSystemPrompt = useMemo( () => buildProjectSystemPrompt(project), [project], ); const workingContextPrompt = useMemo(() => { if (!activeWorkspace?.branch) return undefined; return `\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n`; }, [activeWorkspace?.branch, activeWorkspace?.path]); const effectiveSystemPrompt = useMemo( () => composeSystemPrompt( selectedPersona?.systemPrompt, projectSystemPrompt, workingContextPrompt, ), [projectSystemPrompt, selectedPersona?.systemPrompt, workingContextPrompt], ); const prepareCurrentSession = useCallback( async ( providerId: string, nextProject = project, nextWorkspacePath = activeWorkspace?.path, modelSelection?: PreferredModelSelection | null, ) => { if (!sessionId) { return; } const workingDir = await resolveSessionCwd( nextProject, nextWorkspacePath, ); await acpPrepareSession(sessionId, providerId, workingDir); if (!modelSelection?.id) { return; } const sessionStore = useChatSessionStore.getState(); const liveSession = sessionStore.getSession(sessionId); const modelAlreadyApplied = liveSession?.modelId === modelSelection.id && liveSession?.modelName === modelSelection.name; if (modelAlreadyApplied) { return; } await acpSetModel(sessionId, modelSelection.id); sessionStore.updateSession(sessionId, { modelId: modelSelection.id, modelName: modelSelection.name, }); }, [activeWorkspace?.path, project, sessionId], ); const prepareSelectedProvider = useCallback( (providerId: string, modelSelection?: PreferredModelSelection | null) => prepareCurrentSession( providerId, project, activeWorkspace?.path, modelSelection, ), [activeWorkspace?.path, prepareCurrentSession, project], ); const prevProjectIdRef = useRef(session?.projectId); useEffect(() => { if (!sessionId) { return; } const previousProjectId = prevProjectIdRef.current; prevProjectIdRef.current = session?.projectId; if ( previousProjectId !== undefined && previousProjectId !== session?.projectId ) { clearActiveWorkspace(sessionId); } }, [clearActiveWorkspace, session?.projectId, sessionId]); const { selectedAgentId, pickerAgents, availableModels, modelsLoading, modelStatusMessage, handleProviderChange, handleModelChange, handlePickerOpen, effectiveModelSelection, } = useResolvedAgentModelPicker({ providers, selectedProvider, sessionId, session, pendingModelSelection, setPendingProviderId, setPendingModelSelection, setGlobalSelectedProvider, prepareSelectedProvider, }); const prevWorkspaceRef = useRef(activeWorkspace); useEffect(() => { const previousWorkspace = prevWorkspaceRef.current; if ( !sessionId || !activeWorkspace || !selectedProvider || activeWorkspace === previousWorkspace ) { return; } prevWorkspaceRef.current = activeWorkspace; if (previousWorkspace?.path === activeWorkspace.path) { return; } void prepareSelectedProvider( selectedProvider, effectiveModelSelection, ).catch((error) => { console.error("Failed to prepare ACP session:", error); }); }, [ activeWorkspace, effectiveModelSelection, prepareSelectedProvider, selectedProvider, sessionId, ]); const handleProviderChangeWithContextReset = useCallback( (providerId: string) => { if (providerId === selectedProvider) { return; } useChatStore.getState().resetTokenState(stateSessionId); handleProviderChange(providerId); }, [handleProviderChange, selectedProvider, stateSessionId], ); const handleModelChangeWithContextReset = useCallback( (modelId: string, model?: ModelOption) => { const nextProviderId = model?.providerId; if ( modelId === effectiveModelSelection?.id && (!nextProviderId || nextProviderId === effectiveModelSelection?.providerId) ) { return; } useChatStore.getState().resetTokenState(stateSessionId); handleModelChange(modelId, model); }, [ effectiveModelSelection?.id, effectiveModelSelection?.providerId, handleModelChange, stateSessionId, ], ); const handleProjectChange = useCallback( (projectId: string | null) => { if (!sessionId) { setPendingProjectId(projectId); return; } const nextProject = projectId == null ? null : (useProjectStore .getState() .projects.find((candidate) => candidate.id === projectId) ?? null); useChatSessionStore.getState().updateSession(sessionId, { projectId }); if (!selectedProvider) { return; } void prepareCurrentSession( selectedProvider, nextProject, activeWorkspace?.path, effectiveModelSelection, ).catch((error) => { console.error("Failed to update ACP session working directory:", error); }); }, [ activeWorkspace?.path, effectiveModelSelection, prepareCurrentSession, selectedProvider, sessionId, ], ); const handlePersonaChange = useCallback( (personaId: string | null) => { if (personaId === selectedPersonaId) { return; } const persona = personas.find((candidate) => candidate.id === personaId); if (persona?.provider) { const matchingProvider = providers.find( (provider) => provider.id === persona.provider || provider.label.toLowerCase().includes(persona.provider ?? ""), ); if (matchingProvider) { if (!sessionId) { setPendingProviderId(matchingProvider.id); setPendingModelSelection(undefined); setGlobalSelectedProvider(matchingProvider.id); } else { handleProviderChange(matchingProvider.id); } } } const agentStore = useAgentStore.getState(); const matchingAgent = agentStore.agents.find( (agent) => agent.personaId === personaId, ); if (matchingAgent) { agentStore.setActiveAgent(matchingAgent.id); } if (!sessionId) { setPendingPersonaId(personaId); return; } useChatSessionStore .getState() .updateSession(sessionId, { personaId: personaId ?? undefined }); }, [ handleProviderChange, personas, providers, sessionId, selectedPersonaId, setGlobalSelectedProvider, ], ); useEffect(() => { if ( selectedPersonaId !== null && personas.length > 0 && !personas.find((persona) => persona.id === selectedPersonaId) ) { if (sessionId) { useChatSessionStore .getState() .updateSession(sessionId, { personaId: undefined }); } else { setPendingPersonaId(undefined); } } }, [personas, selectedPersonaId, sessionId]); const personaInfo = selectedPersona ? { id: selectedPersona.id, name: selectedPersona.displayName } : undefined; const { messages, chatState, tokenState, sendMessage, compactConversation, stopStreaming, streamingMessageId, } = useChat( stateSessionId, selectedProvider, effectiveSystemPrompt, personaInfo, { onMessageAccepted: sessionId ? onMessageAccepted : undefined, ensurePrepared: selectedProvider ? () => prepareCurrentSession( selectedProvider, project, activeWorkspace?.path, ) : undefined, }, ); const resolvedTokenState = tokenState ?? INITIAL_TOKEN_STATE; const supportsAutoCompactContext = supportsContextAutoCompaction(selectedAgentId); const supportsCompactionControls = supportsContextCompactionControls(selectedAgentId); const isCompactingContext = chatState === "compacting"; const resolveAutoCompactAgentId = useCallback( (overridePersona?: { id: string; name?: string }): string | null => { if (!overridePersona?.id) { return selectedAgentId; } const targetPersona = personas.find( (persona) => persona.id === overridePersona.id, ); if (!targetPersona?.provider) { return selectedAgentId; } const targetAgentId = resolveAgentProviderCatalogIdStrictFromEntries( catalogEntries, targetPersona.provider, ); if (targetAgentId) { return targetAgentId; } const isGooseModelProvider = providers.some( (provider) => provider.id === targetPersona.provider || provider.label.toLowerCase().includes(targetPersona.provider ?? ""), ); return isGooseModelProvider ? "goose" : null; }, [catalogEntries, personas, providers, selectedAgentId], ); const canAutoCompactBeforeSend = useCallback( (overridePersona?: { id: string; name?: string }) => { const targetAgentId = resolveAutoCompactAgentId(overridePersona); if ( !sessionId || !supportsContextAutoCompaction(targetAgentId) || !isAutoCompactThresholdHydrated ) { return false; } const liveRuntime = useChatStore .getState() .getSessionRuntime(stateSessionId); return shouldAutoCompactContext( liveRuntime.tokenState.accumulatedTotal, liveRuntime.tokenState.contextLimit, autoCompactThreshold, ); }, [ autoCompactThreshold, isAutoCompactThresholdHydrated, resolveAutoCompactAgentId, sessionId, stateSessionId, ], ); const sendWithAutoCompact = useCallback( ( text: string, overridePersona?: { id: string; name?: string }, attachments?: ChatAttachmentDraft[], sendOptions?: ChatSendOptions, ) => { if (!canAutoCompactBeforeSend(overridePersona)) { if (sendOptions) { void sendMessage(text, overridePersona, attachments, sendOptions); } else { void sendMessage(text, overridePersona, attachments); } return true; } return (async () => { const compactionResult = await compactConversation(overridePersona); if (compactionResult !== "completed") { return false; } if (sendOptions) { void sendMessage(text, overridePersona, attachments, sendOptions); } else { void sendMessage(text, overridePersona, attachments); } return true; })(); }, [canAutoCompactBeforeSend, compactConversation, sendMessage], ); const isLoadingHistory = useChatStore((s) => sessionId ? s.loadingSessionIds.has(sessionId) && (s.messagesBySession[sessionId]?.length ?? 0) === 0 : false, ); const deferredSend = useRef<{ text: string; attachments?: ChatAttachmentDraft[]; sendOptions?: ChatSendOptions; resolve?: (accepted: boolean) => void; } | null>(null); const queue = useMessageQueue( stateSessionId, sessionId ? chatState : "thinking", sendWithAutoCompact, ); const handleSend = useCallback( ( text: string, personaId?: string, attachments?: ChatAttachmentDraft[], sendOptions?: ChatSendOptions, ) => { if (!sessionId) { if (!queue.queuedMessage) { queue.enqueue(text, personaId, attachments, sendOptions); } return true; } if (personaId && personaId !== selectedPersonaId) { handlePersonaChange(personaId); return new Promise((resolve) => { deferredSend.current = { text, attachments, sendOptions, resolve }; }); } if (chatState !== "idle" && !queue.queuedMessage) { queue.enqueue(text, personaId, attachments, sendOptions); return true; } return sendWithAutoCompact(text, undefined, attachments, sendOptions); }, [ chatState, handlePersonaChange, queue, sessionId, selectedPersonaId, sendWithAutoCompact, ], ); useEffect(() => { if (deferredSend.current && selectedPersona) { const { text, attachments, sendOptions, resolve } = deferredSend.current; deferredSend.current = null; const sendResult = sendWithAutoCompact( text, undefined, attachments, sendOptions, ); if (sendResult instanceof Promise) { void sendResult.then((accepted) => { if (accepted === false) { useChatStore.getState().setDraft(stateSessionId, text); } resolve?.(accepted !== false); }); return; } resolve?.(true); } }, [selectedPersona, sendWithAutoCompact, stateSessionId]); const handleCreatePersona = useCallback(() => { if (onCreatePersonaRequested) { onCreatePersonaRequested(); return; } useAgentStore.getState().openPersonaEditor(); }, [onCreatePersonaRequested]); const sessionDraftValue = useChatStore((s) => sessionId ? (s.draftsBySession[sessionId] ?? "") : "", ); const sessionSkillDrafts = useChatStore((s) => sessionId ? (s.skillDraftsBySession[sessionId] ?? EMPTY_SKILL_DRAFTS) : EMPTY_SKILL_DRAFTS, ); const draftValue = sessionId ? sessionDraftValue : pendingDraftValue; const selectedSkills = sessionId ? sessionSkillDrafts : pendingSkillDrafts; const handleDraftChange = useCallback( (text: string) => { useChatStore.getState().setDraft(stateSessionId, text); }, [stateSessionId], ); const handleSkillsChange = useCallback( (skills: typeof selectedSkills) => { useChatStore.getState().setSkillDrafts(stateSessionId, skills); }, [stateSessionId], ); const scrollTarget = useChatStore((s) => sessionId ? (s.scrollTargetMessageBySession[sessionId] ?? null) : null, ); const handleScrollTargetHandled = useCallback(() => { if (!sessionId) { return; } useChatStore.getState().clearScrollTargetMessage(sessionId); }, [sessionId]); useEffect(() => { if (!sessionId) { return; } let cancelled = false; void pendingDraftValue; void pendingSkillDrafts; void pendingQueuedMessage; const syncPendingHomeState = async () => { const chatState = useChatStore.getState(); const pendingDraft = chatState.draftsBySession[PENDING_HOME_SESSION_ID] ?? ""; const pendingSkills = chatState.skillDraftsBySession[PENDING_HOME_SESSION_ID] ?? []; if (pendingDraft && !chatState.draftsBySession[sessionId]) { chatState.setDraft(sessionId, pendingDraft); } if ( pendingSkills.length > 0 && !chatState.skillDraftsBySession[sessionId]?.length ) { chatState.setSkillDrafts(sessionId, pendingSkills); } const hasPendingProvider = pendingProviderId !== undefined; const hasPendingPersona = pendingPersonaId !== undefined; const hasPendingProject = pendingProjectId !== undefined; const hasPendingModel = pendingModelSelection !== undefined; if ( hasPendingProvider || hasPendingPersona || hasPendingProject || hasPendingModel ) { const nextProviderId = pendingProviderId ?? selectedProvider; const nextPersonaId = pendingPersonaId !== undefined ? (pendingPersonaId ?? undefined) : session?.personaId; const nextProjectId = pendingProjectId !== undefined ? pendingProjectId : session?.projectId; const nextProject = nextProjectId == null ? null : (useProjectStore .getState() .projects.find((candidate) => candidate.id === nextProjectId) ?? null); const patch: { providerId?: string; personaId?: string | undefined; projectId?: string | null; modelId?: string | undefined; modelName?: string | undefined; } = {}; if (hasPendingProvider) { patch.providerId = nextProviderId; patch.modelId = undefined; patch.modelName = undefined; } if (hasPendingPersona) { patch.personaId = nextPersonaId; } if (hasPendingProject) { patch.projectId = nextProjectId ?? null; } useChatSessionStore.getState().updateSession(sessionId, patch); try { await prepareCurrentSession( nextProviderId, nextProject, activeWorkspace?.path, pendingModelSelection, ); if (cancelled) { return; } if (pendingModelSelection?.source === "explicit") { const agentId = resolveAgentProviderCatalogIdStrictFromEntries( catalogEntries, pendingModelSelection.providerId ?? nextProviderId, ) ?? "goose"; setStoredModelPreference(agentId, { modelId: pendingModelSelection.id, modelName: pendingModelSelection.name, providerId: pendingModelSelection.providerId ?? nextProviderId, }); } } catch (error) { console.error("Failed to sync pending Home state:", error); return; } setPendingProviderId(undefined); setPendingPersonaId(undefined); setPendingProjectId(undefined); setPendingModelSelection(undefined); } const latestChatState = useChatStore.getState(); const latestPendingQueue = latestChatState.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null; if ( latestPendingQueue && !latestChatState.queuedMessageBySession[sessionId] ) { latestChatState.enqueueMessage(sessionId, latestPendingQueue); } useChatStore.getState().clearDraft(PENDING_HOME_SESSION_ID); useChatStore.getState().clearSkillDrafts(PENDING_HOME_SESSION_ID); useChatStore.getState().dismissQueuedMessage(PENDING_HOME_SESSION_ID); useChatStore.getState().cleanupSession(PENDING_HOME_SESSION_ID); }; void syncPendingHomeState(); return () => { cancelled = true; }; }, [ activeWorkspace?.path, catalogEntries, pendingDraftValue, pendingSkillDrafts, pendingModelSelection, pendingPersonaId, pendingProjectId, pendingProviderId, pendingQueuedMessage, prepareCurrentSession, selectedProvider, session?.personaId, session?.projectId, sessionId, ]); return { session, project, sessionArtifactCwd, messages, chatState, tokenState: resolvedTokenState, stopStreaming, streamingMessageId, compactConversation, canCompactContext: supportsCompactionControls && messages.length > 0 && chatState === "idle", isCompactingContext, supportsAutoCompactContext, supportsCompactionControls, isContextUsageReady: hasContextUsageSnapshot && resolvedTokenState.contextLimit > 0, isLoadingHistory, queue, handleSend, draftValue, handleDraftChange, selectedSkills, handleSkillsChange, scrollTarget, handleScrollTargetHandled, projectMetadataPending, personas, selectedPersonaId, handlePersonaChange, handleCreatePersona, pickerAgents, providersLoading, selectedProvider: selectedAgentId, handleProviderChange: handleProviderChangeWithContextReset, currentModelId: effectiveModelSelection?.id ?? null, currentModelProviderId: effectiveModelSelection?.providerId ?? null, currentModelName: effectiveModelSelection?.name ?? null, availableModels, modelsLoading, modelStatusMessage, handleModelChange: handleModelChangeWithContextReset, handlePickerOpen, selectedProjectId: effectiveProjectId, availableProjects, handleProjectChange, }; }