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>
This commit is contained in:
David Kaya
2026-03-25 22:41:46 +01:00
co-authored by Copilot
parent 46b5de472e
commit 968ae37279
10 changed files with 76 additions and 74 deletions
+14 -13
View File
@@ -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<AppServiceEvents> {
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<AppServiceEvents> {
}
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<AppServiceEvents> {
return result;
}
async updateScratchpadSessionConfig(
async updateSessionModelConfig(
sessionId: string,
model: string,
reasoningEffort?: ReasoningEffort,
@@ -749,13 +749,15 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
}
private async buildEffectivePattern(
project: ProjectRecord,
pattern: PatternDefinition,
session: SessionRecord,
): Promise<PatternDefinition> {
const patternWithSessionConfig = isScratchpadProject(project)
? applyScratchpadSessionConfig(pattern, session)
const patternWithSessionConfig = session.sessionModelConfig
? applySessionModelConfig(pattern, session)
: pattern;
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
+4 -4
View File
@@ -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));
+2 -2
View File
@@ -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),
+5 -5
View File
@@ -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,
+24 -23
View File
@@ -36,7 +36,7 @@ interface ChatPaneProps {
onSend: (content: string) => Promise<void>;
onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
onUpdateScratchpadConfig?: (config: {
onUpdateSessionModelConfig?: (config: {
model: string;
reasoningEffort?: ReasoningEffort;
}) => Promise<unknown>;
@@ -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<string>();
const [approvalError, setApprovalError] = useState<string>();
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false);
const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false);
const transcriptRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<MarkdownComposerHandle>(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({
</div>
)}
{/* Scratchpad pills — tools/approval left, model/reasoning right */}
{isScratchpad && (
{/* Session config pills — tools/approval left, model/reasoning right */}
{isSingleAgent && (
<div className="mb-2 flex items-center gap-2">
{hasConfigurableTools && onUpdateSessionTooling && (
<InlineToolsPill
@@ -366,9 +367,9 @@ export function ChatPane({
models={availableModels}
onChange={(modelId) => {
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({
<InlineThinkingPill
disabled={isComposerDisabled}
onChange={(reasoningEffort) =>
void handleScratchpadConfigChange({
void handleSessionModelConfigChange({
model: primaryAgent.model,
reasoningEffort,
})
}
supportedEfforts={supportedEfforts}
value={scratchpadReasoningEffort}
value={sessionReasoningEffort}
/>
{isUpdatingScratchpadConfig && (
{isUpdatingSessionModelConfig && (
<Loader2 className="size-3 animate-spin text-zinc-500" />
)}
</div>
@@ -392,8 +393,8 @@ export function ChatPane({
</div>
)}
{/* Session config pills — tool & approval controls (non-scratchpad) */}
{!isScratchpad && (hasConfigurableTools || hasToolCallApproval) && (
{/* Session config pills — tool & approval controls (multi-agent) */}
{!isSingleAgent && (hasConfigurableTools || hasToolCallApproval) && (
<div className="mb-2 flex items-center gap-2">
{hasConfigurableTools && onUpdateSessionTooling && (
<InlineToolsPill
@@ -427,8 +428,8 @@ export function ChatPane({
? 'Awaiting approval...'
: isSessionBusy
? 'Waiting for response...'
: isUpdatingScratchpadConfig
? 'Saving scratchpad settings...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: 'Message...'
}
>
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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<void>;
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
selectProject(projectId?: string): Promise<WorkspaceState>;
selectPattern(patternId?: string): Promise<WorkspaceState>;
+11 -11
View File
@@ -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;
+1 -1
View File
@@ -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],
+12 -12
View File
@@ -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>): 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',
},