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()