From ddc67a010586fa8bfa854a780e3f67f74a298684 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 20 Jan 2026 14:31:03 +0800 Subject: [PATCH] feat: add dynamic session mode and model selection Add support for dynamically switching session modes and models through the ACP protocol. This enables users to change the AI model or operating mode during an active session. Changes: - Add ModeSelector and ModelSelector dropdown components - Extend ACP client to handle mode/model update notifications - Add IPC channels for mode/model state management - Update Conductor and SessionLifecycle for dynamic switching - Add max-height constraint to dropdowns for better UX Co-Authored-By: Claude Opus 4.5 --- src/main/cli.ts | 2 +- src/main/conductor/AcpClientFactory.ts | 15 +- src/main/conductor/AgentProcessManager.ts | 24 ++- src/main/conductor/Conductor.ts | 19 ++- src/main/conductor/SessionLifecycle.ts | 85 +++++++++- src/main/conductor/types.ts | 15 +- src/main/ipc/handlers.ts | 74 +++++++- src/preload/index.ts | 18 +- src/renderer/src/App.tsx | 12 +- src/renderer/src/components/MessageInput.tsx | 39 ++++- src/renderer/src/components/ModeSelector.tsx | 75 ++++++++ src/renderer/src/components/ModelSelector.tsx | 77 +++++++++ src/renderer/src/hooks/useApp.ts | 160 +++++++++++++++--- src/shared/electron-api.d.ts | 13 +- src/shared/ipc-channels.ts | 7 +- src/shared/types.ts | 4 + src/shared/types/mode.ts | 4 + src/shared/types/model.ts | 4 + tests/integration/conductor/Conductor.test.ts | 18 +- 19 files changed, 603 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/ModeSelector.tsx create mode 100644 src/renderer/src/components/ModelSelector.tsx create mode 100644 src/shared/types/mode.ts create mode 100644 src/shared/types/model.ts diff --git a/src/main/cli.ts b/src/main/cli.ts index c383252def..5ce498b0f6 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -416,7 +416,7 @@ async function cmdStatus(state: CLIState): Promise { // Current session if (state.currentSession) { const isRunning = state.conductor.isSessionRunning(state.currentSession.id) - const agentConfig = state.conductor.getSessionAgent(state.currentSession.id) + const agentConfig = state.conductor.getSessionAgentConfig(state.currentSession.id) print(` Current Session: ${c.green}${state.currentSession.id.slice(0, 8)}${c.reset}`) print( ` Agent: ${agentConfig?.name || 'unknown'} (${isRunning ? `${c.green}running${c.reset}` : `${c.red}stopped${c.reset}`})` diff --git a/src/main/conductor/AcpClientFactory.ts b/src/main/conductor/AcpClientFactory.ts index 4dacf88d98..e52d879297 100644 --- a/src/main/conductor/AcpClientFactory.ts +++ b/src/main/conductor/AcpClientFactory.ts @@ -7,13 +7,19 @@ import type { Client, SessionNotification, RequestPermissionRequest, - RequestPermissionResponse + RequestPermissionResponse, + SessionModeId, + ModelId } from '@agentclientprotocol/sdk' import type { SessionStore } from '../session/SessionStore' export interface AcpClientCallbacks { onSessionUpdate?: (update: SessionNotification, sequenceNumber?: number) => void onPermissionRequest?: (params: RequestPermissionRequest) => Promise + /** Called when server sends a mode update notification */ + onModeUpdate?: (modeId: SessionModeId) => void + /** Called when server sends a model update notification */ + onModelUpdate?: (modelId: ModelId) => void } export interface AcpClientFactoryOptions { @@ -56,6 +62,13 @@ export function createAcpClient(sessionId: string, options: AcpClientFactoryOpti // Log other types briefly console.log(`[ACP] ${updateType}`) } + // Handle mode update notification + if (updateType === 'current_mode_update' && callbacks.onModeUpdate) { + const modeUpdate = update as { currentModeId?: SessionModeId } + if (modeUpdate.currentModeId) { + callbacks.onModeUpdate(modeUpdate.currentModeId) + } + } } else { console.log(`[ACP] raw update:`, params) } diff --git a/src/main/conductor/AgentProcessManager.ts b/src/main/conductor/AgentProcessManager.ts index b80a1c22b8..9ba2e47358 100644 --- a/src/main/conductor/AgentProcessManager.ts +++ b/src/main/conductor/AgentProcessManager.ts @@ -62,7 +62,21 @@ export class AgentProcessManager implements IAgentProcessManager { sessionStore: this.sessionStore as any, // Cast to SessionStore for compatibility callbacks: { onSessionUpdate: this.events.onSessionUpdate, - onPermissionRequest: this.events.onPermissionRequest + onPermissionRequest: this.events.onPermissionRequest, + // Handle server-initiated mode updates + onModeUpdate: (modeId) => { + const sessionAgent = this.sessions.get(sessionId) + if (sessionAgent?.sessionModeState) { + sessionAgent.sessionModeState.currentModeId = modeId + } + }, + // Handle server-initiated model updates + onModelUpdate: (modelId) => { + const sessionAgent = this.sessions.get(sessionId) + if (sessionAgent?.sessionModelState) { + sessionAgent.sessionModelState.currentModelId = modelId + } + } } }), stream @@ -98,13 +112,19 @@ export class AgentProcessManager implements IAgentProcessManager { this.sessions.delete(sessionId) }) + // Extract modes and models from ACP response (like Zed does) + const sessionModeState = acpResult.modes ?? null + const sessionModelState = acpResult.models ?? null + // Store in sessions map const sessionAgent: SessionAgent = { agentProcess, connection, agentConfig: config, agentSessionId: acpResult.sessionId, - needsHistoryReplay: isResumed // True when resuming, agent needs conversation context + needsHistoryReplay: isResumed, // True when resuming, agent needs conversation context + sessionModeState, + sessionModelState } this.sessions.set(sessionId, sessionAgent) diff --git a/src/main/conductor/Conductor.ts b/src/main/conductor/Conductor.ts index 505cbb4871..3b4066b3a1 100644 --- a/src/main/conductor/Conductor.ts +++ b/src/main/conductor/Conductor.ts @@ -112,12 +112,20 @@ export class Conductor { } /** - * Load a session without starting its agent (lazy loading) + * Load a session without starting its agent */ async loadSession(sessionId: string): Promise { return this.sessionLifecycle.load(sessionId) } + /** + * Start agent for a session (if not already running) + * Used when selecting historical sessions to ensure agent is running + */ + async startSessionAgent(sessionId: string): Promise { + return this.sessionLifecycle.startAgent(sessionId) + } + /** * Delete a session */ @@ -177,10 +185,17 @@ export class Conductor { /** * Get agent config for a session */ - getSessionAgent(sessionId: string): AgentConfig | null { + getSessionAgentConfig(sessionId: string): AgentConfig | null { return this.agentProcessManager.getAgentConfig(sessionId) } + /** + * Get full session agent state (for accessing mode/model state) + */ + getSessionAgent(sessionId: string): import('./types').SessionAgent | undefined { + return this.agentProcessManager.get(sessionId) + } + /** * Check if a session has a running agent */ diff --git a/src/main/conductor/SessionLifecycle.ts b/src/main/conductor/SessionLifecycle.ts index a4785cbc7d..66182b11ed 100644 --- a/src/main/conductor/SessionLifecycle.ts +++ b/src/main/conductor/SessionLifecycle.ts @@ -6,7 +6,7 @@ * - Loading and resuming sessions * - Deleting sessions * - Switching session agents - * - Ensuring agents are running (lazy-start) + * - Starting agents for sessions (immediate or on-demand) */ import { DEFAULT_AGENTS } from '../config/defaults' import type { @@ -51,15 +51,15 @@ export class SessionLifecycle implements ISessionLifecycle { } /** - * Create a new session (agent starts lazily on first prompt) + * Create a new session (agent starts immediately) */ async create(cwd: string, agentConfig: AgentConfig): Promise { let session: MulticaSession if (this.sessionStore) { - // Create session record - agent will be started lazily via ensureAgentForSession + // Create session record session = await this.sessionStore.create({ - agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt + agentSessionId: '', // Will be filled after agent start agentId: agentConfig.id, workingDirectory: cwd }) @@ -80,8 +80,26 @@ export class SessionLifecycle implements ISessionLifecycle { this.inMemorySession = session } - // Agent will be started lazily when user sends first prompt - console.log(`[SessionLifecycle] Created session: ${session.id} (agent: pending)`) + // Start agent immediately (isResumed = false for new session) + const { agentSessionId } = await this.agentProcessManager.start( + session.id, + agentConfig, + cwd, + false + ) + + // Update session with agentSessionId + if (this.sessionStore) { + session = await this.sessionStore.updateMeta(session.id, { + agentSessionId, + status: 'active' + }) + } else { + session.agentSessionId = agentSessionId + this.inMemorySession = session + } + + console.log(`[SessionLifecycle] Created session: ${session.id} (agent: ${agentSessionId})`) return session } @@ -174,6 +192,61 @@ export class SessionLifecycle implements ISessionLifecycle { return this.sessionStore.updateMeta(sessionId, updates) } + /** + * Start agent for a session (if not already running) + * Used when selecting historical sessions to ensure agent is running + */ + async startAgent(sessionId: string): Promise { + // If already running, return current session + if (this.agentProcessManager.get(sessionId)) { + const data = await this.sessionStore!.get(sessionId) + return data!.session + } + + if (!this.sessionStore) { + throw new Error('Session start not available in CLI mode') + } + + // Load session + const data = await this.sessionStore.get(sessionId) + if (!data) { + throw new Error(`Session not found: ${sessionId}`) + } + + // Get agent config + const agentConfig = DEFAULT_AGENTS[data.session.agentId] + if (!agentConfig) { + throw new Error(`Unknown agent: ${data.session.agentId}`) + } + + console.log(`[SessionLifecycle] Starting agent for session ${sessionId}`) + + // Start agent (isResumed = true for history replay) + const { agentSessionId } = await this.agentProcessManager.start( + sessionId, + agentConfig, + data.session.workingDirectory, + true + ) + + // Update session with new agentSessionId + const updatedSession = await this.sessionStore.updateMeta(sessionId, { + agentSessionId, + status: 'active' + }) + + // Notify frontend + if (this.events.onSessionMetaUpdated) { + this.events.onSessionMetaUpdated(updatedSession) + } + + console.log( + `[SessionLifecycle] Started agent for session: ${sessionId} (agent session: ${agentSessionId})` + ) + + return updatedSession + } + /** * Switch a session's agent (stops current, starts new) */ diff --git a/src/main/conductor/types.ts b/src/main/conductor/types.ts index 065972175c..7686f937b1 100644 --- a/src/main/conductor/types.ts +++ b/src/main/conductor/types.ts @@ -8,7 +8,9 @@ import type { ClientSideConnection, SessionNotification, RequestPermissionRequest, - RequestPermissionResponse + RequestPermissionResponse, + SessionModeState, + SessionModelState } from '@agentclientprotocol/sdk' import type { AgentProcess } from './AgentProcess' import type { @@ -36,6 +38,10 @@ export interface SessionAgent { agentSessionId: string /** Whether the first prompt needs history prepended (for resumed sessions) */ needsHistoryReplay: boolean + /** Mode state from ACP server (available modes and current mode) */ + sessionModeState: SessionModeState | null + /** Model state from ACP server (available models and current model) */ + sessionModelState: SessionModelState | null } /** @@ -226,15 +232,18 @@ export interface ISessionLifecycle { /** Initialize the lifecycle manager */ initialize(): Promise - /** Create a new session (agent starts lazily on first prompt) */ + /** Create a new session (agent starts immediately) */ create(cwd: string, agentConfig: AgentConfig): Promise - /** Load a session without starting its agent (lazy loading) */ + /** Load a session without starting its agent */ load(sessionId: string): Promise /** Resume an existing session (starts a new agent process) */ resume(sessionId: string): Promise + /** Start agent for a session (if not already running) */ + startAgent(sessionId: string): Promise + /** Delete a session and stop its agent */ delete(sessionId: string): Promise diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 2bdfa24b01..7943fcdf9c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -8,7 +8,12 @@ import { DEFAULT_AGENTS } from '../config/defaults' import { checkAgents, checkAgent } from '../utils/agent-check' import { installAgent } from '../utils/agent-install' import type { Conductor } from '../conductor/Conductor' -import type { ListSessionsOptions, MulticaSession } from '../../shared/types' +import type { + ListSessionsOptions, + MulticaSession, + SessionModeId, + ModelId +} from '../../shared/types' import type { FileTreeNode, DetectedApp } from '../../shared/electron-api' import type { MessageContent } from '../../shared/types/message' import * as fs from 'fs' @@ -152,6 +157,11 @@ export function registerIPCHandlers(conductor: Conductor): void { return withDirectoryExists(session) }) + ipcMain.handle(IPC_CHANNELS.SESSION_START_AGENT, async (_event, sessionId: string) => { + const session = await conductor.startSessionAgent(sessionId) + return withDirectoryExists(session) + }) + ipcMain.handle(IPC_CHANNELS.SESSION_DELETE, async (_event, sessionId: string) => { await conductor.deleteSession(sessionId) return { success: true } @@ -184,6 +194,68 @@ export function registerIPCHandlers(conductor: Conductor): void { } ) + // --- Mode/Model handlers --- + + ipcMain.handle(IPC_CHANNELS.SESSION_GET_MODES, async (_event, sessionId: string) => { + const sessionAgent = conductor.getSessionAgent(sessionId) + return sessionAgent?.sessionModeState ?? null + }) + + ipcMain.handle(IPC_CHANNELS.SESSION_GET_MODELS, async (_event, sessionId: string) => { + const sessionAgent = conductor.getSessionAgent(sessionId) + return sessionAgent?.sessionModelState ?? null + }) + + ipcMain.handle( + IPC_CHANNELS.SESSION_SET_MODE, + async (_event, sessionId: string, modeId: SessionModeId) => { + const sessionAgent = conductor.getSessionAgent(sessionId) + if (!sessionAgent) { + throw new Error('Session not found or agent not running') + } + + try { + // Call ACP server to set mode + await sessionAgent.connection.setSessionMode({ + sessionId: sessionAgent.agentSessionId, + modeId + }) + + // Optimistic update local state + if (sessionAgent.sessionModeState) { + sessionAgent.sessionModeState.currentModeId = modeId + } + } catch (err) { + throw new Error(extractErrorMessage(err)) + } + } + ) + + ipcMain.handle( + IPC_CHANNELS.SESSION_SET_MODEL, + async (_event, sessionId: string, modelId: ModelId) => { + const sessionAgent = conductor.getSessionAgent(sessionId) + if (!sessionAgent) { + throw new Error('Session not found or agent not running') + } + + try { + // Call ACP server to set model (unstable API) + await sessionAgent.connection.unstable_setSessionModel({ + sessionId: sessionAgent.agentSessionId, + modelId + }) + + // Optimistic update local state + if (sessionAgent.sessionModelState) { + sessionAgent.sessionModelState.currentModelId = modelId + } + } catch (err) { + throw new Error(extractErrorMessage(err)) + } + } + ) + // --- Configuration handlers --- ipcMain.handle(IPC_CHANNELS.CONFIG_GET, async () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 32134163ad..072be8e3ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { IPC_CHANNELS } from '../shared/ipc-channels' import type { ElectronAPI, OpenWithOptions } from '../shared/electron-api' -import type { ListSessionsOptions, MulticaSession } from '../shared/types' +import type { ListSessionsOptions, MulticaSession, SessionModeId, ModelId } from '../shared/types' import type { MessageContent } from '../shared/types/message' // Electron API exposed to renderer process @@ -26,6 +26,9 @@ const electronAPI: ElectronAPI = { loadSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_LOAD, sessionId), + startSessionAgent: (sessionId: string) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_START_AGENT, sessionId), + resumeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_RESUME, sessionId), deleteSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_DELETE, sessionId), @@ -36,6 +39,19 @@ const electronAPI: ElectronAPI = { switchSessionAgent: (sessionId: string, newAgentId: string) => ipcRenderer.invoke(IPC_CHANNELS.SESSION_SWITCH_AGENT, sessionId, newAgentId), + // Mode/Model management + getSessionModes: (sessionId: string) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_GET_MODES, sessionId), + + getSessionModels: (sessionId: string) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_GET_MODELS, sessionId), + + setSessionMode: (sessionId: string, modeId: SessionModeId) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_SET_MODE, sessionId, modeId), + + setSessionModel: (sessionId: string, modelId: ModelId) => + ipcRenderer.invoke(IPC_CHANNELS.SESSION_SET_MODEL, sessionId, modelId), + // Configuration getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.CONFIG_GET), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b9e04fbb57..606c240294 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -27,6 +27,8 @@ function AppContent(): React.JSX.Element { runningSessionsStatus, isProcessing, isInitializing, + sessionModeState, + sessionModelState, isSwitchingAgent, // Actions @@ -36,7 +38,9 @@ function AppContent(): React.JSX.Element { clearCurrentSession, sendPrompt, cancelRequest, - switchSessionAgent + switchSessionAgent, + setSessionMode, + setSessionModel } = useApp() // UI state @@ -154,7 +158,7 @@ function AppContent(): React.JSX.Element {
{/* Chat scroll area - only messages */}
-
+
diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx index abbd4bdd7f..ac336ad100 100644 --- a/src/renderer/src/components/MessageInput.tsx +++ b/src/renderer/src/components/MessageInput.tsx @@ -6,7 +6,15 @@ import { ArrowUp, Square, Paperclip, X, AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { AgentSelector } from './AgentSelector' +import { ModeSelector } from './ModeSelector' +import { ModelSelector } from './ModelSelector' import type { MessageContent, ImageContentItem } from '../../../shared/types/message' +import type { + SessionModeState, + SessionModelState, + SessionModeId, + ModelId +} from '../../../shared/types' interface MessageInputProps { onSend: (content: MessageContent) => void @@ -20,6 +28,11 @@ interface MessageInputProps { isSwitchingAgent?: boolean directoryExists?: boolean onDeleteSession?: () => void + // Mode/Model props + sessionModeState?: SessionModeState | null + sessionModelState?: SessionModelState | null + onModeChange?: (modeId: SessionModeId) => void + onModelChange?: (modelId: ModelId) => void } const MAX_IMAGE_SIZE = 10 * 1024 * 1024 // 10MB @@ -65,7 +78,11 @@ export function MessageInput({ onAgentChange, isSwitchingAgent = false, directoryExists, - onDeleteSession + onDeleteSession, + sessionModeState, + sessionModelState, + onModeChange, + onModelChange }: MessageInputProps) { const [value, setValue] = useState('') const [isComposing, setIsComposing] = useState(false) @@ -271,7 +288,7 @@ export function MessageInput({ {/* Bottom toolbar */}
- {/* Left side: agent selector, folder, and image button */} + {/* Left side: agent selector, mode/model selectors, and image button */}
{/* Agent selector */} {currentAgentId && onAgentChange && ( @@ -283,6 +300,24 @@ export function MessageInput({ /> )} + {/* Mode selector (only shown if agent supports modes) */} + {sessionModeState && onModeChange && ( + + )} + + {/* Model selector (only shown if agent supports model selection) */} + {sessionModelState && onModelChange && ( + + )} + {/* Image upload button */} diff --git a/src/renderer/src/components/ModeSelector.tsx b/src/renderer/src/components/ModeSelector.tsx new file mode 100644 index 0000000000..552cad82e9 --- /dev/null +++ b/src/renderer/src/components/ModeSelector.tsx @@ -0,0 +1,75 @@ +/** + * Mode selector dropdown for MessageInput + * Shows available session modes from the ACP server + */ +import { useState } from 'react' +import { ChevronDown } from 'lucide-react' +import type { SessionModeState, SessionModeId } from '../../../shared/types' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +interface ModeSelectorProps { + modeState: SessionModeState | null + onModeChange: (modeId: SessionModeId) => void + disabled?: boolean +} + +export function ModeSelector({ modeState, onModeChange, disabled = false }: ModeSelectorProps) { + const [open, setOpen] = useState(false) + + // Don't render if no mode state (agent doesn't support modes) + if (!modeState) { + return null + } + + const currentMode = modeState.availableModes.find((m) => m.id === modeState.currentModeId) + const currentModeName = currentMode?.name || modeState.currentModeId + + function handleSelect(modeId: SessionModeId) { + if (modeId !== modeState?.currentModeId) { + onModeChange(modeId) + } + setOpen(false) + } + + return ( + + + + + + {modeState.availableModes.map((mode) => { + const isSelected = mode.id === modeState.currentModeId + + return ( + handleSelect(mode.id)} + className="flex items-center justify-between gap-2" + > + {mode.name} + {isSelected && } + + ) + })} + + + ) +} diff --git a/src/renderer/src/components/ModelSelector.tsx b/src/renderer/src/components/ModelSelector.tsx new file mode 100644 index 0000000000..da9e87596b --- /dev/null +++ b/src/renderer/src/components/ModelSelector.tsx @@ -0,0 +1,77 @@ +/** + * Model selector dropdown for MessageInput + * Shows available AI models from the ACP server + */ +import { useState } from 'react' +import { ChevronDown } from 'lucide-react' +import type { SessionModelState, ModelId } from '../../../shared/types' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +interface ModelSelectorProps { + modelState: SessionModelState | null + onModelChange: (modelId: ModelId) => void + disabled?: boolean +} + +export function ModelSelector({ modelState, onModelChange, disabled = false }: ModelSelectorProps) { + const [open, setOpen] = useState(false) + + // Don't render if no model state (agent doesn't support model selection) + if (!modelState) { + return null + } + + const currentModel = modelState.availableModels.find( + (m) => m.modelId === modelState.currentModelId + ) + const currentModelName = currentModel?.name || modelState.currentModelId + + function handleSelect(modelId: ModelId) { + if (modelId !== modelState?.currentModelId) { + onModelChange(modelId) + } + setOpen(false) + } + + return ( + + + + + + {modelState.availableModels.map((model) => { + const isSelected = model.modelId === modelState.currentModelId + + return ( + handleSelect(model.modelId)} + className="flex items-center justify-between gap-2" + > + {model.name} + {isSelected && } + + ) + })} + + + ) +} diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 3b3236e02f..924711d676 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -2,7 +2,14 @@ * Main application state hook */ import { useState, useEffect, useCallback, useRef } from 'react' -import type { MulticaSession, StoredSessionUpdate } from '../../../shared/types' +import type { + MulticaSession, + StoredSessionUpdate, + SessionModeState, + SessionModelState, + SessionModeId, + ModelId +} from '../../../shared/types' import type { RunningSessionsStatus } from '../../../shared/electron-api' import type { MessageContent } from '../../../shared/types/message' import { usePermissionStore } from '../stores/permissionStore' @@ -45,6 +52,10 @@ export interface AppState { isProcessing: boolean isInitializing: boolean + // Mode/Model (from ACP server) + sessionModeState: SessionModeState | null + sessionModelState: SessionModelState | null + // UI isSwitchingAgent: boolean } @@ -61,6 +72,10 @@ export interface AppActions { sendPrompt: (content: MessageContent) => Promise cancelRequest: () => Promise switchSessionAgent: (newAgentId: string) => Promise + + // Mode/Model actions + setSessionMode: (modeId: SessionModeId) => Promise + setSessionModel: (modelId: ModelId) => Promise } export function useApp(): AppState & AppActions { @@ -82,6 +97,8 @@ export function useApp(): AppState & AppActions { }) const [isInitializing, setIsInitializing] = useState(false) const [isSwitchingAgent, setIsSwitchingAgent] = useState(false) + const [sessionModeState, setSessionModeState] = useState(null) + const [sessionModelState, setSessionModelState] = useState(null) // Derive isProcessing from processingSessionIds (per-session isolation) const isProcessing = currentSession @@ -227,6 +244,23 @@ export function useApp(): AppState & AppActions { } }, []) + // Load mode/model state for current session (if agent supports it) + const loadSessionModeModel = useCallback(async (sessionId: string) => { + try { + const [modes, models] = await Promise.all([ + window.electronAPI.getSessionModes(sessionId), + window.electronAPI.getSessionModels(sessionId) + ]) + setSessionModeState(modes) + setSessionModelState(models) + } catch (err) { + console.error('Failed to load session mode/model:', err) + // Reset to null on error (agent may not support modes/models) + setSessionModeState(null) + setSessionModelState(null) + } + }, []) + // Validate current session directory exists (called on app focus) const validateCurrentSessionDirectory = useCallback(async () => { if (!currentSession) return @@ -270,41 +304,62 @@ export function useApp(): AppState & AppActions { setSessionUpdates([]) await loadSessions() await loadRunningStatus() + // Agent starts immediately now, load mode/model state + await loadSessionModeModel(session.id) } catch (err) { toast.error(`Failed to create session: ${getErrorMessage(err)}`) } finally { setIsInitializing(false) } }, - [loadSessions, loadRunningStatus] + [loadSessions, loadRunningStatus, loadSessionModeModel] ) - const selectSession = useCallback(async (sessionId: string) => { - try { - // Mark this as the pending session (for rapid switching protection) - pendingSessionRef.current = sessionId + const selectSession = useCallback( + async (sessionId: string) => { + try { + // Mark this as the pending session (for rapid switching protection) + pendingSessionRef.current = sessionId - // Load session and history in parallel - const [session, data] = await Promise.all([ - window.electronAPI.loadSession(sessionId), - window.electronAPI.getSession(sessionId) - ]) + // Load session and history in parallel + const [session, data] = await Promise.all([ + window.electronAPI.loadSession(sessionId), + window.electronAPI.getSession(sessionId) + ]) - // Verify: user might have switched to another session while loading - if (pendingSessionRef.current !== sessionId) { - return // Discard, user already switched elsewhere + // Verify: user might have switched to another session while loading + if (pendingSessionRef.current !== sessionId) { + return // Discard, user already switched elsewhere + } + + // Update both states together - React will batch them into one render + setCurrentSession(session) + setSessionUpdates(data?.updates ?? []) + + // Start agent if not already running + const status = await window.electronAPI.getAgentStatus() + if (!status.sessionIds.includes(sessionId)) { + setIsInitializing(true) + try { + const updatedSession = await window.electronAPI.startSessionAgent(sessionId) + if (pendingSessionRef.current !== sessionId) return + setCurrentSession(updatedSession) + } finally { + setIsInitializing(false) + } + } + + // Agent now guaranteed running, load mode/model + await loadSessionModeModel(sessionId) + } catch (err) { + // Only show error if this is still the pending session + if (pendingSessionRef.current === sessionId) { + toast.error(`Failed to select session: ${getErrorMessage(err)}`) + } } - - // Update both states together - React will batch them into one render - setCurrentSession(session) - setSessionUpdates(data?.updates ?? []) - } catch (err) { - // Only show error if this is still the pending session - if (pendingSessionRef.current === sessionId) { - toast.error(`Failed to select session: ${getErrorMessage(err)}`) - } - } - }, []) + }, + [loadSessionModeModel] + ) const deleteSession = useCallback( async (sessionId: string) => { @@ -326,6 +381,8 @@ export function useApp(): AppState & AppActions { const clearCurrentSession = useCallback(() => { setCurrentSession(null) setSessionUpdates([]) + setSessionModeState(null) + setSessionModelState(null) }, []) const sendPrompt = useCallback( @@ -351,6 +408,10 @@ export function useApp(): AppState & AppActions { setSessionUpdates((prev) => [...prev, userUpdate]) await window.electronAPI.sendPrompt(currentSession.id, content) + + // After successful prompt, agent is guaranteed to be running + // Reload mode/model state (important for lazy-started sessions) + await loadSessionModeModel(currentSession.id) } catch (err) { const errorMessage = getErrorMessage(err) @@ -380,7 +441,7 @@ export function useApp(): AppState & AppActions { } } }, - [currentSession] + [currentSession, loadSessionModeModel] ) const cancelRequest = useCallback(async () => { @@ -408,6 +469,8 @@ export function useApp(): AppState & AppActions { ) setCurrentSession(updatedSession) await loadRunningStatus() + // Reload mode/model state for the new agent + await loadSessionModeModel(currentSession.id) toast.success(`Successfully switched to ${newAgentId}`) } catch (err) { toast.error(`Failed to switch agent: ${getErrorMessage(err)}`) @@ -415,7 +478,44 @@ export function useApp(): AppState & AppActions { setIsSwitchingAgent(false) } }, - [currentSession, loadRunningStatus] + [currentSession, loadRunningStatus, loadSessionModeModel] + ) + + // Mode/Model actions + const setSessionMode = useCallback( + async (modeId: SessionModeId) => { + if (!currentSession) { + toast.error('No active session') + return + } + + try { + await window.electronAPI.setSessionMode(currentSession.id, modeId) + // Optimistic update + setSessionModeState((prev) => (prev ? { ...prev, currentModeId: modeId } : null)) + } catch (err) { + toast.error(`Failed to set mode: ${getErrorMessage(err)}`) + } + }, + [currentSession] + ) + + const setSessionModel = useCallback( + async (modelId: ModelId) => { + if (!currentSession) { + toast.error('No active session') + return + } + + try { + await window.electronAPI.setSessionModel(currentSession.id, modelId) + // Optimistic update + setSessionModelState((prev) => (prev ? { ...prev, currentModelId: modelId } : null)) + } catch (err) { + toast.error(`Failed to set model: ${getErrorMessage(err)}`) + } + }, + [currentSession] ) return { @@ -426,6 +526,8 @@ export function useApp(): AppState & AppActions { runningSessionsStatus, isProcessing, isInitializing, + sessionModeState, + sessionModelState, isSwitchingAgent, // Actions @@ -436,6 +538,8 @@ export function useApp(): AppState & AppActions { clearCurrentSession, sendPrompt, cancelRequest, - switchSessionAgent + switchSessionAgent, + setSessionMode, + setSessionModel } } diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 22092c56c5..44e650665d 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -6,7 +6,11 @@ import type { AppConfig, MulticaSession, SessionData, - ListSessionsOptions + ListSessionsOptions, + SessionModeState, + SessionModelState, + SessionModeId, + ModelId } from './types' import type { MessageContent } from './types/message' @@ -161,11 +165,18 @@ export interface ElectronAPI { listSessions(options?: ListSessionsOptions): Promise getSession(sessionId: string): Promise loadSession(sessionId: string): Promise // Load without starting agent + startSessionAgent(sessionId: string): Promise // Start agent for a session resumeSession(sessionId: string): Promise deleteSession(sessionId: string): Promise<{ success: boolean }> updateSession(sessionId: string, updates: Partial): Promise switchSessionAgent(sessionId: string, newAgentId: string): Promise + // Mode/Model management + getSessionModes(sessionId: string): Promise + getSessionModels(sessionId: string): Promise + setSessionMode(sessionId: string, modeId: SessionModeId): Promise + setSessionModel(sessionId: string, modelId: ModelId): Promise + // Configuration getConfig(): Promise updateConfig(config: Partial): Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 59d83c4d97..da74e291df 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -13,12 +13,17 @@ export const IPC_CHANNELS = { SESSION_CREATE: 'session:create', SESSION_LIST: 'session:list', SESSION_GET: 'session:get', - SESSION_LOAD: 'session:load', // Load session without starting agent (lazy) + SESSION_LOAD: 'session:load', // Load session without starting agent + SESSION_START_AGENT: 'session:start-agent', // Start agent for a session SESSION_RESUME: 'session:resume', SESSION_DELETE: 'session:delete', SESSION_UPDATE: 'session:update', SESSION_SWITCH_AGENT: 'session:switch-agent', SESSION_META_UPDATED: 'session:meta-updated', // Push event when session metadata changes (e.g., agentSessionId) + SESSION_GET_MODES: 'session:get-modes', // Get current session's available modes + SESSION_GET_MODELS: 'session:get-models', // Get current session's available models + SESSION_SET_MODE: 'session:set-mode', // Set session mode + SESSION_SET_MODEL: 'session:set-model', // Set session model // Configuration CONFIG_GET: 'config:get', diff --git a/src/shared/types.ts b/src/shared/types.ts index a359b34e25..88ac246ff5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -81,3 +81,7 @@ export interface FileApprovalResponse { // Re-export session types export * from './types/session' + +// Re-export mode/model types from ACP SDK for frontend use +export * from './types/mode' +export * from './types/model' diff --git a/src/shared/types/mode.ts b/src/shared/types/mode.ts new file mode 100644 index 0000000000..2847884ca7 --- /dev/null +++ b/src/shared/types/mode.ts @@ -0,0 +1,4 @@ +/** + * Mode types - re-exported from ACP SDK for frontend use + */ +export type { SessionModeId, SessionMode, SessionModeState } from '@agentclientprotocol/sdk' diff --git a/src/shared/types/model.ts b/src/shared/types/model.ts new file mode 100644 index 0000000000..c643506780 --- /dev/null +++ b/src/shared/types/model.ts @@ -0,0 +1,4 @@ +/** + * Model types - re-exported from ACP SDK for frontend use + */ +export type { ModelId, ModelInfo, SessionModelState } from '@agentclientprotocol/sdk' diff --git a/tests/integration/conductor/Conductor.test.ts b/tests/integration/conductor/Conductor.test.ts index b012495e21..495652e477 100644 --- a/tests/integration/conductor/Conductor.test.ts +++ b/tests/integration/conductor/Conductor.test.ts @@ -124,11 +124,11 @@ describe('Conductor', () => { it('should track running sessions', async () => { const session = await conductor.createSession('/test/project', mockAgentConfig) - // Session is not running immediately after creation (lazy start) - expect(conductor.isSessionRunning(session.id)).toBe(false) - expect(conductor.getRunningSessionIds()).not.toContain(session.id) + // Session is running immediately after creation (immediate start) + expect(conductor.isSessionRunning(session.id)).toBe(true) + expect(conductor.getRunningSessionIds()).toContain(session.id) - // After sending a prompt, agent starts and session becomes running + // After sending a prompt, session is still running await conductor.sendPrompt(session.id, [{ type: 'text', text: 'Hello' }]) expect(conductor.isSessionRunning(session.id)).toBe(true) expect(conductor.getRunningSessionIds()).toContain(session.id) @@ -146,14 +146,10 @@ describe('Conductor', () => { const session1 = await conductor.createSession('/test/project1', mockAgentConfig) const session2 = await conductor.createSession('/test/project2', mockAgentConfig) - // Sessions are not running until prompts are sent (lazy start) - expect(conductor.getRunningSessionIds().length).toBe(0) - - // Start agents by sending prompts - await conductor.sendPrompt(session1.id, [{ type: 'text', text: 'Hello' }]) - await conductor.sendPrompt(session2.id, [{ type: 'text', text: 'Hello' }]) - + // Sessions are running immediately after creation (immediate start) expect(conductor.getRunningSessionIds().length).toBe(2) + expect(conductor.getRunningSessionIds()).toContain(session1.id) + expect(conductor.getRunningSessionIds()).toContain(session2.id) await conductor.stopAllSessions()