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.
This commit is contained in:
RAIT-09 2025-11-29 16:10:11 +09:00
parent 948119ff60
commit 704fb5ae16
7 changed files with 324 additions and 2 deletions

View file

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

View file

@ -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<HTMLButtonElement>(null);
const modeDropdownRef = useRef<HTMLDivElement>(null);
const modeDropdownInstance = useRef<DropdownComponent | null>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const modelDropdownInstance = useRef<DropdownComponent | null>(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({
)}
</div>
{/* Input Actions (Mode Selector + Send Button) */}
{/* Input Actions (Mode Selector + Model Selector + Send Button) */}
<div className="chat-input-actions">
{/* Mode Selector */}
{modes && modes.availableModes.length > 1 && (
@ -619,6 +689,26 @@ export function ChatInput({
</div>
)}
{/* Model Selector (experimental) */}
{models && models.availableModels.length > 1 && (
<div
ref={modelDropdownRef}
className="model-selector"
title={
models.availableModels.find(
(m) => m.modelId === models.currentModelId,
)?.description ?? "Select model"
}
>
<span
className="model-selector-icon"
ref={(el) => {
if (el) setIcon(el, "chevron-down");
}}
/>
</div>
)}
{/* Send/Stop Button */}
<button
ref={sendButtonRef}

View file

@ -518,6 +518,8 @@ function ChatComponent({
onRestoredMessageConsumed={handleRestoredMessageConsumed}
modes={session.modes}
onModeChange={(modeId) => void agentSession.setMode(modeId)}
models={session.models}
onModelChange={(modelId) => void agentSession.setModel(modelId)}
/>
</div>
);

View file

@ -120,6 +120,42 @@ export interface SessionModeState {
currentModeId: string;
}
// ============================================================================
// Model (Experimental)
// ============================================================================
/**
* Represents an AI model available in a session.
*
* Models determine which AI model is used for responses.
* This is an experimental feature and may change.
*/
export interface SessionModel {
/** Unique identifier for this model (e.g., "claude-sonnet-4") */
modelId: string;
/** Human-readable name for display */
name: string;
/** Optional description of this model */
description?: string;
}
/**
* State of available models in a session.
*
* Contains both the list of available models and the currently active model.
* Updated via NewSessionResponse initially.
* Note: Unlike modes, there is no dedicated notification for model changes.
*/
export interface SessionModelState {
/** List of models available in this session */
availableModels: SessionModel[];
/** ID of the currently active model */
currentModelId: string;
}
// ============================================================================
// Chat Session
// ============================================================================
@ -166,6 +202,13 @@ export interface ChatSession {
*/
modes?: SessionModeState;
/**
* Model state for this session (experimental).
* Contains available models and the currently active model.
* Updated via NewSessionResponse initially.
*/
models?: SessionModelState;
/** Timestamp when the session was created */
createdAt: Date;

View file

@ -16,6 +16,7 @@ import type { AgentError } from "../models/agent-error";
import type {
AuthenticationMethod,
SessionModeState,
SessionModelState,
} from "../models/chat-session";
/**
@ -101,6 +102,13 @@ export interface NewSessionResult {
* Undefined if the agent does not support modes.
*/
modes?: SessionModeState;
/**
* Model state for this session (experimental).
* Contains available models and the currently active model.
* Undefined if the agent does not support model selection.
*/
models?: SessionModelState;
}
/**
@ -247,4 +255,11 @@ export interface IAgentClient {
* @throws Error if connection is not initialized or mode is invalid
*/
setSessionMode(sessionId: string, modeId: string): Promise<void>;
/**
* Set the session model (experimental).
* @param sessionId - The session ID
* @param modelId - The model ID to set
*/
setSessionModel(sessionId: string, modelId: string): Promise<void>;
}

View file

@ -106,6 +106,13 @@ export interface UseAgentSessionReturn {
* @param modeId - ID of the mode to set
*/
setMode: (modeId: string) => Promise<void>;
/**
* Set the session model (experimental).
* Sends a request to the agent to change the model.
* @param modelId - ID of the model to set
*/
setModel: (modelId: string) => Promise<void>;
}
// ============================================================================
@ -253,6 +260,7 @@ function createInitialSession(
authMethods: [],
availableCommands: undefined,
modes: undefined,
models: undefined,
createdAt: new Date(),
lastActivityAt: new Date(),
workingDirectory,
@ -318,6 +326,7 @@ export function useAgentSession(
authMethods: [],
availableCommands: undefined,
modes: undefined,
models: undefined,
createdAt: new Date(),
lastActivityAt: new Date(),
}));
@ -371,6 +380,7 @@ export function useAgentSession(
state: "ready",
authMethods: authMethods,
modes: sessionResult.modes,
models: sessionResult.models,
lastActivityAt: new Date(),
}));
} catch (error) {
@ -461,12 +471,13 @@ export function useAgentSession(
await settingsAccess.updateSettings({ activeAgentId: agentId });
// Update session with new agent ID
// Clear availableCommands and modes (new agent will send its own)
// Clear availableCommands, modes, and models (new agent will send its own)
setSession((prev) => ({
...prev,
agentId,
availableCommands: undefined,
modes: undefined,
models: undefined,
}));
},
[settingsAccess],
@ -562,6 +573,56 @@ export function useAgentSession(
[agentClient, session.sessionId, session.modes?.currentModeId],
);
/**
* Set the session model (experimental).
* Sends a request to the agent to change the model.
*/
const setModel = useCallback(
async (modelId: string) => {
if (!session.sessionId) {
console.warn("Cannot set model: no active session");
return;
}
// Store previous model for rollback on error
const previousModelId = session.models?.currentModelId;
// Optimistic update - update UI immediately
setSession((prev) => {
if (!prev.models) return prev;
return {
...prev,
models: {
...prev.models,
currentModelId: modelId,
},
};
});
try {
await agentClient.setSessionModel(session.sessionId, modelId);
// Note: Unlike modes, there is no dedicated notification for model changes.
// UI is already updated optimistically above.
} catch (error) {
console.error("Failed to set model:", error);
// Rollback to previous model on error
if (previousModelId) {
setSession((prev) => {
if (!prev.models) return prev;
return {
...prev,
models: {
...prev.models,
currentModelId: previousModelId,
},
};
});
}
}
},
[agentClient, session.sessionId, session.models?.currentModelId],
);
return {
session,
isReady,
@ -575,5 +636,6 @@ export function useAgentSession(
updateAvailableCommands,
updateCurrentMode,
setMode,
setModel,
};
}

View file

@ -680,6 +680,66 @@ If your plugin does not need CSS, delete this file.
box-shadow: none !important;
}
/* Model Selector (experimental) */
.model-selector {
display: inline-flex;
align-items: center;
padding: 0 4px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.1s ease;
height: 20px;
max-width: 120px;
}
.model-selector:hover {
background-color: var(--background-modifier-hover);
}
.model-selector-icon {
display: flex;
align-items: center;
pointer-events: none;
color: var(--text-faint);
margin-left: 2px;
order: 2;
}
.model-selector-icon svg {
width: 12px;
height: 12px;
}
.model-selector select.dropdown {
padding: 0 !important;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
background: none !important;
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
color: var(--text-faint) !important;
font-size: 11px !important;
cursor: pointer !important;
outline: none !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
order: 1;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-selector select.dropdown:hover,
.model-selector select.dropdown:focus {
color: var(--text-faint) !important;
background-color: transparent !important;
box-shadow: none !important;
}
/* Send button */
.chat-send-button {
width: 20px;