Skip to content

Commit e45a664

Browse files
committed
Fix composer model override display
1 parent 8f65275 commit e45a664

4 files changed

Lines changed: 271 additions & 92 deletions

File tree

desktop/garyx-desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"dist:mac": "npm run build:packaged && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac --config.mac.identity=null --publish never && npm run install:app",
2323
"dist:dir": "npm run build:packaged && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac --dir --config.mac.identity=null --publish never && npm run install:app",
2424
"dist:mac:release": "node ./scripts/build-mac-release.mjs",
25-
"test:unit": "node --experimental-strip-types --test src/main/gary-client.test.mjs src/shared/desktop-state.test.mjs src/shared/transcript-sync.test.mjs src/renderer/src/app-shell/agent-options.test.mjs src/renderer/src/app-shell/components/task-forest-layout.test.mjs src/renderer/src/app-shell/desktop-route.test.mjs src/renderer/src/app-shell/diagnostics-helpers.test.mjs src/renderer/src/app-shell/gateway-errors.test.mjs src/renderer/src/app-shell/image-generation-content.test.mjs src/renderer/src/app-shell/loading-labels.test.mjs src/renderer/src/app-shell/thread-activity.test.mjs src/renderer/src/bot-console-controller.test.mjs src/renderer/src/bot-console-model.test.mjs src/renderer/src/conversation-state-conformance.test.mjs src/renderer/src/message-machine.test.mjs src/renderer/src/thread-model.test.mjs src/renderer/src/render-view-model.test.mjs src/renderer/src/message-markdown-preprocess.test.mjs src/renderer/src/provider-usage.test.mjs src/renderer/src/task-notification.test.mjs",
25+
"test:unit": "node --experimental-strip-types --test src/main/gary-client.test.mjs src/shared/desktop-state.test.mjs src/shared/transcript-sync.test.mjs src/renderer/src/app-shell/agent-options.test.mjs src/renderer/src/app-shell/components/task-forest-layout.test.mjs src/renderer/src/app-shell/desktop-route.test.mjs src/renderer/src/app-shell/diagnostics-helpers.test.mjs src/renderer/src/app-shell/gateway-errors.test.mjs src/renderer/src/app-shell/image-generation-content.test.mjs src/renderer/src/app-shell/loading-labels.test.mjs src/renderer/src/app-shell/thread-activity.test.mjs src/renderer/src/bot-console-controller.test.mjs src/renderer/src/bot-console-model.test.mjs src/renderer/src/composer-model-control.test.mjs src/renderer/src/conversation-state-conformance.test.mjs src/renderer/src/message-machine.test.mjs src/renderer/src/thread-model.test.mjs src/renderer/src/render-view-model.test.mjs src/renderer/src/message-markdown-preprocess.test.mjs src/renderer/src/provider-usage.test.mjs src/renderer/src/task-notification.test.mjs",
2626
"test:smoke": "npm run dist:dir && node ./scripts/electron-smoke.mjs",
2727
"test:workflow-text-smoke": "npm run build:ui && node ./scripts/workflow-text-smoke.mjs",
2828
"test:i18n-literals": "node ./scripts/check-i18n-literals.mjs",

desktop/garyx-desktop/src/renderer/src/ComposerForm.tsx

Lines changed: 29 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
} from './app-shell/agent-options';
6969
import { AgentOptionAvatar, AgentOptionRow } from './app-shell/components/AgentOptionAvatar';
7070
import { AgentsIcon } from './app-shell/icons';
71+
import { resolveComposerModelControlState } from './composer-model-control';
7172

7273
export type { ComposerAgentOption };
7374

