From 704fb5ae164046821e46b1caa1e47cf3bcd1ec35 Mon Sep 17 00:00:00 2001 From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:10:11 +0900 Subject: [PATCH] Add experimental session model selection support Introduces support for selecting AI models per session, including new types and UI for model selection. Updates the domain model, agent client port, and adapter to handle available models and current model state. Adds a model selector dropdown to the chat input, updates the session hook for model changes, and styles the new selector. --- src/adapters/acp/acp.adapter.ts | 50 +++++++++++++++ src/components/chat/ChatInput.tsx | 92 ++++++++++++++++++++++++++- src/components/chat/ChatView.tsx | 2 + src/domain/models/chat-session.ts | 43 +++++++++++++ src/domain/ports/agent-client.port.ts | 15 +++++ src/hooks/useAgentSession.ts | 64 ++++++++++++++++++- styles.css | 60 +++++++++++++++++ 7 files changed, 324 insertions(+), 2 deletions(-) diff --git a/src/adapters/acp/acp.adapter.ts b/src/adapters/acp/acp.adapter.ts index df2c241..bcd36c3 100644 --- a/src/adapters/acp/acp.adapter.ts +++ b/src/adapters/acp/acp.adapter.ts @@ -22,6 +22,7 @@ import type AgentClientPlugin from "../../plugin"; import type { SlashCommand, SessionModeState, + SessionModelState, } from "src/domain/models/chat-session"; import { wrapCommandForWsl, @@ -518,9 +519,29 @@ export class AcpAdapter implements IAgentClient, IAcpClient { ); } + // Convert models from ACP format to domain format (experimental) + let models: SessionModelState | undefined; + if (sessionResult.models) { + models = { + availableModels: sessionResult.models.availableModels.map( + (m) => ({ + modelId: m.modelId, + name: m.name, + // Convert null to undefined for type compatibility + description: m.description ?? undefined, + }), + ), + currentModelId: sessionResult.models.currentModelId, + }; + this.logger.log( + `[AcpAdapter] Session models: ${models.availableModels.map((m) => m.modelId).join(", ")} (current: ${models.currentModelId})`, + ); + } + return { sessionId: sessionResult.sessionId, modes, + models, }; } catch (error) { this.logger.error("[AcpAdapter] New Session Error:", error); @@ -820,6 +841,35 @@ export class AcpAdapter implements IAgentClient, IAcpClient { } } + /** + * Implementation of IAgentClient.setSessionModel() + */ + async setSessionModel(sessionId: string, modelId: string): Promise { + if (!this.connection) { + throw new Error( + "Connection not initialized. Call initialize() first.", + ); + } + + this.logger.log( + `[AcpAdapter] Setting session model to: ${modelId} for session: ${sessionId}`, + ); + + try { + await this.connection.setSessionModel({ + sessionId, + modelId, + }); + this.logger.log(`[AcpAdapter] Session model set to: ${modelId}`); + } catch (error) { + this.logger.error( + "[AcpAdapter] Failed to set session model:", + error, + ); + throw error; + } + } + /** * Register a callback to receive chat messages from the agent. */ diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 61423dd..6445b22 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -8,6 +8,7 @@ import type { NoteMetadata } from "../../domain/ports/vault-access.port"; import type { SlashCommand, SessionModeState, + SessionModelState, } from "../../domain/models/chat-session"; import type { UseMentionsReturn } from "../../hooks/useMentions"; import type { UseSlashCommandsReturn } from "../../hooks/useSlashCommands"; @@ -51,6 +52,10 @@ export interface ChatInputProps { modes?: SessionModeState; /** Callback when mode is changed */ onModeChange?: (modeId: string) => void; + /** Session model state (available models and current model) - experimental */ + models?: SessionModelState; + /** Callback when model is changed */ + onModelChange?: (modelId: string) => void; } /** @@ -82,6 +87,8 @@ export function ChatInput({ onRestoredMessageConsumed, modes, onModeChange, + models, + onModelChange, }: ChatInputProps) { const logger = useMemo(() => new Logger(plugin), [plugin]); @@ -95,6 +102,8 @@ export function ChatInput({ const sendButtonRef = useRef(null); const modeDropdownRef = useRef(null); const modeDropdownInstance = useRef(null); + const modelDropdownRef = useRef(null); + const modelDropdownInstance = useRef(null); /** * Common logic for setting cursor position after text replacement. @@ -495,6 +504,67 @@ export function ChatInput({ } }, [currentModeId]); + // Stable references for model callbacks + const onModelChangeRef = useRef(onModelChange); + onModelChangeRef.current = onModelChange; + + // Initialize Model dropdown (only when availableModels change) + const availableModels = models?.availableModels; + const currentModelId = models?.currentModelId; + + useEffect(() => { + const containerEl = modelDropdownRef.current; + if (!containerEl) return; + + // Only show dropdown if there are multiple models + if (!availableModels || availableModels.length <= 1) { + // Clean up existing dropdown if models become unavailable + if (modelDropdownInstance.current) { + containerEl.empty(); + modelDropdownInstance.current = null; + } + return; + } + + // Create dropdown if not exists + if (!modelDropdownInstance.current) { + const dropdown = new DropdownComponent(containerEl); + modelDropdownInstance.current = dropdown; + + // Add options + for (const model of availableModels) { + dropdown.addOption(model.modelId, model.name); + } + + // Set initial value + if (currentModelId) { + dropdown.setValue(currentModelId); + } + + // Handle change - use ref to avoid recreating dropdown on callback change + dropdown.onChange((value) => { + if (onModelChangeRef.current) { + onModelChangeRef.current(value); + } + }); + } + + // Cleanup on unmount or when availableModels change + return () => { + if (modelDropdownInstance.current) { + containerEl.empty(); + modelDropdownInstance.current = null; + } + }; + }, [availableModels]); + + // Update dropdown value when currentModelId changes (separate effect) + useEffect(() => { + if (modelDropdownInstance.current && currentModelId) { + modelDropdownInstance.current.setValue(currentModelId); + } + }, [currentModelId]); + // Button disabled state const isButtonDisabled = !isSending && (inputValue.trim() === "" || !isSessionReady); @@ -597,7 +667,7 @@ export function ChatInput({ )} - {/* Input Actions (Mode Selector + Send Button) */} + {/* Input Actions (Mode Selector + Model Selector + Send Button) */}
{/* Mode Selector */} {modes && modes.availableModes.length > 1 && ( @@ -619,6 +689,26 @@ export function ChatInput({
)} + {/* Model Selector (experimental) */} + {models && models.availableModels.length > 1 && ( +
m.modelId === models.currentModelId, + )?.description ?? "Select model" + } + > + { + if (el) setIcon(el, "chevron-down"); + }} + /> +
+ )} + {/* Send/Stop Button */}