fix: use live copilot model availability

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 11:07:43 +01:00
co-authored by Copilot
parent 9799a959d6
commit 6e623b7bd6
16 changed files with 691 additions and 267 deletions
@@ -48,10 +48,19 @@ public sealed class SidecarModeCapabilityDto
public string? Reason { get; init; } public string? Reason { get; init; }
} }
public sealed class SidecarModelCapabilityDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public IReadOnlyList<string> SupportedReasoningEfforts { get; init; } = [];
public string? DefaultReasoningEffort { get; init; }
}
public sealed class SidecarCapabilitiesDto public sealed class SidecarCapabilitiesDto
{ {
public string Runtime { get; init; } = "dotnet-maf"; public string Runtime { get; init; } = "dotnet-maf";
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
} }
public class SidecarCommandEnvelope public class SidecarCommandEnvelope
@@ -1,12 +1,14 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using GitHub.Copilot.SDK;
using Kopaya.AgentHost.Contracts; using Kopaya.AgentHost.Contracts;
namespace Kopaya.AgentHost.Services; namespace Kopaya.AgentHost.Services;
public sealed class SidecarProtocolHost public sealed class SidecarProtocolHost
{ {
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly PatternValidator _patternValidator; private readonly PatternValidator _patternValidator;
private readonly ITurnWorkflowRunner _workflowRunner; private readonly ITurnWorkflowRunner _workflowRunner;
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
@@ -18,10 +20,14 @@ public sealed class SidecarProtocolHost
{ {
} }
public SidecarProtocolHost(PatternValidator patternValidator, ITurnWorkflowRunner? workflowRunner = null) public SidecarProtocolHost(
PatternValidator patternValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
{ {
_patternValidator = patternValidator; _patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator); _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) _jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{ {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -82,7 +88,7 @@ public sealed class SidecarProtocolHost
{ {
Type = "capabilities", Type = "capabilities",
RequestId = envelope.RequestId, RequestId = envelope.RequestId,
Capabilities = BuildCapabilities(), Capabilities = await _capabilitiesProvider(cancellationToken).ConfigureAwait(false),
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
break; break;
@@ -155,8 +161,19 @@ public sealed class SidecarProtocolHost
} }
} }
private static SidecarCapabilitiesDto BuildCapabilities() private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{ {
IReadOnlyList<SidecarModelCapabilityDto> models = [];
try
{
models = await ListAvailableModelsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}");
}
return new SidecarCapabilitiesDto return new SidecarCapabilitiesDto
{ {
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase) Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
@@ -172,6 +189,38 @@ public sealed class SidecarProtocolHost
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.", Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
}, },
}, },
Models = models,
}; };
} }
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CancellationToken cancellationToken)
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
{
Id = model.Id,
Name = model.Name,
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
.Where(IsReasoningEffort)
.Distinct(StringComparer.Ordinal)
.ToList(),
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
? model.DefaultReasoningEffort
: null,
})
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool IsReasoningEffort(string? value)
{
return value is "low" or "medium" or "high" or "xhigh";
}
} }
@@ -18,7 +18,7 @@ public sealed class SidecarProtocolHostTests
{ {
Type = "describe-capabilities", Type = "describe-capabilities",
RequestId = "cap-1", RequestId = "cap-1",
}); }, CreateHostForTests());
Assert.Collection( Assert.Collection(
events, events,
@@ -33,6 +33,10 @@ public sealed class SidecarProtocolHostTests
JsonElement modes = capabilities.GetProperty("modes"); JsonElement modes = capabilities.GetProperty("modes");
Assert.True(modes.GetProperty("single").GetProperty("available").GetBoolean()); Assert.True(modes.GetProperty("single").GetProperty("available").GetBoolean());
Assert.False(modes.GetProperty("magentic").GetProperty("available").GetBoolean()); Assert.False(modes.GetProperty("magentic").GetProperty("available").GetBoolean());
JsonElement[] models = capabilities.GetProperty("models").EnumerateArray().ToArray();
JsonElement model = Assert.Single(models);
Assert.Equal("gpt-5.4", model.GetProperty("id").GetString());
Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString());
string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty; string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty;
Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase); Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase);
@@ -220,10 +224,42 @@ public sealed class SidecarProtocolHostTests
using StringReader reader = new(input); using StringReader reader = new(input);
using StringWriter writer = new(); using StringWriter writer = new();
await (host ?? new SidecarProtocolHost()).RunAsync(reader, writer, CancellationToken.None); await (host ?? CreateHostForTests()).RunAsync(reader, writer, CancellationToken.None);
return ParseEvents(writer.ToString()); return ParseEvents(writer.ToString());
} }
private static SidecarProtocolHost CreateHostForTests()
{
return new SidecarProtocolHost(
new PatternValidator(),
capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto
{
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
["sequential"] = new() { Available = true },
["concurrent"] = new() { Available = true },
["handoff"] = new() { Available = true },
["group-chat"] = new() { Available = true },
["magentic"] = new()
{
Available = false,
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
},
},
Models =
[
new SidecarModelCapabilityDto
{
Id = "gpt-5.4",
Name = "GPT-5.4",
SupportedReasoningEfforts = ["low", "medium", "high", "xhigh"],
DefaultReasoningEffort = "medium",
},
],
}));
}
private static IReadOnlyList<JsonElement> ParseEvents(string output) private static IReadOnlyList<JsonElement> ParseEvents(string output)
{ {
List<JsonElement> events = []; List<JsonElement> events = [];
+54 -12
View File
@@ -3,8 +3,17 @@ import { basename } from 'node:path';
import { dialog } from 'electron'; import { dialog } from 'electron';
import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar'; import type {
import { findModel } from '@shared/domain/models'; AgentActivityEvent,
SidecarCapabilities,
TurnDeltaEvent,
} from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModel,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import { import {
buildSessionTitle, buildSessionTitle,
isReasoningEffort, isReasoningEffort,
@@ -42,6 +51,15 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
private readonly sidecar = new SidecarClient(); private readonly sidecar = new SidecarClient();
private readonly secretStore = new SecretStore(); private readonly secretStore = new SecretStore();
private workspace?: WorkspaceState; private workspace?: WorkspaceState;
private sidecarCapabilities?: SidecarCapabilities;
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
if (!this.sidecarCapabilities) {
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
}
return this.sidecarCapabilities;
}
async loadWorkspace(): Promise<WorkspaceState> { async loadWorkspace(): Promise<WorkspaceState> {
if (!this.workspace) { if (!this.workspace) {
@@ -152,6 +170,8 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
const workspace = await this.loadWorkspace(); const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId); const project = this.requireProject(workspace, projectId);
const pattern = this.requirePattern(workspace, patternId); const pattern = this.requirePattern(workspace, patternId);
const modelCatalog = await this.loadAvailableModelCatalog();
const normalizedPattern = normalizePatternModels(pattern, modelCatalog);
const session: SessionRecord = { const session: SessionRecord = {
id: createId('session'), id: createId('session'),
@@ -162,7 +182,9 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
updatedAt: nowIso(), updatedAt: nowIso(),
status: 'idle', status: 'idle',
messages: [], messages: [],
scratchpadConfig: isScratchpadProject(project) ? createScratchpadSessionConfig(pattern) : undefined, scratchpadConfig: isScratchpadProject(project)
? createScratchpadSessionConfig(normalizedPattern)
: undefined,
}; };
workspace.sessions.unshift(session); workspace.sessions.unshift(session);
@@ -177,9 +199,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
const session = this.requireSession(workspace, sessionId); const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId); const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId); const pattern = this.requirePattern(workspace, session.patternId);
const effectivePattern = isScratchpadProject(project) const effectivePattern = await this.buildEffectivePattern(project, pattern, session);
? applyScratchpadSessionConfig(pattern, session)
: pattern;
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) { if (!trimmed) {
@@ -248,12 +268,12 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
async updateScratchpadSessionConfig( async updateScratchpadSessionConfig(
sessionId: string, sessionId: string,
model: string, model: string,
reasoningEffort: ReasoningEffort, reasoningEffort?: ReasoningEffort,
): Promise<WorkspaceState> { ): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace(); const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId); const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId); const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId); const modelCatalog = await this.loadAvailableModelCatalog();
if (!isScratchpadProject(project)) { if (!isScratchpadProject(project)) {
throw new Error('Only scratchpad sessions can change model settings in chat.'); throw new Error('Only scratchpad sessions can change model settings in chat.');
@@ -264,17 +284,17 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
} }
const normalizedModel = model.trim(); const normalizedModel = model.trim();
if (!normalizedModel || !findModel(normalizedModel)) { const selectedModel = normalizedModel ? findModel(normalizedModel, modelCatalog) : undefined;
if (!selectedModel) {
throw new Error(`Model "${model}" is not available.`); throw new Error(`Model "${model}" is not available.`);
} }
if (!isReasoningEffort(reasoningEffort)) { if (reasoningEffort && !isReasoningEffort(reasoningEffort)) {
throw new Error(`Reasoning effort "${reasoningEffort}" is not supported.`); throw new Error(`Reasoning effort "${reasoningEffort}" is not supported.`);
} }
session.scratchpadConfig = { session.scratchpadConfig = {
...(createScratchpadSessionConfig(pattern) ?? {}),
model: normalizedModel, model: normalizedModel,
reasoningEffort, reasoningEffort: resolveReasoningEffort(selectedModel, reasoningEffort),
}; };
session.updatedAt = nowIso(); session.updatedAt = nowIso();
@@ -449,6 +469,28 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
return workspace; return workspace;
} }
private async loadAvailableModelCatalog() {
try {
const capabilities = await this.describeSidecarCapabilities();
return buildAvailableModelCatalog(capabilities.models);
} catch {
return buildAvailableModelCatalog();
}
}
private async buildEffectivePattern(
project: ProjectRecord,
pattern: PatternDefinition,
session: SessionRecord,
): Promise<PatternDefinition> {
const patternWithSessionConfig = isScratchpadProject(project)
? applyScratchpadSessionConfig(pattern, session)
: pattern;
const modelCatalog = await this.loadAvailableModelCatalog();
return normalizePatternModels(patternWithSessionConfig, modelCatalog);
}
private emitSessionEvent(event: SessionEventRecord): void { private emitSessionEvent(event: SessionEventRecord): void {
this.emit('session-event', event); this.emit('session-event', event);
} }
+1
View File
@@ -11,6 +11,7 @@ import type {
import { KopayaAppService } from '@main/KopayaAppService'; import { KopayaAppService } from '@main/KopayaAppService';
export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void { export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void {
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace()); ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
ipcMain.handle(ipcChannels.addProject, () => service.addProject()); ipcMain.handle(ipcChannels.addProject, () => service.addProject());
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId)); ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
+1
View File
@@ -4,6 +4,7 @@ import { ipcChannels } from '@shared/contracts/channels';
import type { ElectronApi } from '@shared/contracts/ipc'; import type { ElectronApi } from '@shared/contracts/ipc';
const api: ElectronApi = { const api: ElectronApi = {
describeSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.describeSidecarCapabilities),
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace), loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
addProject: () => ipcRenderer.invoke(ipcChannels.addProject), addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId), removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
+42 -10
View File
@@ -14,13 +14,20 @@ import {
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import { WelcomePane } from '@renderer/components/WelcomePane'; import { WelcomePane } from '@renderer/components/WelcomePane';
import { getElectronApi } from '@renderer/lib/electronApi'; import { getElectronApi } from '@renderer/lib/electronApi';
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModel,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject } from '@shared/domain/project'; import { isScratchpadProject } from '@shared/domain/project';
import { applyScratchpadSessionConfig } from '@shared/domain/session'; import { applyScratchpadSessionConfig } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids'; import { createId, nowIso } from '@shared/utils/ids';
function createDraftPattern(): PatternDefinition { function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
const timestamp = nowIso(); const timestamp = nowIso();
return { return {
id: createId('custom-pattern'), id: createId('custom-pattern'),
@@ -35,8 +42,8 @@ function createDraftPattern(): PatternDefinition {
name: 'Primary Agent', name: 'Primary Agent',
description: 'General-purpose assistant.', description: 'General-purpose assistant.',
instructions: 'You are a helpful coding assistant working inside the selected project.', instructions: 'You are a helpful coding assistant working inside the selected project.',
model: 'gpt-5.4', model: defaultModelId,
reasoningEffort: 'high', reasoningEffort: defaultReasoningEffort,
}, },
], ],
createdAt: timestamp, createdAt: timestamp,
@@ -48,6 +55,7 @@ export default function App() {
const api = getElectronApi(); const api = getElectronApi();
const [workspace, setWorkspace] = useState<WorkspaceState>(); const [workspace, setWorkspace] = useState<WorkspaceState>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [sidecarCapabilities, setSidecarCapabilities] = useState<SidecarCapabilities>();
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({}); const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
@@ -61,6 +69,14 @@ export default function App() {
.loadWorkspace() .loadWorkspace()
.then((ws) => !disposed && setWorkspace(ws)) .then((ws) => !disposed && setWorkspace(ws))
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e))); .catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
void api
.describeSidecarCapabilities()
.then((capabilities) => !disposed && setSidecarCapabilities(capabilities))
.catch((e) => {
if (!disposed) {
console.warn('Failed to load sidecar capabilities', e);
}
});
const offWorkspace = api.onWorkspaceUpdated((ws) => { const offWorkspace = api.onWorkspaceUpdated((ws) => {
setWorkspace(ws); setWorkspace(ws);
@@ -97,6 +113,10 @@ export default function App() {
: undefined, : undefined,
[selectedSession, workspace?.projects], [selectedSession, workspace?.projects],
); );
const availableModels = useMemo(
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
[sidecarCapabilities?.models],
);
const patternForSession = useMemo(() => { const patternForSession = useMemo(() => {
if (!selectedSession) { if (!selectedSession) {
return undefined; return undefined;
@@ -107,10 +127,13 @@ export default function App() {
return undefined; return undefined;
} }
return projectForSession && isScratchpadProject(projectForSession) const patternWithSessionConfig =
? applyScratchpadSessionConfig(basePattern, selectedSession) projectForSession && isScratchpadProject(projectForSession)
: basePattern; ? applyScratchpadSessionConfig(basePattern, selectedSession)
}, [projectForSession, selectedSession, workspace?.patterns]); : basePattern;
return normalizePatternModels(patternWithSessionConfig, availableModels);
}, [availableModels, projectForSession, selectedSession, workspace?.patterns]);
const activityForSession = useMemo( const activityForSession = useMemo(
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined), () => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities], [selectedSession, sessionActivities],
@@ -152,6 +175,7 @@ export default function App() {
reasoningEffort: config.reasoningEffort, reasoningEffort: config.reasoningEffort,
}) })
} }
availableModels={availableModels}
pattern={patternForSession} pattern={patternForSession}
project={projectForSession} project={projectForSession}
session={selectedSession} session={selectedSession}
@@ -177,12 +201,20 @@ export default function App() {
// Settings overlay // Settings overlay
const overlay = showSettings ? ( const overlay = showSettings ? (
<SettingsPanel <SettingsPanel
onClose={() => setShowSettings(false)} availableModels={availableModels}
onClose={() => setShowSettings(false)}
onDeletePattern={async (id) => { onDeletePattern={async (id) => {
await api.deletePattern(id); await api.deletePattern(id);
}} }}
onNewPattern={createDraftPattern} onNewPattern={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftPattern(
defaultModel?.id ?? 'gpt-5.4',
resolveReasoningEffort(defaultModel, 'high'),
);
}}
onSavePattern={async (pattern) => { onSavePattern={async (pattern) => {
await api.savePattern({ pattern }); await api.savePattern({ pattern });
}} }}
+67 -9
View File
@@ -13,6 +13,10 @@ import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pat
import { ProviderIcon } from './ProviderIcons'; import { ProviderIcon } from './ProviderIcons';
function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) { function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) {
if (!tier) {
return null;
}
const styles = { const styles = {
premium: 'bg-amber-500/10 text-amber-400', premium: 'bg-amber-500/10 text-amber-400',
standard: 'bg-zinc-700/50 text-zinc-500', standard: 'bg-zinc-700/50 text-zinc-500',
@@ -29,6 +33,7 @@ function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) {
interface ModelSelectProps { interface ModelSelectProps {
value: string; value: string;
onChange: (model: string) => void; onChange: (model: string) => void;
models?: ReadonlyArray<ModelDefinition>;
label?: string; label?: string;
disabled?: boolean; disabled?: boolean;
} }
@@ -36,6 +41,7 @@ interface ModelSelectProps {
export function ModelSelect({ export function ModelSelect({
value, value,
onChange, onChange,
models = modelCatalog,
label = 'Model', label = 'Model',
disabled = false, disabled = false,
}: ModelSelectProps) { }: ModelSelectProps) {
@@ -57,8 +63,15 @@ export function ModelSelect({
return () => document.removeEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick);
}, [open]); }, [open]);
const selected = findModel(value); const selected = findModel(value, models);
const provider = selected?.provider ?? inferProvider(value); const provider = selected?.provider ?? inferProvider(value);
const groupedModels = providerMeta
.map((providerGroup) => ({
...providerGroup,
models: models.filter((model) => model.provider === providerGroup.id),
}))
.filter((providerGroup) => providerGroup.models.length > 0);
const otherModels = models.filter((model) => !model.provider);
return ( return (
<label className="block space-y-1.5"> <label className="block space-y-1.5">
@@ -79,9 +92,7 @@ export function ModelSelect({
{open && !disabled && ( {open && !disabled && (
<div className="absolute z-30 mt-1 max-h-72 w-full overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl"> <div className="absolute z-30 mt-1 max-h-72 w-full overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{providerMeta.map((providerGroup) => { {groupedModels.map((providerGroup) => {
const models = modelCatalog.filter((model) => model.provider === providerGroup.id);
return ( return (
<div key={providerGroup.id}> <div key={providerGroup.id}>
<div className="flex items-center gap-2 px-3 pb-1 pt-2.5"> <div className="flex items-center gap-2 px-3 pb-1 pt-2.5">
@@ -90,7 +101,7 @@ export function ModelSelect({
{providerGroup.label} {providerGroup.label}
</span> </span>
</div> </div>
{models.map((model) => ( {providerGroup.models.map((model) => (
<button <button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${ className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300' model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
@@ -109,6 +120,29 @@ export function ModelSelect({
</div> </div>
); );
})} })}
{otherModels.length > 0 && (
<div>
<div className="px-3 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
Other
</div>
{otherModels.map((model) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
}`}
key={model.id}
onClick={() => {
onChange(model.id);
setOpen(false);
}}
type="button"
>
<span className="flex-1">{model.name}</span>
<TierBadge tier={model.tier} />
</button>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -117,8 +151,9 @@ export function ModelSelect({
} }
interface ReasoningEffortSelectProps { interface ReasoningEffortSelectProps {
value: ReasoningEffort; value?: ReasoningEffort;
onChange: (value: ReasoningEffort) => void; onChange: (value: ReasoningEffort) => void;
supportedEfforts?: ReadonlyArray<ReasoningEffort>;
label?: string; label?: string;
disabled?: boolean; disabled?: boolean;
} }
@@ -126,20 +161,43 @@ interface ReasoningEffortSelectProps {
export function ReasoningEffortSelect({ export function ReasoningEffortSelect({
value, value,
onChange, onChange,
supportedEfforts,
label = 'Reasoning', label = 'Reasoning',
disabled = false, disabled = false,
}: ReasoningEffortSelectProps) { }: ReasoningEffortSelectProps) {
const options = supportedEfforts
? reasoningEffortOptions.filter((option) => supportedEfforts.includes(option.value))
: [...reasoningEffortOptions];
const selectedValue = value && options.some((option) => option.value === value) ? value : options[0]?.value;
if (supportedEfforts && supportedEfforts.length === 0) {
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-zinc-400">{label}</span>
<div className="relative">
<input
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-[13px] text-zinc-500 outline-none"
disabled
readOnly
value="Not supported for this model"
/>
<Sparkles className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 text-zinc-600" />
</div>
</label>
);
}
return ( return (
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="text-[12px] font-medium text-zinc-400">{label}</span> <span className="text-[12px] font-medium text-zinc-400">{label}</span>
<div className="relative"> <div className="relative">
<select <select
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50 disabled:cursor-not-allowed disabled:opacity-60" className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50 disabled:cursor-not-allowed disabled:opacity-60"
disabled={disabled} disabled={disabled || !selectedValue}
onChange={(event) => onChange(event.target.value as ReasoningEffort)} onChange={(event) => onChange(event.target.value as ReasoningEffort)}
value={value} value={selectedValue}
> >
{reasoningEffortOptions.map((option) => ( {options.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
+60 -207
View File
@@ -1,13 +1,16 @@
import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, ChevronDown, Loader2, Sparkles, User } from 'lucide-react'; import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import { ProviderIcon } from '@renderer/components/ProviderIcons'; import {
findModel,
import { findModel, inferProvider, modelCatalog, providerMeta, type ModelDefinition } from '@shared/domain/models'; getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import { reasoningEffortOptions } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session'; import type { SessionRecord } from '@shared/domain/session';
@@ -21,171 +24,15 @@ function ThinkingDots() {
); );
} }
/* ── Tier badge for model dropdown ─────────────────────────── */
function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) {
const styles = {
premium: 'bg-amber-500/10 text-amber-400',
standard: 'bg-zinc-700/50 text-zinc-500',
fast: 'bg-emerald-500/10 text-emerald-400',
};
return (
<span className={`ml-auto rounded px-1.5 py-0.5 text-[9px] font-medium ${styles[tier]}`}>
{tier}
</span>
);
}
/* ── Inline model pill with dropdown ───────────────────────── */
function InlineModelPill({
value,
onChange,
disabled,
}: {
value: string;
onChange: (model: string) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.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);
const displayName = selected?.name ?? value ?? 'Model';
return (
<div className="relative" ref={ref}>
<button
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
open
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
} disabled:cursor-not-allowed disabled:opacity-50`}
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
{provider && <ProviderIcon provider={provider} className="size-3" />}
<span className="max-w-[140px] truncate">{displayName}</span>
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
</button>
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{providerMeta.map((pg) => {
const models = modelCatalog.filter((m) => m.provider === pg.id);
return (
<div key={pg.id}>
<div className="flex items-center gap-2 px-3 pb-1 pt-2.5">
<ProviderIcon provider={pg.id} className="size-3.5" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{pg.label}
</span>
</div>
{models.map((model) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
}`}
key={model.id}
onClick={() => { onChange(model.id); setOpen(false); }}
type="button"
>
<span className="flex-1">{model.name}</span>
<TierBadge tier={model.tier} />
</button>
))}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Inline thinking effort pill with dropdown ─────────────── */
function InlineThinkingPill({
value,
onChange,
disabled,
}: {
value: ReasoningEffort;
onChange: (effort: ReasoningEffort) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const currentLabel = reasoningEffortOptions.find((o) => o.value === value)?.label ?? value;
return (
<div className="relative" ref={ref}>
<button
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
open
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
} disabled:cursor-not-allowed disabled:opacity-50`}
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<Sparkles className="size-3" />
<span>{currentLabel}</span>
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
</button>
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-36 overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{reasoningEffortOptions.map((option) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
option.value === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
}`}
key={option.value}
onClick={() => { onChange(option.value); setOpen(false); }}
type="button"
>
{option.label}
</button>
))}
</div>
)}
</div>
);
}
/* ── ChatPane ──────────────────────────────────────────────── */
interface ChatPaneProps { interface ChatPaneProps {
project: ProjectRecord; project: ProjectRecord;
pattern: PatternDefinition; pattern: PatternDefinition;
session: SessionRecord; session: SessionRecord;
availableModels: ReadonlyArray<ModelDefinition>;
onSend: (content: string) => Promise<void>; onSend: (content: string) => Promise<void>;
onUpdateScratchpadConfig?: (config: { onUpdateScratchpadConfig?: (config: {
model: string; model: string;
reasoningEffort: ReasoningEffort; reasoningEffort?: ReasoningEffort;
}) => Promise<unknown>; }) => Promise<unknown>;
} }
@@ -193,6 +40,7 @@ export function ChatPane({
project, project,
pattern, pattern,
session, session,
availableModels,
onSend, onSend,
onUpdateScratchpadConfig, onUpdateScratchpadConfig,
}: ChatPaneProps) { }: ChatPaneProps) {
@@ -205,7 +53,9 @@ export function ChatPane({
const isSessionBusy = session.status === 'running'; const isSessionBusy = session.status === 'running';
const isScratchpad = isScratchpadProject(project); const isScratchpad = isScratchpadProject(project);
const primaryAgent = pattern.agents[0]; const primaryAgent = pattern.agents[0];
const scratchpadReasoningEffort = primaryAgent?.reasoningEffort ?? 'high'; const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig; const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig;
useEffect(() => { useEffect(() => {
@@ -229,13 +79,16 @@ export function ChatPane({
async function handleScratchpadConfigChange(config: { async function handleScratchpadConfigChange(config: {
model: string; model: string;
reasoningEffort: ReasoningEffort; reasoningEffort?: ReasoningEffort;
}) { }) {
if (!isScratchpad || !primaryAgent || isComposerDisabled || !onUpdateScratchpadConfig) { if (!isScratchpad || !primaryAgent || isComposerDisabled || !onUpdateScratchpadConfig) {
return; return;
} }
if (config.model === primaryAgent.model && config.reasoningEffort === scratchpadReasoningEffort) { if (
config.model === primaryAgent.model &&
config.reasoningEffort === scratchpadReasoningEffort
) {
return; return;
} }
@@ -251,16 +104,15 @@ export function ChatPane({
} }
} }
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
e.preventDefault(); event.preventDefault();
void handleSubmit(); void handleSubmit();
} }
} }
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header — extra top padding clears the title bar overlay zone */}
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 pb-3 pt-12"> <header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 pb-3 pt-12">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2> <h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2>
@@ -269,9 +121,7 @@ export function ChatPane({
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isSessionBusy && ( {isSessionBusy && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
<span className="size-2 animate-pulse rounded-full bg-blue-400" />
)}
{session.status === 'error' && ( {session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-red-400"> <div className="flex items-center gap-1.5 text-[12px] text-red-400">
<AlertCircle className="size-3.5" /> <AlertCircle className="size-3.5" />
@@ -286,14 +136,11 @@ export function ChatPane({
</div> </div>
</header> </header>
{/* Messages */}
<div className="flex-1 overflow-y-auto" ref={transcriptRef}> <div className="flex-1 overflow-y-auto" ref={transcriptRef}>
{session.messages.length === 0 ? ( {session.messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center"> <div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<Bot className="size-10 text-zinc-800" /> <Bot className="size-10 text-zinc-800" />
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">Send a message to start the conversation</p>
Send a message to start the conversation
</p>
<p className="text-[12px] text-zinc-700"> <p className="text-[12px] text-zinc-700">
{isScratchpad ? ( {isScratchpad ? (
<> <>
@@ -332,9 +179,7 @@ export function ChatPane({
<div className="flex gap-3"> <div className="flex gap-3">
<div <div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${ className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
isUser isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-800 text-zinc-400'
? 'bg-indigo-600 text-white'
: 'bg-zinc-800 text-zinc-400'
}`} }`}
> >
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />} {isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
@@ -373,7 +218,6 @@ export function ChatPane({
)} )}
</div> </div>
{/* Input area */}
<div className="border-t border-[var(--color-border)] px-6 py-4"> <div className="border-t border-[var(--color-border)] px-6 py-4">
{session.lastError && ( {session.lastError && (
<div className="mb-3 flex items-start gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-[13px] text-red-300"> <div className="mb-3 flex items-start gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-[13px] text-red-300">
@@ -390,32 +234,41 @@ export function ChatPane({
)} )}
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
{/* Scratchpad config pills — inline above composer */}
{isScratchpad && primaryAgent && ( {isScratchpad && primaryAgent && (
<div className="mb-2 flex items-center gap-2"> <div className="mb-3 rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<InlineModelPill <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
disabled={isComposerDisabled} <div className="min-w-0 flex-1">
onChange={(model) => <ModelSelect
void handleScratchpadConfigChange({ disabled={isComposerDisabled}
model, models={availableModels}
reasoningEffort: scratchpadReasoningEffort, onChange={(modelId) => {
}) const nextModel = findModel(modelId, availableModels);
} void handleScratchpadConfigChange({
value={primaryAgent.model} model: modelId,
/> reasoningEffort: resolveReasoningEffort(nextModel, scratchpadReasoningEffort),
<InlineThinkingPill });
disabled={isComposerDisabled} }}
onChange={(reasoningEffort) => value={primaryAgent.model}
void handleScratchpadConfigChange({ />
model: primaryAgent.model, </div>
reasoningEffort, <div className="sm:w-48">
}) <ReasoningEffortSelect
} disabled={isComposerDisabled}
value={scratchpadReasoningEffort} label="Thinking"
/> onChange={(reasoningEffort) =>
{isUpdatingScratchpadConfig && ( void handleScratchpadConfigChange({
<Loader2 className="size-3 animate-spin text-zinc-500" /> model: primaryAgent.model,
)} reasoningEffort,
})
}
supportedEfforts={supportedEfforts}
value={scratchpadReasoningEffort}
/>
</div>
</div>
<p className="mt-2 text-[11px] text-zinc-500">
Applies to future replies in this scratchpad.
</p>
</div> </div>
)} )}
@@ -423,7 +276,7 @@ export function ChatPane({
<textarea <textarea
className="auto-resize-textarea block w-full resize-none bg-transparent px-4 py-3 pr-12 text-[14px] text-zinc-100 placeholder-zinc-600 outline-none" className="auto-resize-textarea block w-full resize-none bg-transparent px-4 py-3 pr-12 text-[14px] text-zinc-100 placeholder-zinc-600 outline-none"
disabled={isComposerDisabled} disabled={isComposerDisabled}
onChange={(e) => setInput(e.target.value)} onChange={(event) => setInput(event.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isSessionBusy isSessionBusy
+28 -3
View File
@@ -15,6 +15,12 @@ import {
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import { import {
validatePatternDefinition, validatePatternDefinition,
type OrchestrationMode, type OrchestrationMode,
@@ -25,6 +31,7 @@ import {
import { ModelSelect, ReasoningEffortSelect } from './AgentConfigFields'; import { ModelSelect, ReasoningEffortSelect } from './AgentConfigFields';
interface PatternEditorProps { interface PatternEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
pattern: PatternDefinition; pattern: PatternDefinition;
isBuiltin: boolean; isBuiltin: boolean;
onChange: (pattern: PatternDefinition) => void; onChange: (pattern: PatternDefinition) => void;
@@ -189,7 +196,15 @@ function InputField({
); );
} }
export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave, onBack }: PatternEditorProps) { export function PatternEditor({
availableModels,
pattern,
isBuiltin,
onChange,
onDelete,
onSave,
onBack,
}: PatternEditorProps) {
const issues = validatePatternDefinition(pattern); const issues = validatePatternDefinition(pattern);
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) { function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
@@ -199,6 +214,14 @@ export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave,
}); });
} }
function updateAgentModel(agent: PatternAgentDefinition, modelId: string) {
const model = findModel(modelId, availableModels);
updateAgent(agent.id, {
model: modelId,
reasoningEffort: resolveReasoningEffort(model, agent.reasoningEffort),
});
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */} {/* Header — top padding clears the title bar overlay zone */}
@@ -408,13 +431,15 @@ export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave,
value={agent.name} value={agent.name}
/> />
<ModelSelect <ModelSelect
onChange={(v) => updateAgent(agent.id, { model: v })} models={availableModels}
onChange={(value) => updateAgentModel(agent, value)}
value={agent.model} value={agent.model}
/> />
<ReasoningEffortSelect <ReasoningEffortSelect
label="Reasoning" label="Reasoning"
onChange={(value) => updateAgent(agent.id, { reasoningEffort: value })} onChange={(value) => updateAgent(agent.id, { reasoningEffort: value })}
value={agent.reasoningEffort ?? 'high'} supportedEfforts={getSupportedReasoningEfforts(findModel(agent.model, availableModels))}
value={resolveReasoningEffort(findModel(agent.model, availableModels), agent.reasoningEffort)}
/> />
</div> </div>
<div className="mt-3"> <div className="mt-3">
@@ -1,10 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronRight, Plus, X } from 'lucide-react'; import { ChevronRight, Plus, X } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern';
import { PatternEditor } from '@renderer/components/PatternEditor'; import { PatternEditor } from '@renderer/components/PatternEditor';
interface SettingsPanelProps { interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[]; patterns: PatternDefinition[];
onClose: () => void; onClose: () => void;
onSavePattern: (pattern: PatternDefinition) => Promise<void>; onSavePattern: (pattern: PatternDefinition) => Promise<void>;
@@ -18,6 +20,7 @@ function modeBadgeClasses(pattern: PatternDefinition) {
} }
export function SettingsPanel({ export function SettingsPanel({
availableModels,
patterns, patterns,
onClose, onClose,
onSavePattern, onSavePattern,
@@ -31,6 +34,7 @@ export function SettingsPanel({
return ( return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]"> <div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<PatternEditor <PatternEditor
availableModels={availableModels}
isBuiltin={isBuiltin} isBuiltin={isBuiltin}
onBack={() => setEditingPattern(null)} onBack={() => setEditingPattern(null)}
onChange={setEditingPattern} onChange={setEditingPattern}
+1
View File
@@ -1,4 +1,5 @@
export const ipcChannels = { export const ipcChannels = {
describeSidecarCapabilities: 'sidecar:describe-capabilities',
loadWorkspace: 'workspace:load', loadWorkspace: 'workspace:load',
addProject: 'workspace:add-project', addProject: 'workspace:add-project',
removeProject: 'workspace:remove-project', removeProject: 'workspace:remove-project',
+3 -1
View File
@@ -1,3 +1,4 @@
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project'; import type { ProjectRecord } from '@shared/domain/project';
import type { SessionEventRecord } from '@shared/domain/event'; import type { SessionEventRecord } from '@shared/domain/event';
@@ -20,10 +21,11 @@ export interface SendSessionMessageInput {
export interface UpdateScratchpadSessionConfigInput { export interface UpdateScratchpadSessionConfigInput {
sessionId: string; sessionId: string;
model: string; model: string;
reasoningEffort: ReasoningEffort; reasoningEffort?: ReasoningEffort;
} }
export interface ElectronApi { export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
loadWorkspace(): Promise<WorkspaceState>; loadWorkspace(): Promise<WorkspaceState>;
addProject(): Promise<WorkspaceState>; addProject(): Promise<WorkspaceState>;
removeProject(projectId: string): Promise<WorkspaceState>; removeProject(projectId: string): Promise<WorkspaceState>;
+9 -1
View File
@@ -1,4 +1,4 @@
import type { PatternDefinition, PatternValidationIssue } from '@shared/domain/pattern'; import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
export interface SidecarModeCapability { export interface SidecarModeCapability {
@@ -6,9 +6,17 @@ export interface SidecarModeCapability {
reason?: string; reason?: string;
} }
export interface SidecarModelCapability {
id: string;
name: string;
supportedReasoningEfforts?: ReasoningEffort[];
defaultReasoningEffort?: ReasoningEffort;
}
export interface SidecarCapabilities { export interface SidecarCapabilities {
runtime: 'dotnet-maf'; runtime: 'dotnet-maf';
modes: Record<PatternDefinition['mode'], SidecarModeCapability>; modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
models: SidecarModelCapability[];
} }
export interface DescribeCapabilitiesCommand { export interface DescribeCapabilitiesCommand {
+249 -19
View File
@@ -1,37 +1,181 @@
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
export type ModelProvider = 'openai' | 'anthropic' | 'google'; export type ModelProvider = 'openai' | 'anthropic' | 'google';
export interface ModelDefinition { export interface ModelDefinition {
id: string; id: string;
name: string; name: string;
provider: ModelProvider; provider?: ModelProvider;
tier: 'premium' | 'standard' | 'fast'; tier?: 'premium' | 'standard' | 'fast';
supportedReasoningEfforts?: ReadonlyArray<ReasoningEffort>;
defaultReasoningEffort?: ReasoningEffort;
} }
export const modelCatalog: ModelDefinition[] = [ const allReasoningEfforts: ReadonlyArray<ReasoningEffort> = ['low', 'medium', 'high', 'xhigh'];
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'openai', tier: 'fast' },
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.2', name: 'GPT-5.2', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai', tier: 'standard' },
{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', tier: 'fast' },
{ id: 'claude-opus-4.5', name: 'Claude Opus 4.5', provider: 'anthropic', tier: 'premium' }, export const modelCatalog: ReadonlyArray<ModelDefinition> = [
{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', provider: 'anthropic', tier: 'standard' }, {
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic', tier: 'standard' }, id: 'gpt-5.4',
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', provider: 'anthropic', tier: 'fast' }, name: 'GPT-5.4',
provider: 'openai',
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', provider: 'google', tier: 'standard' }, tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.4-mini',
name: 'GPT-5.4 mini',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'high',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1',
name: 'GPT-5.1',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1-Codex',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1-Codex-Max',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1-Codex-Mini',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5-mini',
name: 'GPT-5 mini',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-4.1',
name: 'GPT-4.1',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: [],
},
{
id: 'claude-opus-4.6',
name: 'Claude Opus 4.6',
provider: 'anthropic',
tier: 'premium',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'high',
},
{
id: 'claude-opus-4.6-1m',
name: 'Claude Opus 4.6 (1M context)(Internal only)',
provider: 'anthropic',
tier: 'premium',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'high',
},
{
id: 'claude-opus-4.5',
name: 'Claude Opus 4.5',
provider: 'anthropic',
tier: 'premium',
supportedReasoningEfforts: [],
},
{
id: 'claude-sonnet-4.6',
name: 'Claude Sonnet 4.6',
provider: 'anthropic',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
provider: 'anthropic',
tier: 'standard',
supportedReasoningEfforts: [],
},
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
provider: 'anthropic',
tier: 'standard',
supportedReasoningEfforts: [],
},
{
id: 'claude-haiku-4.5',
name: 'Claude Haiku 4.5',
provider: 'anthropic',
tier: 'fast',
supportedReasoningEfforts: [],
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
provider: 'google',
tier: 'standard',
supportedReasoningEfforts: [],
},
]; ];
export const providerMeta: { id: ModelProvider; label: string }[] = [ const fallbackModelMap = new Map(modelCatalog.map((model) => [model.id, model] as const));
export const providerMeta: ReadonlyArray<{ id: ModelProvider; label: string }> = [
{ id: 'openai', label: 'OpenAI' }, { id: 'openai', label: 'OpenAI' },
{ id: 'anthropic', label: 'Anthropic' }, { id: 'anthropic', label: 'Anthropic' },
{ id: 'google', label: 'Google' }, { id: 'google', label: 'Google' },
]; ];
export function findModel(id: string): ModelDefinition | undefined { export function findModel(
return modelCatalog.find((m) => m.id === id); id: string,
models: ReadonlyArray<ModelDefinition> = modelCatalog,
): ModelDefinition | undefined {
return models.find((model) => model.id === id);
} }
export function inferProvider(modelId: string): ModelProvider | undefined { export function inferProvider(modelId: string): ModelProvider | undefined {
@@ -40,3 +184,89 @@ export function inferProvider(modelId: string): ModelProvider | undefined {
if (modelId.startsWith('gemini-')) return 'google'; if (modelId.startsWith('gemini-')) return 'google';
return undefined; return undefined;
} }
export function buildAvailableModelCatalog(
availableModels?: ReadonlyArray<SidecarModelCapability>,
): ReadonlyArray<ModelDefinition> {
if (!availableModels || availableModels.length === 0) {
return modelCatalog;
}
const providerOrder = new Map(providerMeta.map((provider, index) => [provider.id, index] as const));
return [...availableModels]
.map((model) => {
const fallback = fallbackModelMap.get(model.id);
const provider = fallback?.provider ?? inferProvider(model.id);
return {
id: model.id,
name: model.name || fallback?.name || model.id,
provider,
tier: fallback?.tier,
supportedReasoningEfforts: model.supportedReasoningEfforts,
defaultReasoningEffort: model.defaultReasoningEffort,
} satisfies ModelDefinition;
})
.sort((left, right) => {
const leftProviderOrder =
left.provider !== undefined
? (providerOrder.get(left.provider) ?? Number.MAX_SAFE_INTEGER)
: Number.MAX_SAFE_INTEGER;
const rightProviderOrder =
right.provider !== undefined
? (providerOrder.get(right.provider) ?? Number.MAX_SAFE_INTEGER)
: Number.MAX_SAFE_INTEGER;
const providerCompare = leftProviderOrder - rightProviderOrder;
if (providerCompare !== 0) {
return providerCompare;
}
return left.name.localeCompare(right.name);
});
}
export function getSupportedReasoningEfforts(
model: ModelDefinition | undefined,
): ReadonlyArray<ReasoningEffort> | undefined {
return model?.supportedReasoningEfforts;
}
export function resolveReasoningEffort(
model: ModelDefinition | undefined,
requested: ReasoningEffort | undefined,
): ReasoningEffort | undefined {
const supported = model?.supportedReasoningEfforts;
if (!supported) {
return requested;
}
if (supported.length === 0) {
return undefined;
}
if (requested && supported.includes(requested)) {
return requested;
}
if (model?.defaultReasoningEffort && supported.includes(model.defaultReasoningEffort)) {
return model.defaultReasoningEffort;
}
return supported[0];
}
export function normalizePatternModels(
pattern: PatternDefinition,
models: ReadonlyArray<ModelDefinition>,
): PatternDefinition {
return {
...pattern,
agents: pattern.agents.map((agent) => {
const model = findModel(agent.model, models);
const reasoningEffort = resolveReasoningEffort(model, agent.reasoningEffort);
return reasoningEffort === agent.reasoningEffort ? agent : { ...agent, reasoningEffort };
}),
};
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, test } from 'bun:test';
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModel,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
const availableModels: SidecarModelCapability[] = [
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
supportedReasoningEfforts: [],
},
{
id: 'gpt-5.4',
name: 'GPT-5.4',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'medium',
},
];
function createPattern(): PatternDefinition {
return {
id: 'pattern-1',
name: 'Pattern',
description: '',
mode: 'single',
availability: 'available',
maxIterations: 1,
agents: [
{
id: 'agent-1',
name: 'Primary Agent',
description: 'Helpful assistant',
instructions: 'Help the user.',
model: 'claude-sonnet-4.5',
reasoningEffort: 'high',
},
],
createdAt: '2026-03-23T00:00:00.000Z',
updatedAt: '2026-03-23T00:00:00.000Z',
};
}
describe('dynamic model catalog', () => {
test('builds the available model list from sidecar capabilities', () => {
const catalog = buildAvailableModelCatalog(availableModels);
expect(catalog.map((model) => model.id)).toEqual(['gpt-5.4', 'claude-sonnet-4.5']);
expect(findModel('claude-sonnet-4.5', catalog)?.supportedReasoningEfforts).toEqual([]);
expect(findModel('gpt-5.4', catalog)?.defaultReasoningEffort).toBe('medium');
});
test('drops unsupported reasoning effort selections for a model', () => {
const catalog = buildAvailableModelCatalog(availableModels);
const model = findModel('claude-sonnet-4.5', catalog);
expect(resolveReasoningEffort(model, 'high')).toBeUndefined();
});
test('normalizes pattern agents before runtime execution', () => {
const normalized = normalizePatternModels(
createPattern(),
buildAvailableModelCatalog(availableModels),
);
expect(normalized.agents[0].reasoningEffort).toBeUndefined();
});
});