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
+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...'
}
>