mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
Merge pull request #67 from multica-ai/feat/dynamic-agent-modes
feat: add dynamic session mode and model selection
This commit is contained in:
@@ -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}`})`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
75
src/renderer/src/components/ModeSelector.tsx
Normal file
75
src/renderer/src/components/ModeSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/renderer/src/components/ModelSelector.tsx
Normal file
77
src/renderer/src/components/ModelSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
13
src/shared/electron-api.d.ts
vendored
13
src/shared/electron-api.d.ts
vendored
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
4
src/shared/types/mode.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Mode types - re-exported from ACP SDK for frontend use
|
||||
*/
|
||||
export type { SessionModeId, SessionMode, SessionModeState } from '@agentclientprotocol/sdk'
|
||||
4
src/shared/types/model.ts
Normal file
4
src/shared/types/model.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Model types - re-exported from ACP SDK for frontend use
|
||||
*/
|
||||
export type { ModelId, ModelInfo, SessionModelState } from '@agentclientprotocol/sdk'
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user