Merge pull request #67 from multica-ai/feat/dynamic-agent-modes

feat: add dynamic session mode and model selection
This commit is contained in:
LinYushen
2026-01-20 14:43:54 +08:00
committed by GitHub
19 changed files with 603 additions and 62 deletions

View File

@@ -416,7 +416,7 @@ async function cmdStatus(state: CLIState): Promise<void> {
// 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}`})`

View File

@@ -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<RequestPermissionResponse>
/** 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)
}

View File

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

View File

@@ -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<MulticaSession> {
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<MulticaSession> {
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
*/

View File

@@ -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<MulticaSession> {
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<MulticaSession> {
// 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)
*/

View File

@@ -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<void>
/** Create a new session (agent starts lazily on first prompt) */
/** Create a new session (agent starts immediately) */
create(cwd: string, agentConfig: AgentConfig): Promise<MulticaSession>
/** Load a session without starting its agent (lazy loading) */
/** Load a session without starting its agent */
load(sessionId: string): Promise<MulticaSession>
/** Resume an existing session (starts a new agent process) */
resume(sessionId: string): Promise<MulticaSession>
/** Start agent for a session (if not already running) */
startAgent(sessionId: string): Promise<MulticaSession>
/** Delete a session and stop its agent */
delete(sessionId: string): Promise<void>

View File

@@ -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 () => {

View File

@@ -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),

View File

@@ -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 {
<div className="relative flex-1 overflow-hidden flex flex-col">
{/* Chat scroll area - only messages */}
<div ref={containerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4">
<div className="mx-auto max-w-3xl pb-12 px-8">
<div className="mx-auto max-w-3xl pb-12 px-8 min-h-full flex flex-col">
<ChatView
updates={sessionUpdates}
isProcessing={isProcessing}
@@ -201,6 +205,10 @@ function AppContent(): React.JSX.Element {
isSwitchingAgent={isSwitchingAgent}
directoryExists={currentSession?.directoryExists}
onDeleteSession={handleDeleteCurrentSession}
sessionModeState={sessionModeState}
sessionModelState={sessionModelState}
onModeChange={setSessionMode}
onModelChange={setSessionModel}
/>
</div>
</div>

View File

@@ -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 */}
<div className="flex items-center justify-between pt-2">
{/* Left side: agent selector, folder, and image button */}
{/* Left side: agent selector, mode/model selectors, and image button */}
<div className="flex items-center gap-1">
{/* Agent selector */}
{currentAgentId && onAgentChange && (
@@ -283,6 +300,24 @@ export function MessageInput({
/>
)}
{/* Mode selector (only shown if agent supports modes) */}
{sessionModeState && onModeChange && (
<ModeSelector
modeState={sessionModeState}
onModeChange={onModeChange}
disabled={isProcessing}
/>
)}
{/* Model selector (only shown if agent supports model selection) */}
{sessionModelState && onModelChange && (
<ModelSelector
modelState={sessionModelState}
onModelChange={onModelChange}
disabled={isProcessing}
/>
)}
{/* Image upload button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
className={cn(
'flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-background/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<span className="max-w-[140px] truncate">{currentModeName}</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="min-w-[140px] max-h-[300px] overflow-y-auto"
>
{modeState.availableModes.map((mode) => {
const isSelected = mode.id === modeState.currentModeId
return (
<DropdownMenuItem
key={mode.id}
onClick={() => handleSelect(mode.id)}
className="flex items-center justify-between gap-2"
>
<span>{mode.name}</span>
{isSelected && <span className="h-2 w-2 rounded-full bg-primary" />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
className={cn(
'flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-background/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<span className="max-w-[140px] truncate">{currentModelName}</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="min-w-[160px] max-h-[300px] overflow-y-auto"
>
{modelState.availableModels.map((model) => {
const isSelected = model.modelId === modelState.currentModelId
return (
<DropdownMenuItem
key={model.modelId}
onClick={() => handleSelect(model.modelId)}
className="flex items-center justify-between gap-2"
>
<span>{model.name}</span>
{isSelected && <span className="h-2 w-2 rounded-full bg-primary" />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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<void>
cancelRequest: () => Promise<void>
switchSessionAgent: (newAgentId: string) => Promise<void>
// Mode/Model actions
setSessionMode: (modeId: SessionModeId) => Promise<void>
setSessionModel: (modelId: ModelId) => Promise<void>
}
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<SessionModeState | null>(null)
const [sessionModelState, setSessionModelState] = useState<SessionModelState | null>(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
}
}

View File

@@ -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<MulticaSession[]>
getSession(sessionId: string): Promise<SessionData | null>
loadSession(sessionId: string): Promise<MulticaSession> // Load without starting agent
startSessionAgent(sessionId: string): Promise<MulticaSession> // Start agent for a session
resumeSession(sessionId: string): Promise<MulticaSession>
deleteSession(sessionId: string): Promise<{ success: boolean }>
updateSession(sessionId: string, updates: Partial<MulticaSession>): Promise<MulticaSession>
switchSessionAgent(sessionId: string, newAgentId: string): Promise<MulticaSession>
// Mode/Model management
getSessionModes(sessionId: string): Promise<SessionModeState | null>
getSessionModels(sessionId: string): Promise<SessionModelState | null>
setSessionMode(sessionId: string, modeId: SessionModeId): Promise<void>
setSessionModel(sessionId: string, modelId: ModelId): Promise<void>
// Configuration
getConfig(): Promise<AppConfig>
updateConfig(config: Partial<AppConfig>): Promise<AppConfig>

View File

@@ -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',

View File

@@ -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'

4
src/shared/types/mode.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Mode types - re-exported from ACP SDK for frontend use
*/
export type { SessionModeId, SessionMode, SessionModeState } from '@agentclientprotocol/sdk'

View File

@@ -0,0 +1,4 @@
/**
* Model types - re-exported from ACP SDK for frontend use
*/
export type { ModelId, ModelInfo, SessionModelState } from '@agentclientprotocol/sdk'

View File

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