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 && ( +
+ + {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. +

+
+ )} +