diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts
index e3380d9..98498e7 100644
--- a/src/main/KopayaAppService.ts
+++ b/src/main/KopayaAppService.ts
@@ -4,10 +4,22 @@ import { basename } from 'node:path';
import { dialog } from 'electron';
import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
-import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern';
+import { findModel } from '@shared/domain/models';
+import {
+ buildSessionTitle,
+ isReasoningEffort,
+ type PatternDefinition,
+ type ReasoningEffort,
+ validatePatternDefinition,
+} from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { SessionEventRecord } from '@shared/domain/event';
-import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
+import {
+ applyScratchpadSessionConfig,
+ createScratchpadSessionConfig,
+ type ChatMessageRecord,
+ type SessionRecord,
+} from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
@@ -150,6 +162,7 @@ export class KopayaAppService extends EventEmitter {
updatedAt: nowIso(),
status: 'idle',
messages: [],
+ scratchpadConfig: isScratchpadProject(project) ? createScratchpadSessionConfig(pattern) : undefined,
};
workspace.sessions.unshift(session);
@@ -164,6 +177,9 @@ export class KopayaAppService extends EventEmitter {
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
+ const effectivePattern = isScratchpadProject(project)
+ ? applyScratchpadSessionConfig(pattern, session)
+ : pattern;
const trimmed = content.trim();
if (!trimmed) {
@@ -177,7 +193,7 @@ export class KopayaAppService extends EventEmitter {
content: trimmed,
createdAt: nowIso(),
});
- session.title = buildSessionTitle(pattern, session.messages);
+ session.title = buildSessionTitle(effectivePattern, session.messages);
session.status = 'running';
session.lastError = undefined;
session.updatedAt = nowIso();
@@ -200,7 +216,7 @@ export class KopayaAppService extends EventEmitter {
sessionId: session.id,
projectPath: project.path,
workspaceKind,
- pattern,
+ pattern: effectivePattern,
messages: session.messages,
},
async (event) => {
@@ -229,6 +245,42 @@ export class KopayaAppService extends EventEmitter {
}
}
+ async updateScratchpadSessionConfig(
+ sessionId: string,
+ model: string,
+ reasoningEffort: ReasoningEffort,
+ ): Promise {
+ const workspace = await this.loadWorkspace();
+ const session = this.requireSession(workspace, sessionId);
+ const project = this.requireProject(workspace, session.projectId);
+ const pattern = this.requirePattern(workspace, session.patternId);
+
+ if (!isScratchpadProject(project)) {
+ throw new Error('Only scratchpad sessions can change model settings in chat.');
+ }
+
+ if (session.status === 'running') {
+ throw new Error('Wait for the current scratchpad response to finish before changing model settings.');
+ }
+
+ const normalizedModel = model.trim();
+ if (!normalizedModel || !findModel(normalizedModel)) {
+ throw new Error(`Model "${model}" is not available.`);
+ }
+ if (!isReasoningEffort(reasoningEffort)) {
+ throw new Error(`Reasoning effort "${reasoningEffort}" is not supported.`);
+ }
+
+ session.scratchpadConfig = {
+ ...(createScratchpadSessionConfig(pattern) ?? {}),
+ model: normalizedModel,
+ reasoningEffort,
+ };
+ session.updatedAt = nowIso();
+
+ return this.persistAndBroadcast(workspace);
+ }
+
async selectProject(projectId?: string): Promise {
const workspace = await this.loadWorkspace();
workspace.selectedProjectId = projectId;
diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts
index e74d66e..7dd3211 100644
--- a/src/main/ipc/registerIpcHandlers.ts
+++ b/src/main/ipc/registerIpcHandlers.ts
@@ -1,7 +1,12 @@
import { BrowserWindow, ipcMain } from 'electron';
import { ipcChannels } from '@shared/contracts/channels';
-import type { CreateSessionInput, SavePatternInput, SendSessionMessageInput } from '@shared/contracts/ipc';
+import type {
+ CreateSessionInput,
+ SavePatternInput,
+ SendSessionMessageInput,
+ UpdateScratchpadSessionConfigInput,
+} from '@shared/contracts/ipc';
import { KopayaAppService } from '@main/KopayaAppService';
@@ -17,6 +22,11 @@ export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppSer
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
service.sendSessionMessage(input.sessionId, input.content),
);
+ ipcMain.handle(
+ ipcChannels.updateScratchpadSessionConfig,
+ (_event, input: UpdateScratchpadSessionConfigInput) =>
+ service.updateScratchpadSessionConfig(input.sessionId, input.model, input.reasoningEffort),
+ );
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 5966440..c283bff 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -11,6 +11,8 @@ const api: ElectronApi = {
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
+ updateScratchpadSessionConfig: (input) =>
+ ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index d4e74f8..6a5dcfb 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -16,6 +16,7 @@ import { WelcomePane } from '@renderer/components/WelcomePane';
import { getElectronApi } from '@renderer/lib/electronApi';
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject } from '@shared/domain/project';
+import { applyScratchpadSessionConfig } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
@@ -89,13 +90,6 @@ export default function App() {
() => workspace?.sessions.find((s) => s.id === workspace.selectedSessionId),
[workspace?.selectedSessionId, workspace?.sessions],
);
- const patternForSession = useMemo(
- () =>
- selectedSession
- ? workspace?.patterns.find((p) => p.id === selectedSession.patternId)
- : undefined,
- [selectedSession, workspace?.patterns],
- );
const projectForSession = useMemo(
() =>
selectedSession
@@ -103,6 +97,20 @@ export default function App() {
: undefined,
[selectedSession, workspace?.projects],
);
+ const patternForSession = useMemo(() => {
+ if (!selectedSession) {
+ return undefined;
+ }
+
+ const basePattern = workspace?.patterns.find((pattern) => pattern.id === selectedSession.patternId);
+ if (!basePattern) {
+ return undefined;
+ }
+
+ return projectForSession && isScratchpadProject(projectForSession)
+ ? applyScratchpadSessionConfig(basePattern, selectedSession)
+ : basePattern;
+ }, [projectForSession, selectedSession, workspace?.patterns]);
const activityForSession = useMemo(
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities],
@@ -135,12 +143,19 @@ export default function App() {
);
} else if (selectedSession && patternForSession && projectForSession) {
content = (
- api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
- pattern={patternForSession}
- project={projectForSession}
- session={selectedSession}
- />
+ api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
+ onUpdateScratchpadConfig={(config) =>
+ api.updateScratchpadSessionConfig({
+ sessionId: selectedSession.id,
+ model: config.model,
+ reasoningEffort: config.reasoningEffort,
+ })
+ }
+ pattern={patternForSession}
+ project={projectForSession}
+ session={selectedSession}
+ />
);
detailPanel = (
+ {tier}
+
+ );
+}
+
+interface ModelSelectProps {
+ value: string;
+ onChange: (model: string) => void;
+ label?: string;
+ disabled?: boolean;
+}
+
+export function ModelSelect({
+ value,
+ onChange,
+ label = 'Model',
+ disabled = false,
+}: ModelSelectProps) {
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ function handleClick(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ }
+
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, [open]);
+
+ const selected = findModel(value);
+ const provider = selected?.provider ?? inferProvider(value);
+
+ return (
+
+ );
+}
+
+interface ReasoningEffortSelectProps {
+ value: ReasoningEffort;
+ onChange: (value: ReasoningEffort) => void;
+ label?: string;
+ disabled?: boolean;
+}
+
+export function ReasoningEffortSelect({
+ value,
+ onChange,
+ label = 'Reasoning',
+ disabled = false,
+}: ReasoningEffortSelectProps) {
+ return (
+
+ );
+}
diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx
index ee47772..b0acb9a 100644
--- a/src/renderer/components/ChatPane.tsx
+++ b/src/renderer/components/ChatPane.tsx
@@ -3,8 +3,9 @@ import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
+import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
-import type { PatternDefinition } from '@shared/domain/pattern';
+import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
@@ -23,30 +24,74 @@ interface ChatPaneProps {
pattern: PatternDefinition;
session: SessionRecord;
onSend: (content: string) => Promise;
+ onUpdateScratchpadConfig: (config: {
+ model: string;
+ reasoningEffort: ReasoningEffort;
+ }) => Promise;
}
-export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
+export function ChatPane({
+ project,
+ pattern,
+ session,
+ onSend,
+ onUpdateScratchpadConfig,
+}: ChatPaneProps) {
const [input, setInput] = useState('');
+ const [configError, setConfigError] = useState();
+ const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false);
const transcriptRef = useRef(null);
const textareaRef = useRef(null);
- const isBusy = session.status === 'running';
+ const isSessionBusy = session.status === 'running';
const isScratchpad = isScratchpadProject(project);
+ const primaryAgent = pattern.agents[0];
+ const scratchpadReasoningEffort = primaryAgent?.reasoningEffort ?? 'high';
+ const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig;
useEffect(() => {
transcriptRef.current?.scrollTo({
top: transcriptRef.current.scrollHeight,
behavior: 'smooth',
});
- }, [session.messages.length, isBusy]);
+ }, [session.messages.length, isSessionBusy]);
+
+ useEffect(() => {
+ setConfigError(undefined);
+ setIsUpdatingScratchpadConfig(false);
+ }, [session.id]);
async function handleSubmit() {
const text = input.trim();
- if (!text || isBusy) return;
+ if (!text || isComposerDisabled) return;
setInput('');
await onSend(text);
}
+ async function handleScratchpadConfigChange(config: {
+ model: string;
+ reasoningEffort: ReasoningEffort;
+ }) {
+ if (!isScratchpad || !primaryAgent || isComposerDisabled) {
+ return;
+ }
+
+ if (config.model === primaryAgent.model && config.reasoningEffort === scratchpadReasoningEffort) {
+ return;
+ }
+
+ setConfigError(undefined);
+ setIsUpdatingScratchpadConfig(true);
+
+ try {
+ await onUpdateScratchpadConfig(config);
+ } catch (error) {
+ setConfigError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setIsUpdatingScratchpadConfig(false);
+ }
+ }
+
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -65,7 +110,7 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
- {session.status === 'running' && (
+ {isSessionBusy && (
)}
{session.status === 'error' && (
@@ -178,29 +223,77 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
)}
+ {configError && (
+
+ )}
+
+ {isScratchpad && primaryAgent && (
+
+
+
+
+ void handleScratchpadConfigChange({
+ model,
+ reasoningEffort: scratchpadReasoningEffort,
+ })
+ }
+ value={primaryAgent.model}
+ />
+
+
+
+ void handleScratchpadConfigChange({
+ model: primaryAgent.model,
+ reasoningEffort,
+ })
+ }
+ value={scratchpadReasoningEffort}
+ />
+
+
+
+ Applies to future replies in this scratchpad.
+
+
+ )}
+
;
addProject(): Promise;
@@ -25,6 +31,7 @@ export interface ElectronApi {
deletePattern(patternId: string): Promise;
createSession(input: CreateSessionInput): Promise;
sendSessionMessage(input: SendSessionMessageInput): Promise;
+ updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise;
selectProject(projectId?: string): Promise;
selectPattern(patternId?: string): Promise;
selectSession(sessionId?: string): Promise;
diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts
index bc0a2ae..8918533 100644
--- a/src/shared/domain/pattern.ts
+++ b/src/shared/domain/pattern.ts
@@ -11,6 +11,13 @@ export type OrchestrationMode =
export type PatternAvailability = 'available' | 'preview' | 'unavailable';
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
+export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; label: string }> = [
+ { value: 'low', label: 'Low' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'high', label: 'High' },
+ { value: 'xhigh', label: 'Maximum' },
+];
+
export interface PatternAgentDefinition {
id: string;
name: string;
@@ -45,6 +52,12 @@ const defaultModels = {
gpt53: 'gpt-5.3-codex',
} as const;
+const reasoningEffortSet = new Set(reasoningEffortOptions.map((option) => option.value));
+
+export function isReasoningEffort(value: string | undefined): value is ReasoningEffort {
+ return value !== undefined && reasoningEffortSet.has(value as ReasoningEffort);
+}
+
export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
return [
{
diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts
index ef75f63..55a7df5 100644
--- a/src/shared/domain/session.ts
+++ b/src/shared/domain/session.ts
@@ -1,6 +1,13 @@
+import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
+
export type ChatRole = 'system' | 'user' | 'assistant';
export type SessionStatus = 'idle' | 'running' | 'error';
+export interface ScratchpadSessionConfig {
+ model: string;
+ reasoningEffort?: ReasoningEffort;
+}
+
export interface ChatMessageRecord {
id: string;
role: ChatRole;
@@ -20,4 +27,58 @@ export interface SessionRecord {
status: SessionStatus;
messages: ChatMessageRecord[];
lastError?: string;
+ scratchpadConfig?: ScratchpadSessionConfig;
+}
+
+export function createScratchpadSessionConfig(
+ pattern: PatternDefinition,
+): ScratchpadSessionConfig | undefined {
+ const primaryAgent = pattern.agents[0];
+ if (!primaryAgent) {
+ return undefined;
+ }
+
+ return {
+ model: primaryAgent.model,
+ reasoningEffort: primaryAgent.reasoningEffort,
+ };
+}
+
+export function resolveScratchpadSessionConfig(
+ session: SessionRecord,
+ pattern: PatternDefinition,
+): ScratchpadSessionConfig | undefined {
+ const defaults = createScratchpadSessionConfig(pattern);
+ if (!defaults) {
+ return undefined;
+ }
+
+ const overrideModel = session.scratchpadConfig?.model.trim();
+ return {
+ model: overrideModel || defaults.model,
+ reasoningEffort: session.scratchpadConfig?.reasoningEffort ?? defaults.reasoningEffort,
+ };
+}
+
+export function applyScratchpadSessionConfig(
+ pattern: PatternDefinition,
+ session: SessionRecord,
+): PatternDefinition {
+ const config = resolveScratchpadSessionConfig(session, pattern);
+ const primaryAgent = pattern.agents[0];
+ if (!config || !primaryAgent) {
+ return pattern;
+ }
+
+ return {
+ ...pattern,
+ agents: [
+ {
+ ...primaryAgent,
+ model: config.model,
+ reasoningEffort: config.reasoningEffort,
+ },
+ ...pattern.agents.slice(1),
+ ],
+ };
}
diff --git a/tests/shared/session.test.ts b/tests/shared/session.test.ts
new file mode 100644
index 0000000..0a6ef2e
--- /dev/null
+++ b/tests/shared/session.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from 'bun:test';
+
+import type { PatternDefinition } from '@shared/domain/pattern';
+import {
+ applyScratchpadSessionConfig,
+ createScratchpadSessionConfig,
+ resolveScratchpadSessionConfig,
+ type SessionRecord,
+} from '@shared/domain/session';
+
+function createPattern(): PatternDefinition {
+ return {
+ id: 'pattern-single',
+ name: '1-on-1 Copilot Chat',
+ description: 'Single agent chat',
+ mode: 'single',
+ availability: 'available',
+ maxIterations: 1,
+ agents: [
+ {
+ id: 'agent-primary',
+ name: 'Primary Agent',
+ description: 'Helpful assistant',
+ instructions: 'Help the user.',
+ model: 'gpt-5.4',
+ reasoningEffort: 'high',
+ },
+ {
+ id: 'agent-secondary',
+ name: 'Secondary Agent',
+ description: 'Unused here',
+ instructions: 'Review.',
+ model: 'claude-sonnet-4.5',
+ reasoningEffort: 'medium',
+ },
+ ],
+ createdAt: '2026-03-23T00:00:00.000Z',
+ updatedAt: '2026-03-23T00:00:00.000Z',
+ };
+}
+
+function createSession(overrides?: Partial): SessionRecord {
+ return {
+ id: 'session-1',
+ projectId: 'project-scratchpad',
+ patternId: 'pattern-single',
+ title: 'Scratchpad',
+ createdAt: '2026-03-23T00:00:00.000Z',
+ updatedAt: '2026-03-23T00:00:00.000Z',
+ status: 'idle',
+ messages: [],
+ ...overrides,
+ };
+}
+
+describe('scratchpad session config helpers', () => {
+ test('captures the initial scratchpad model settings from the primary agent', () => {
+ expect(createScratchpadSessionConfig(createPattern())).toEqual({
+ model: 'gpt-5.4',
+ reasoningEffort: 'high',
+ });
+ });
+
+ test('resolves persisted scratchpad overrides over the pattern defaults', () => {
+ const config = resolveScratchpadSessionConfig(
+ createSession({
+ scratchpadConfig: {
+ model: 'claude-opus-4.5',
+ reasoningEffort: 'medium',
+ },
+ }),
+ createPattern(),
+ );
+
+ expect(config).toEqual({
+ model: 'claude-opus-4.5',
+ reasoningEffort: 'medium',
+ });
+ });
+
+ test('applies scratchpad settings only to the primary agent', () => {
+ const pattern = createPattern();
+ const updated = applyScratchpadSessionConfig(
+ pattern,
+ createSession({
+ scratchpadConfig: {
+ model: 'gpt-5.4-mini',
+ reasoningEffort: 'low',
+ },
+ }),
+ );
+
+ expect(updated.agents[0].model).toBe('gpt-5.4-mini');
+ expect(updated.agents[0].reasoningEffort).toBe('low');
+ expect(updated.agents[1]).toEqual(pattern.agents[1]);
+ });
+});