Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ package-lock.json
.claude
.codex
.worktrees/
.atlarix/

# Qwen Code Configs
.qwen/*
Expand Down
31 changes: 30 additions & 1 deletion packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2430,6 +2430,21 @@ export function App({
);
return true;
}
if (modelArg === '--vision') {
setModelDialogMode('vision');
return true;
}
if (modelArg.startsWith('--vision ')) {
const visionModelId = modelArg.replace(/^--vision\s+/, '');
setWorkspaceSetting(
'workspace',
'visionModel',
visionModelId,
).catch((error: unknown) =>
reportError(error, t('model.setVision')),
);
return true;
}
if (modelArg) {
if (!connectionRef.current.sessionId) {
setPendingModel(modelArg);
Expand Down Expand Up @@ -3298,6 +3313,15 @@ export function App({
[reportError, setWorkspaceSetting, t],
);

const handleVisionModelSelect = useCallback(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Vision model stored as bare ID — core resolution expects provider-qualified encoding

Both this handler and the /model --vision <id> inline path (line 2433) call setWorkspaceSetting('workspace', 'visionModel', modelId) with a bare model ID. The CLI path uses encodeVisionModelSelector(selected) which produces authType:modelId\0baseUrl (see packages/cli/src/ui/components/ModelDialog.tsx:600).

Core's resolveVisionModelSelection() (packages/core/src/config/config.ts:3147) parses through parseVisionModelSetting, then calls resolveModelId(parsedSetting.selector) which expects the authType:modelId format. When it receives a bare ID, resolveModelId cannot extract an authType qualifier — if the same model ID appears on multiple providers/endpoints (e.g., gpt-4o on both OpenAI direct and Azure), the ambiguity guard at line 3187 silently drops the pin. The user sees a success toast but their choice is discarded.

The CLI also calls config?.setVisionModel(visionModel) after persisting to sync the runtime — the web-shell path skips this, so the pin doesn't take effect until the next Config rebuild.

Suggested change
const handleVisionModelSelect = useCallback(
const handleVisionModelSelect = useCallback(
(model: { id: string; authType?: string; baseUrl?: string }) => {
const encoded = model.authType
? `${model.authType}:${model.id}${model.baseUrl ? `\0${model.baseUrl}` : ''}`
: model.id;
setWorkspaceSetting('workspace', 'visionModel', encoded).catch(
(error: unknown) => reportError(error, t('model.setVision')),
);
},
[reportError, setWorkspaceSetting, t],
);

— qwen3.7-max via Qwen Code /review

(modelId: string) => {
setWorkspaceSetting('workspace', 'visionModel', modelId).catch(
(error: unknown) => reportError(error, t('model.setVision')),
);
},
[reportError, setWorkspaceSetting, t],
);

const commands = useMemo(() => {
const skillNames = new Set(connection.skills ?? []);
return mergeCommands(connection.commands ?? [], getLocalCommands(t))
Expand Down Expand Up @@ -3437,7 +3461,9 @@ export function App({
? t('model.setFast')
: modelDialogMode === 'voice'
? t('model.setVoice')
: t('model.select')
: modelDialogMode === 'vision'
? t('model.setVision')
: t('model.select')
}
size="lg"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Dialog title ternary duplicated + onSelect handler not exhaustive

This 4-way ternary (main → fast → voice → vision → default) is duplicated in ModelDialog.tsx:175-179 for the listbox aria-label. Adding a 5th mode requires updating both files in lockstep — forgetting one causes visible/accessible title disagreement.

Separately, the onSelect dispatch at line 3485 uses if/else if/else with no exhaustive check. A new ModelDialogMode member compiles cleanly even without a handler.

Consider extracting shared lookups:

const MODE_TITLE_KEY: Record<ModelDialogMode, string> = {
  main: 'model.select',
  fast: 'model.setFast',
  voice: 'model.setVoice',
  vision: 'model.setVision',
};

// In onSelect:
const handlers: Record<ModelDialogMode, (id: string) => void> = {
  main: handleModelSelect,
  fast: handleFastModelSelect,
  voice: handleVoiceModelSelect,
  vision: handleVisionModelSelect,
};
handlers[modelDialogMode ?? 'main'](modelId);

— qwen3.7-max via Qwen Code /review

onClose={() => setModelDialogMode(null)}
Expand All @@ -3453,6 +3479,8 @@ export function App({
handleFastModelSelect(modelId);
} else if (modelDialogMode === 'voice') {
handleVoiceModelSelect(modelId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing currentVisionModel — dialog highlights wrong model

The currentModelId prop only handles 'voice' mode (currentVoiceModel). For vision mode it falls through to undefined, and ModelDialog falls back to connection.currentModel (the main chat model). The user can't see which vision model is active in the picker.

Derive currentVisionModel from workspaceSettings the same way currentVoiceModel is computed (~line 1649):

const currentVisionModel = (() => {
  const value = workspaceSettings.find(
    (setting) => setting.key === 'visionModel',
  )?.values.effective;
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
})();

Then extend the prop:

currentModelId={
  modelDialogMode === 'voice' ? currentVoiceModel
    : modelDialogMode === 'vision' ? currentVisionModel
    : undefined
}

— qwen3.7-max via Qwen Code /review

} else if (modelDialogMode === 'vision') {
handleVisionModelSelect(modelId);
} else {
handleModelSelect(modelId);
}
Expand Down Expand Up @@ -3556,6 +3584,7 @@ export function App({
onSubDialog={(key) => {
setShowSettingsDialog(false);
if (key === 'fastModel') setModelDialogMode('fast');
else if (key === 'visionModel') setModelDialogMode('vision');
else if (key === 'tools.approvalMode')
setShowApprovalModeDialog(true);
}}
Expand Down
7 changes: 5 additions & 2 deletions packages/web-shell/client/components/dialogs/ModelDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { dp } from './dialogStyles';
import styles from './ModelDialog.module.css';

export type ModelDialogMode = 'main' | 'fast' | 'voice';
export type ModelDialogMode = 'main' | 'fast' | 'voice' | 'vision';

interface ModelDialogProps {
mode?: ModelDialogMode;
Expand Down Expand Up @@ -107,6 +107,7 @@ export function ModelDialog({
const listRef = useRef<HTMLDivElement>(null);
const isFastMode = mode === 'fast';
const isVoiceMode = mode === 'voice';
const isVisionMode = mode === 'vision';
const currentIdx = availableModels.findIndex((m) => m.id === currentModel);
const [activeIndex, setActiveIndex] = useState(
currentIdx >= 0 ? currentIdx : 0,
Expand Down Expand Up @@ -171,7 +172,9 @@ export function ModelDialog({
? t('model.setFast')
: isVoiceMode
? t('model.setVoice')
: t('model.select')
: isVisionMode
? t('model.setVision')
: t('model.select')
}
>
{availableModels.length === 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export interface SettingsMessageSettingsState {
) => Promise<DaemonSettingUpdateResult>;
}

const SUB_DIALOG_KEYS = new Set(['fastModel']);
const SUB_DIALOG_KEYS = new Set(['fastModel', 'visionModel']);
const HIDDEN_SETTING_KEYS = new Set([
'ui.hideTips',
'ui.enableUserFeedback',
Expand Down
2 changes: 1 addition & 1 deletion packages/web-shell/client/constants/localCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function getLocalCommands(t: Translate): CommandInfo[] {
{
name: 'model',
description: t('local.model'),
argumentHint: '[--fast|--voice] [<model>]',
argumentHint: '[--fast|--voice|--vision] [<model>]',
},
{
name: 'mcp',
Expand Down
2 changes: 2 additions & 0 deletions packages/web-shell/client/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,7 @@ const EN: Messages = {
'model.select': 'Select Model',
'model.setFast': 'Set Fast Model',
'model.setVoice': 'Set Voice Model',
'model.setVision': 'Set Vision Model',
'model.switch': 'Switch Model',
'model.unknown': 'unknown',
'resume.current': 'current',
Expand Down Expand Up @@ -2143,6 +2144,7 @@ const ZH: Messages = {
'model.select': '选择模型',
'model.setFast': '设置 Fast Model',
'model.setVoice': '设置语音模型',
'model.setVision': '设置视觉模型',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing settings.label.visionModel / settings.description.visionModel i18n keys

The visionModel setting is now exposed in the Settings dialog via SUB_DIALOG_KEYS (SettingsMessage.tsx), but there are no settings.label.visionModel or settings.description.visionModel entries in either locale. The fastModel setting has both (settings.label.fastModel: '快速模型', etc.), so Chinese users will see the daemon-provided English fallback for the vision model row while fast model is properly localized.

Add to both EN and ZH, mirroring the fastModel pattern:

// EN
'settings.label.visionModel': 'Vision Model',
'settings.description.visionModel': 'Image-capable model used as the vision bridge. Leave empty to auto-select.',

// ZH
'settings.label.visionModel': '视觉模型',
'settings.description.visionModel': '用于视觉桥接的图像能力模型。留空则自动选择。',

— qwen3.7-max via Qwen Code /review

'model.switch': '切换模型',
'model.unknown': '未知',
'resume.current': '当前',
Expand Down
Loading