From 968ae372796492ad12e9290eba87c81e4a5f590a Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 22:41:46 +0100 Subject: [PATCH] feat: allow model selection in all single-agent sessions Generalize the scratchpad-only model/reasoning-effort selector to work in any session whose pattern has exactly one agent. This enables model switching above the chat input for project 1-on-1 chats, not just scratchpads. - Rename ScratchpadSessionConfig -> SessionModelConfig across domain, contracts, IPC, preload, and renderer - Replace isScratchpadProject guard with agents.length === 1 check in EryxAppService.updateSessionModelConfig and ChatPane pills gate - Apply session model config in buildEffectivePattern whenever present, regardless of project type - Seed sessionModelConfig on session creation for any single-agent pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/EryxAppService.ts | 27 ++++++++-------- src/main/ipc/registerIpcHandlers.ts | 8 ++--- src/preload/index.ts | 4 +-- src/renderer/App.tsx | 10 +++--- src/renderer/components/ChatPane.tsx | 47 ++++++++++++++-------------- src/shared/contracts/channels.ts | 2 +- src/shared/contracts/ipc.ts | 4 +-- src/shared/domain/session.ts | 22 ++++++------- src/shared/domain/sessionLibrary.ts | 2 +- tests/shared/session.test.ts | 24 +++++++------- 10 files changed, 76 insertions(+), 74 deletions(-) diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 6454e1b..ee05509 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -55,9 +55,9 @@ import { import type { SessionEventRecord } from '@shared/domain/event'; import { applySessionApprovalSettings, - applyScratchpadSessionConfig, + applySessionModelConfig, resolveSessionToolingSelection, - createScratchpadSessionConfig, + createSessionModelConfig, resolveSessionTitle, type ChatMessageRecord, type SessionRecord, @@ -494,8 +494,8 @@ export class EryxAppService extends EventEmitter { updatedAt: nowIso(), status: 'idle', messages: [], - scratchpadConfig: isScratchpadProject(project) - ? createScratchpadSessionConfig(normalizedPattern) + sessionModelConfig: normalizedPattern.agents.length === 1 + ? createSessionModelConfig(normalizedPattern) : undefined, tooling: createSessionToolingSelection(), runs: [], @@ -553,7 +553,7 @@ export class EryxAppService extends EventEmitter { } const project = this.requireProject(workspace, session.projectId); const pattern = this.requirePattern(workspace, session.patternId); - const effectivePattern = await this.buildEffectivePattern(project, pattern, session); + const effectivePattern = await this.buildEffectivePattern(pattern, session); const preparedContent = prepareChatMessageContent(content); if (!preparedContent) { @@ -740,7 +740,7 @@ export class EryxAppService extends EventEmitter { return result; } - async updateScratchpadSessionConfig( + async updateSessionModelConfig( sessionId: string, model: string, reasoningEffort?: ReasoningEffort, @@ -749,13 +749,15 @@ export class EryxAppService extends EventEmitter { const session = this.requireSession(workspace, sessionId); const project = this.requireProject(workspace, session.projectId); const modelCatalog = await this.loadAvailableModelCatalog(); + const pattern = this.requirePattern(workspace, session.patternId); + const effectivePattern = normalizePatternModels(pattern, modelCatalog); - if (!isScratchpadProject(project)) { - throw new Error('Only scratchpad sessions can change model settings in chat.'); + if (effectivePattern.agents.length !== 1) { + throw new Error('Model override is only supported for single-agent sessions.'); } if (session.status === 'running') { - throw new Error('Wait for the current scratchpad response to finish before changing model settings.'); + throw new Error('Wait for the current response to finish before changing model settings.'); } const normalizedModel = model.trim(); @@ -767,7 +769,7 @@ export class EryxAppService extends EventEmitter { throw new Error(`Reasoning effort "${reasoningEffort}" is not supported.`); } - session.scratchpadConfig = { + session.sessionModelConfig = { model: normalizedModel, reasoningEffort: resolveReasoningEffort(selectedModel, reasoningEffort), }; @@ -1326,12 +1328,11 @@ export class EryxAppService extends EventEmitter { } private async buildEffectivePattern( - project: ProjectRecord, pattern: PatternDefinition, session: SessionRecord, ): Promise { - const patternWithSessionConfig = isScratchpadProject(project) - ? applyScratchpadSessionConfig(pattern, session) + const patternWithSessionConfig = session.sessionModelConfig + ? applySessionModelConfig(pattern, session) : pattern; const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session); diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index e61865f..3603ca0 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -19,7 +19,7 @@ import type { SetSessionPinnedInput, UpdateSessionApprovalSettingsInput, UpdateSessionToolingInput, - UpdateScratchpadSessionConfigInput, + UpdateSessionModelConfigInput, } from '@shared/contracts/ipc'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; import type { AppearanceTheme } from '@shared/domain/tooling'; @@ -108,9 +108,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision), ); ipcMain.handle( - ipcChannels.updateScratchpadSessionConfig, - (_event, input: UpdateScratchpadSessionConfigInput) => - service.updateScratchpadSessionConfig(input.sessionId, input.model, input.reasoningEffort), + ipcChannels.updateSessionModelConfig, + (_event, input: UpdateSessionModelConfigInput) => + service.updateSessionModelConfig(input.sessionId, input.model, input.reasoningEffort), ); ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input)); ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId)); diff --git a/src/preload/index.ts b/src/preload/index.ts index e2618e2..8a43b98 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,8 +34,8 @@ const api: ElectronApi = { sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input), cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), - updateScratchpadSessionConfig: (input) => - ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input), + updateSessionModelConfig: (input) => + ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input), querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input), selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId), selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 71d93d6..7f4db28 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -27,7 +27,7 @@ import { createDefaultToolApprovalPolicy } from '@shared/domain/approval'; import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; -import { applyScratchpadSessionConfig } from '@shared/domain/session'; +import { applySessionModelConfig } from '@shared/domain/session'; import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; @@ -159,8 +159,8 @@ export default function App() { } const patternWithSessionConfig = - projectForSession && isScratchpadProject(projectForSession) - ? applyScratchpadSessionConfig(basePattern, selectedSession) + projectForSession && selectedSession.sessionModelConfig + ? applySessionModelConfig(basePattern, selectedSession) : basePattern; return normalizePatternModels(patternWithSessionConfig, availableModels); @@ -250,8 +250,8 @@ export default function App() { onResolveApproval={(approvalId, decision) => api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision }) } - onUpdateScratchpadConfig={(config) => - api.updateScratchpadSessionConfig({ + onUpdateSessionModelConfig={(config) => + api.updateSessionModelConfig({ sessionId: selectedSession.id, model: config.model, reasoningEffort: config.reasoningEffort, diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 8b0082f..923d8c6 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -36,7 +36,7 @@ interface ChatPaneProps { onSend: (content: string) => Promise; onCancelTurn?: () => void; onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise; - onUpdateScratchpadConfig?: (config: { + onUpdateSessionModelConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; }) => Promise; @@ -54,7 +54,7 @@ export function ChatPane({ onSend, onCancelTurn, onResolveApproval, - onUpdateScratchpadConfig, + onUpdateSessionModelConfig, onUpdateSessionTooling, onUpdateSessionApprovalSettings, }: ChatPaneProps) { @@ -62,7 +62,7 @@ export function ChatPane({ const [configError, setConfigError] = useState(); const [approvalError, setApprovalError] = useState(); const [isResolvingApproval, setIsResolvingApproval] = useState(false); - const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false); + const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false); const transcriptRef = useRef(null); const composerRef = useRef(null); @@ -71,11 +71,12 @@ export function ChatPane({ const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const isScratchpad = isScratchpadProject(project); + const isSingleAgent = pattern.agents.length === 1; const primaryAgent = pattern.agents[0]; const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined; const supportedEfforts = getSupportedReasoningEfforts(selectedModel); - const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); - const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig; + const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); + const isComposerDisabled = isSessionBusy || isUpdatingSessionModelConfig; const canSubmitInput = hasComposerContent && !isComposerDisabled; const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); @@ -108,37 +109,37 @@ export function ChatPane({ setConfigError(undefined); setApprovalError(undefined); setIsResolvingApproval(false); - setIsUpdatingScratchpadConfig(false); + setIsUpdatingSessionModelConfig(false); }, [session.id]); function handleComposerSubmit(content: string) { void onSend(content); } - async function handleScratchpadConfigChange(config: { + async function handleSessionModelConfigChange(config: { model: string; reasoningEffort?: ReasoningEffort; }) { - if (!isScratchpad || !primaryAgent || isComposerDisabled || !onUpdateScratchpadConfig) { + if (!isSingleAgent || !primaryAgent || isComposerDisabled || !onUpdateSessionModelConfig) { return; } if ( config.model === primaryAgent.model && - config.reasoningEffort === scratchpadReasoningEffort + config.reasoningEffort === sessionReasoningEffort ) { return; } setConfigError(undefined); - setIsUpdatingScratchpadConfig(true); + setIsUpdatingSessionModelConfig(true); try { - await onUpdateScratchpadConfig(config); + await onUpdateSessionModelConfig(config); } catch (error) { setConfigError(error instanceof Error ? error.message : String(error)); } finally { - setIsUpdatingScratchpadConfig(false); + setIsUpdatingSessionModelConfig(false); } } @@ -338,8 +339,8 @@ export function ChatPane({ )} - {/* Scratchpad pills — tools/approval left, model/reasoning right */} - {isScratchpad && ( + {/* Session config pills — tools/approval left, model/reasoning right */} + {isSingleAgent && (
{hasConfigurableTools && onUpdateSessionTooling && ( { const nextModel = findModel(modelId, availableModels); - void handleScratchpadConfigChange({ + void handleSessionModelConfigChange({ model: modelId, - reasoningEffort: resolveReasoningEffort(nextModel, scratchpadReasoningEffort), + reasoningEffort: resolveReasoningEffort(nextModel, sessionReasoningEffort), }); }} value={primaryAgent.model} @@ -376,15 +377,15 @@ export function ChatPane({ - void handleScratchpadConfigChange({ + void handleSessionModelConfigChange({ model: primaryAgent.model, reasoningEffort, }) } supportedEfforts={supportedEfforts} - value={scratchpadReasoningEffort} + value={sessionReasoningEffort} /> - {isUpdatingScratchpadConfig && ( + {isUpdatingSessionModelConfig && ( )}
@@ -392,8 +393,8 @@ export function ChatPane({ )} - {/* Session config pills — tool & approval controls (non-scratchpad) */} - {!isScratchpad && (hasConfigurableTools || hasToolCallApproval) && ( + {/* Session config pills — tool & approval controls (multi-agent) */} + {!isSingleAgent && (hasConfigurableTools || hasToolCallApproval) && (
{hasConfigurableTools && onUpdateSessionTooling && ( diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index fea9164..16ad698 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -27,7 +27,7 @@ export const ipcChannels = { cancelSessionTurn: 'sessions:cancel-turn', resolveSessionApproval: 'sessions:resolve-approval', querySessions: 'sessions:query', - updateScratchpadSessionConfig: 'sessions:update-scratchpad-config', + updateSessionModelConfig: 'sessions:update-model-config', selectProject: 'selection:project', selectPattern: 'selection:pattern', selectSession: 'selection:session', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 678f036..6b4bd57 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -36,7 +36,7 @@ export interface ResolveSessionApprovalInput { decision: ApprovalDecision; } -export interface UpdateScratchpadSessionConfigInput { +export interface UpdateSessionModelConfigInput { sessionId: string; model: string; reasoningEffort?: ReasoningEffort; @@ -126,7 +126,7 @@ export interface ElectronApi { sendSessionMessage(input: SendSessionMessageInput): Promise; cancelSessionTurn(input: CancelSessionTurnInput): Promise; resolveSessionApproval(input: ResolveSessionApprovalInput): Promise; - updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise; + updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise; querySessions(input: QuerySessionsInput): Promise; selectProject(projectId?: string): Promise; selectPattern(patternId?: string): Promise; diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 1f06231..1b38844 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -16,7 +16,7 @@ export type ChatRole = 'system' | 'user' | 'assistant'; export type SessionStatus = 'idle' | 'running' | 'error'; export type SessionTitleSource = 'auto' | 'manual'; -export interface ScratchpadSessionConfig { +export interface SessionModelConfig { model: string; reasoningEffort?: ReasoningEffort; } @@ -43,7 +43,7 @@ export interface SessionRecord { isArchived?: boolean; messages: ChatMessageRecord[]; lastError?: string; - scratchpadConfig?: ScratchpadSessionConfig; + sessionModelConfig?: SessionModelConfig; tooling?: SessionToolingSelection; approvalSettings?: SessionApprovalSettings; pendingApproval?: PendingApprovalRecord; @@ -63,9 +63,9 @@ export function resolveSessionTitle( return buildSessionTitle(pattern, messages); } -export function createScratchpadSessionConfig( +export function createSessionModelConfig( pattern: PatternDefinition, -): ScratchpadSessionConfig | undefined { +): SessionModelConfig | undefined { const primaryAgent = pattern.agents[0]; if (!primaryAgent) { return undefined; @@ -89,27 +89,27 @@ export function resolveSessionApprovalSettings( return normalizeSessionApprovalSettings(session.approvalSettings); } -export function resolveScratchpadSessionConfig( +export function resolveSessionModelConfig( session: SessionRecord, pattern: PatternDefinition, -): ScratchpadSessionConfig | undefined { - const defaults = createScratchpadSessionConfig(pattern); +): SessionModelConfig | undefined { + const defaults = createSessionModelConfig(pattern); if (!defaults) { return undefined; } - const overrideModel = session.scratchpadConfig?.model.trim(); + const overrideModel = session.sessionModelConfig?.model.trim(); return { model: overrideModel || defaults.model, - reasoningEffort: session.scratchpadConfig?.reasoningEffort ?? defaults.reasoningEffort, + reasoningEffort: session.sessionModelConfig?.reasoningEffort ?? defaults.reasoningEffort, }; } -export function applyScratchpadSessionConfig( +export function applySessionModelConfig( pattern: PatternDefinition, session: SessionRecord, ): PatternDefinition { - const config = resolveScratchpadSessionConfig(session, pattern); + const config = resolveSessionModelConfig(session, pattern); const primaryAgent = pattern.agents[0]; if (!config || !primaryAgent) { return pattern; diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index 103833b..a65df12 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -174,7 +174,7 @@ export function duplicateSessionRecord( isPinned: false, isArchived: false, lastError: undefined, - scratchpadConfig: session.scratchpadConfig ? { ...session.scratchpadConfig } : undefined, + sessionModelConfig: session.sessionModelConfig ? { ...session.sessionModelConfig } : undefined, tooling: session.tooling ? { enabledMcpServerIds: [...session.tooling.enabledMcpServerIds], diff --git a/tests/shared/session.test.ts b/tests/shared/session.test.ts index 293839f..9676cba 100644 --- a/tests/shared/session.test.ts +++ b/tests/shared/session.test.ts @@ -3,12 +3,12 @@ import { describe, expect, test } from 'bun:test'; import type { PatternDefinition } from '@shared/domain/pattern'; import { applySessionApprovalSettings, - applyScratchpadSessionConfig, - createScratchpadSessionConfig, + applySessionModelConfig, + createSessionModelConfig, resolveSessionApprovalSettings, resolveSessionToolingSelection, resolveSessionTitle, - resolveScratchpadSessionConfig, + resolveSessionModelConfig, type SessionRecord, } from '@shared/domain/session'; @@ -58,18 +58,18 @@ function createSession(overrides?: Partial): SessionRecord { }; } -describe('scratchpad session config helpers', () => { - test('captures the initial scratchpad model settings from the primary agent', () => { - expect(createScratchpadSessionConfig(createPattern())).toEqual({ +describe('session model config helpers', () => { + test('captures the initial model settings from the primary agent', () => { + expect(createSessionModelConfig(createPattern())).toEqual({ model: 'gpt-5.4', reasoningEffort: 'high', }); }); - test('resolves persisted scratchpad overrides over the pattern defaults', () => { - const config = resolveScratchpadSessionConfig( + test('resolves persisted session overrides over the pattern defaults', () => { + const config = resolveSessionModelConfig( createSession({ - scratchpadConfig: { + sessionModelConfig: { model: 'claude-opus-4.5', reasoningEffort: 'medium', }, @@ -83,12 +83,12 @@ describe('scratchpad session config helpers', () => { }); }); - test('applies scratchpad settings only to the primary agent', () => { + test('applies session model settings only to the primary agent', () => { const pattern = createPattern(); - const updated = applyScratchpadSessionConfig( + const updated = applySessionModelConfig( pattern, createSession({ - scratchpadConfig: { + sessionModelConfig: { model: 'gpt-5.4-mini', reasoningEffort: 'low', },