@@ -419,101 +420,38 @@ function renderComposerModelControl({
419420
return null;
420421
}
421422

422-
const catalogModels = providerModels.models || [];
423-
const effectiveModelId =
424-
selectedModel?.trim() ||
425-
effectiveModel?.trim() ||
426-
agentConfiguredModel?.trim() ||
427-
"";
428-
// Include the running model even when the provider catalog omits it, so the
429-
// picker can always mark the active row (mirrors the mobile thread sheet).
430-
const models =
431-
effectiveModelId && !catalogModels.some((option) => option.id === effectiveModelId)
432-
? [
433-
...catalogModels,
434-
{
435-
id: effectiveModelId,
436-
label: effectiveModelId,
437-
recommended: false,
438-
supportedReasoningEfforts: providerModels.reasoningEfforts || [],
439-
serviceTiers: providerModels.serviceTiers || [],
440-
},
441-
]
442-
: catalogModels;
443-
const selectedModelOption = selectedModel
444-
? models.find((option) => option.id === selectedModel)
445-
: undefined;
446-
const effectiveModelOption = effectiveModelId
447-
? models.find((option) => option.id === effectiveModelId)
448-
: undefined;
449-
const defaultModelId =
450-
agentConfiguredModel?.trim() || providerModels.defaultModel?.trim() || effectiveModelId;
451-
const defaultModelOption = defaultModelId
452-
? models.find((option) => option.id === defaultModelId)
453-
: undefined;
454-
const defaultModelLabel = defaultModelOption?.label || defaultModelId || t("Model");
455-
// Thinking options follow the model that will actually run: explicit
456-
// override first, then the resolved agent/provider default.
457-
const effortFilterModelOption = selectedModelOption || effectiveModelOption;
458-
const catalogReasoningEfforts =
459-
effortFilterModelOption?.supportedReasoningEfforts?.length
460-
? effortFilterModelOption.supportedReasoningEfforts
461-
: providerModels.reasoningEfforts || [];
462-
const effectiveReasoningEffortId =
463-
selectedReasoningEffort?.trim() || effectiveReasoningEffort?.trim() || "";
464-
// Include the running level even when the catalog omits it, so the picker can
465-
// always mark the active row.
466-
const reasoningEfforts =
467-
effectiveReasoningEffortId &&
468-
!catalogReasoningEfforts.some((option) => option.id === effectiveReasoningEffortId)
469-
? [
470-
...catalogReasoningEfforts,
471-
{ id: effectiveReasoningEffortId, label: effectiveReasoningEffortId, recommended: false },
472-
]
473-
: catalogReasoningEfforts;
423+
const {
424+
models,
425+
effectiveModelId,
426+
defaultModelId,
427+
defaultModelLabel,
428+
defaultModelOption,
429+
triggerLabel,
430+
reasoningEfforts,
431+
effectiveReasoningEffortId,
432+
defaultReasoningEffortId,
433+
defaultEffortLabel,
434+
serviceTiers,
435+
effectiveServiceTierId,
436+
defaultServiceTierLabel,
437+
} = resolveComposerModelControlState({
438+
providerModels,
439+
agentConfiguredModel,
440+
effectiveModel,
441+
effectiveReasoningEffort,
442+
effectiveServiceTier,
443+
selectedModel,
444+
selectedReasoningEffort,
445+
selectedServiceTier,
446+
modelFallbackLabel: t("Model"),
447+
thinkingLevelFallbackLabel: t("Thinking level"),
448+
standardServiceTierLabel: t("Standard"),
449+
});
474450
const supportsReasoning =
475451
Boolean(providerModels.supportsReasoningEffortSelection) &&
476452
reasoningEfforts.length > 0;
477-
const selectedEffortOption = effectiveReasoningEffortId
478-
? reasoningEfforts.find((option) => option.id === effectiveReasoningEffortId)
479-
: undefined;
480-
const defaultReasoningEffortId =
481-
effortFilterModelOption?.defaultReasoningEffort?.trim() ||
482-
reasoningEfforts.find((option) => option.recommended)?.id ||
483-
reasoningEfforts[0]?.id ||
484-
"";
485-
const defaultEffortOption = defaultReasoningEffortId
486-
? reasoningEfforts.find((option) => option.id === defaultReasoningEffortId)
487-
: undefined;
488-
const defaultEffortLabel =
489-
defaultEffortOption?.label || defaultReasoningEffortId || t("Thinking level");
490-
// Service tier (speed) follows the same model as thinking level.
491-
const catalogServiceTiers =
492-
effortFilterModelOption?.serviceTiers?.length
493-
? effortFilterModelOption.serviceTiers
494-
: providerModels.serviceTiers || [];
495-
const effectiveServiceTierId =
496-
selectedServiceTier?.trim() || effectiveServiceTier?.trim() || "";
497-
const serviceTiers =
498-
effectiveServiceTierId &&
499-
!catalogServiceTiers.some((option) => option.id === effectiveServiceTierId)
500-
? [
501-
...catalogServiceTiers,
502-
{ id: effectiveServiceTierId, label: effectiveServiceTierId, recommended: false },
503-
]
504-
: catalogServiceTiers;
505453
const supportsServiceTier =
506454
Boolean(providerModels.supportsServiceTierSelection) && serviceTiers.length > 0;
507-
// The provider default tier is "standard" (no explicit tier); represented as
508-
// clearing the override.
509-
const defaultServiceTierLabel = t("Standard");
510-
const triggerLabel = effectiveModelOption
511-
? selectedEffortOption
512-
? `${effectiveModelOption.label} · ${selectedEffortOption.label}`
513-
: effectiveModelOption.label
514-
: selectedEffortOption
515-
? `${t("Model")} · ${selectedEffortOption.label}`
516-
: t("Model");
517455

518456
// Selecting a model also clears a service tier the target model does not
519457
// support, so the thread never runs with an unsupported speed tier (mirrors
@@ -539,7 +477,7 @@ function renderComposerModelControl({
539477
<DropdownMenuTrigger
540478
aria-label={t("Change model for this thread")}
541479
className="composer-provider-trigger"
542-
data-muted={!effectiveModelOption && !selectedEffortOption ? "" : undefined}
480+
data-muted={!effectiveModelId && !effectiveReasoningEffortId ? "" : undefined}
543481
type="button"
544482
>
545483
<IconCube aria-hidden size={15} stroke={1.75} />
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import { resolveComposerModelControlState } from './composer-model-control.ts';
5+
6+
const providerModels = {
7+
providerType: 'claude_code',
8+
supportsModelSelection: true,
9+
models: [
10+
{
11+
id: 'claude-opus-4-8',
12+
label: 'Claude Opus 4.8',
13+
recommended: false,
14+
defaultReasoningEffort: 'high',
15+
supportedReasoningEfforts: [
16+
{ id: 'low', label: 'Low', recommended: false },
17+
{ id: 'medium', label: 'Medium', recommended: false },
18+
{ id: 'high', label: 'High', recommended: true },
19+
{ id: 'xhigh', label: 'Extra High', recommended: false },
20+
{ id: 'max', label: 'Max', recommended: false },
21+
],
22+
serviceTiers: [],
23+
},
24+
{
25+
id: 'claude-haiku-4-5',
26+
label: 'Claude Haiku 4.5',
27+
recommended: false,
28+
defaultReasoningEffort: 'high',
29+
supportedReasoningEfforts: [
30+
{ id: 'low', label: 'Low', recommended: false },
31+
{ id: 'medium', label: 'Medium', recommended: false },
32+
{ id: 'high', label: 'High', recommended: true },
33+
],
34+
serviceTiers: [],
35+
},
36+
],
37+
supportsReasoningEffortSelection: true,
38+
reasoningEfforts: [
39+
{ id: 'low', label: 'Low', recommended: false },
40+
{ id: 'medium', label: 'Medium', recommended: false },
41+
{ id: 'high', label: 'High', recommended: true },
42+
],
43+
supportsServiceTierSelection: false,
44+
serviceTiers: [],
45+
defaultModel: null,
46+
source: 'claude_code_builtin',
47+
};
48+
49+
function resolve(overrides = {}) {
50+
return resolveComposerModelControlState({
51+
providerModels,
52+
modelFallbackLabel: 'Model',
53+
thinkingLevelFallbackLabel: 'Thinking level',
54+
standardServiceTierLabel: 'Standard',
55+
...overrides,
56+
});
57+
}
58+
59+
test('explicit model override is not treated as the default reset row', () => {
60+
const state = resolve({ selectedModel: 'claude-opus-4-8' });
61+
62+
assert.equal(state.effectiveModelId, 'claude-opus-4-8');
63+
assert.equal(state.defaultModelId, '');
64+
assert.equal(state.defaultModelLabel, 'Model');
65+
assert.equal(state.triggerLabel, 'Claude Opus 4.8');
66+
assert.deepEqual(
67+
state.models.map((option) => option.id),
68+
['claude-opus-4-8', 'claude-haiku-4-5'],
69+
);
70+
assert.deepEqual(
71+
state.reasoningEfforts.map((option) => option.id),
72+
['low', 'medium', 'high', 'xhigh', 'max'],
73+
);
74+
});
75+
76+
test('effective model can still act as default when no override is selected', () => {
77+
const state = resolve({ effectiveModel: 'claude-opus-4-8' });
78+
79+
assert.equal(state.effectiveModelId, 'claude-opus-4-8');
80+
assert.equal(state.defaultModelId, 'claude-opus-4-8');
81+
assert.equal(state.defaultModelLabel, 'Claude Opus 4.8');
82+
assert.equal(state.triggerLabel, 'Claude Opus 4.8');
83+
});
84+
85+
test('model-less Claude Code menu keeps provider-level reasoning intersection', () => {
86+
const state = resolve();
87+
88+
assert.equal(state.effectiveModelId, '');
89+
assert.equal(state.defaultModelId, '');
90+
assert.equal(state.triggerLabel, 'Model');
91+
assert.deepEqual(
92+
state.reasoningEfforts.map((option) => option.id),
93+
['low', 'medium', 'high'],
94+
);
95+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import type {
2+
DesktopProviderModelOption,
3+
DesktopProviderModels,
4+
} from '@shared/contracts';
5+
6+
export type ComposerModelControlState = {
7+
models: DesktopProviderModelOption[];
8+
effectiveModelId: string;
9+
defaultModelId: string;
10+
defaultModelLabel: string;
11+
defaultModelOption?: DesktopProviderModelOption;
12+
triggerLabel: string;
13+
reasoningEfforts: DesktopProviderModelOption[];
14+
effectiveReasoningEffortId: string;
15+
defaultReasoningEffortId: string;
16+
defaultEffortLabel: string;
17+
serviceTiers: DesktopProviderModelOption[];
18+
effectiveServiceTierId: string;
19+
defaultServiceTierLabel: string;
20+
};
21+
22+
export function resolveComposerModelControlState({
23+
providerModels,
24+
agentConfiguredModel,
25+
effectiveModel,
26+
effectiveReasoningEffort,
27+
effectiveServiceTier,
28+
selectedModel,
29+
selectedReasoningEffort,
30+
selectedServiceTier,
31+
modelFallbackLabel,
32+
thinkingLevelFallbackLabel,
33+
standardServiceTierLabel,
34+
}: {
35+
providerModels: DesktopProviderModels;
36+
agentConfiguredModel?: string | null;
37+
effectiveModel?: string | null;
38+
effectiveReasoningEffort?: string | null;
39+
effectiveServiceTier?: string | null;
40+
selectedModel?: string | null;
41+
selectedReasoningEffort?: string | null;
42+
selectedServiceTier?: string | null;
43+
modelFallbackLabel: string;
44+
thinkingLevelFallbackLabel: string;
45+
standardServiceTierLabel: string;
46+
}): ComposerModelControlState {
47+
const catalogModels = providerModels.models || [];
48+
const selectedModelId = selectedModel?.trim() || '';
49+
const configuredDefaultModelId =
50+
agentConfiguredModel?.trim() || providerModels.defaultModel?.trim() || '';
51+
const effectiveModelId =
52+
selectedModelId ||
53+
effectiveModel?.trim() ||
54+
agentConfiguredModel?.trim() ||
55+
'';
56+
const models =
57+
effectiveModelId && !catalogModels.some((option) => option.id === effectiveModelId)
58+
? [
59+
...catalogModels,
60+
{
61+
id: effectiveModelId,
62+
label: effectiveModelId,
63+
recommended: false,
64+
supportedReasoningEfforts: providerModels.reasoningEfforts || [],
65+
serviceTiers: providerModels.serviceTiers || [],
66+
},
67+
]
68+
: catalogModels;
69+
const selectedModelOption = selectedModelId
70+
? models.find((option) => option.id === selectedModelId)
71+
: undefined;
72+
const effectiveModelOption = effectiveModelId
73+
? models.find((option) => option.id === effectiveModelId)
74+
: undefined;
75+
const defaultModelId =
76+
configuredDefaultModelId || (!selectedModelId ? effectiveModel?.trim() || '' : '');
77+
const defaultModelOption = defaultModelId
78+
? models.find((option) => option.id === defaultModelId)
79+
: undefined;
80+
const defaultModelLabel = defaultModelOption?.label || defaultModelId || modelFallbackLabel;
81+
const effortFilterModelOption = selectedModelOption || effectiveModelOption;
82+
const catalogReasoningEfforts =
83+
effortFilterModelOption?.supportedReasoningEfforts?.length
84+
? effortFilterModelOption.supportedReasoningEfforts
85+
: providerModels.reasoningEfforts || [];
86+
const effectiveReasoningEffortId =
87+
selectedReasoningEffort?.trim() || effectiveReasoningEffort?.trim() || '';
88+
const reasoningEfforts =
89+
effectiveReasoningEffortId &&
90+
!catalogReasoningEfforts.some((option) => option.id === effectiveReasoningEffortId)
91+
? [
92+
...catalogReasoningEfforts,
93+
{ id: effectiveReasoningEffortId, label: effectiveReasoningEffortId, recommended: false },
94+
]
95+
: catalogReasoningEfforts;
96+
const selectedEffortOption = effectiveReasoningEffortId
97+
? reasoningEfforts.find((option) => option.id === effectiveReasoningEffortId)
98+
: undefined;
99+
const defaultReasoningEffortId =
100+
effortFilterModelOption?.defaultReasoningEffort?.trim() ||
101+
reasoningEfforts.find((option) => option.recommended)?.id ||
102+
reasoningEfforts[0]?.id ||
103+
'';
104+
const defaultEffortOption = defaultReasoningEffortId
105+
? reasoningEfforts.find((option) => option.id === defaultReasoningEffortId)
106+
: undefined;
107+
const defaultEffortLabel =
108+
defaultEffortOption?.label || defaultReasoningEffortId || thinkingLevelFallbackLabel;
109+
const catalogServiceTiers =
110+
effortFilterModelOption?.serviceTiers?.length
111+
? effortFilterModelOption.serviceTiers
112+
: providerModels.serviceTiers || [];
113+
const effectiveServiceTierId =
114+
selectedServiceTier?.trim() || effectiveServiceTier?.trim() || '';
115+
const serviceTiers =
116+
effectiveServiceTierId &&
117+
!catalogServiceTiers.some((option) => option.id === effectiveServiceTierId)
118+
? [
119+
...catalogServiceTiers,
120+
{ id: effectiveServiceTierId, label: effectiveServiceTierId, recommended: false },
121+
]
122+
: catalogServiceTiers;
123+
const triggerLabel = effectiveModelOption
124+
? selectedEffortOption
125+
? `${effectiveModelOption.label} · ${selectedEffortOption.label}`
126+
: effectiveModelOption.label
127+
: selectedEffortOption
128+
? `${modelFallbackLabel} · ${selectedEffortOption.label}`
129+
: modelFallbackLabel;
130+
131+
return {
132+
models,
133+
effectiveModelId,
134+
defaultModelId,
135+
defaultModelLabel,
136+
defaultModelOption,
137+
triggerLabel,
138+
reasoningEfforts,
139+
effectiveReasoningEffortId,
140+
defaultReasoningEffortId,
141+
defaultEffortLabel,
142+
serviceTiers,
143+
effectiveServiceTierId,
144+
defaultServiceTierLabel: standardServiceTierLabel,
145+
};
146+
}

0 commit comments

Comments
 (0)