Skip to content

Commit cf6a809

Browse files
committed
feat(ai): support model selection for AI chat panel
Node side: fetch the SDK's supportedModels() list once per process from the first live query (aiModelList event + getSupportedModels peer), and emit aiSessionInfo with the session's resolved model from the init message so the browser can learn what "Default" actually resolves to. Styles: hover-revealed model switcher in the chat header, two-line item layout for the DropdownButton popup, and the mid-conversation model switch notice. Strings: AI_CHAT_MODEL_* keys for the switcher, placeholder and notice.
1 parent 12fff19 commit cf6a809

3 files changed

Lines changed: 133 additions & 1 deletion

File tree

src-node/claude-code-agent.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ let queryModule = null;
4545
// Session state
4646
let currentSessionId = null;
4747

48+
// Model list from the SDK's supportedModels() — fetched once per process,
49+
// best-effort, from the first live query. null until available.
50+
let cachedModelList = null;
51+
52+
/**
53+
* Fetch the model list from a live Query once per process. Best-effort:
54+
* the control request can fail on older CLIs or non-streaming input —
55+
* the browser keeps its static fallback list in that case.
56+
*/
57+
function _fetchModelListOnce(queryResult) {
58+
if (cachedModelList) {
59+
return;
60+
}
61+
queryResult.supportedModels().then(function (models) {
62+
if (models && models.length) {
63+
cachedModelList = models;
64+
nodeConnector.triggerPeer("aiModelList", { models: models });
65+
}
66+
}).catch(function () {});
67+
}
68+
4869
// Active query state
4970
let currentAbortController = null;
5071

@@ -652,6 +673,15 @@ exports.setPermissionMode = async function (params) {
652673
return { success: true };
653674
};
654675

676+
/**
677+
* Return the model list fetched from the SDK, or null if no query has
678+
* populated it yet (browser falls back to a static alias list).
679+
* Called from browser via execPeer("getSupportedModels").
680+
*/
681+
exports.getSupportedModels = async function () {
682+
return { models: cachedModelList };
683+
};
684+
655685
/**
656686
* Resume a previous session by setting the session ID.
657687
* The next sendPrompt call will use queryOptions.resume with this session ID.
@@ -1602,6 +1632,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
16021632
if (!receivedFirstMessage) {
16031633
receivedFirstMessage = true;
16041634
clearTimeout(connectionTimer);
1635+
_fetchModelListOnce(result);
16051636
}
16061637
// Check abort
16071638
if (signal.aborted) {
@@ -1615,6 +1646,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
16151646
_log("Session:", currentSessionId);
16161647
}
16171648

1649+
// The init message carries the fully-resolved model for the
1650+
// session — with no model param this is the user's saved
1651+
// Claude Code default. requestedModel lets the browser tell
1652+
// an explicit pick apart from default resolution.
1653+
if (message.type === "system" && message.subtype === "init" && message.model) {
1654+
nodeConnector.triggerPeer("aiSessionInfo", {
1655+
model: message.model,
1656+
requestedModel: model || null
1657+
});
1658+
}
1659+
16181660
// Subagent tool extraction. The SDK delivers the parent
16191661
// agent's tool calls as a stream of stream_event messages
16201662
// (content_block_start / content_block_delta / content_block_

src/nls/root/strings.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2509,6 +2509,7 @@ define({
25092509
"AI_CHAT_NEW_BTN": "New",
25102510
"AI_CHAT_THINKING": "Thinking...",
25112511
"AI_CHAT_PLACEHOLDER": "Ask Claude...",
2512+
"AI_CHAT_PLACEHOLDER_MODEL": "Ask {0}...",
25122513
"AI_CHAT_SEND_TITLE": "Send message",
25132514
"AI_CHAT_STOP_TITLE": "Stop generation (Esc)",
25142515
"AI_CHAT_CLI_NOT_FOUND": "Claude Code Not Installed",
@@ -2647,6 +2648,10 @@ define({
26472648
"AI_CHAT_MODE_INFO_EDIT": "AI can edit files. Shell commands need approval (Click to switch)",
26482649
"AI_CHAT_MODE_INFO_FULL_AUTO": "AI can edit files and run commands without approval (Click to switch)",
26492650
"AI_CHAT_MODE_SWITCH_HINT": "[or Shift+Tab]",
2651+
"AI_CHAT_MODEL_DEFAULT": "Default",
2652+
"AI_CHAT_MODEL_DEFAULT_DESC_CURRENT": "Currently {0}",
2653+
"AI_CHAT_MODEL_SELECT_TITLE": "Choose the AI model for this chat",
2654+
"AI_CHAT_MODEL_SWITCHED_NOTICE": "Switched to {0}. Applies from your next message; the first response may take a moment longer while the cache rebuilds.",
26502655
"AI_CHAT_INPUT_HINT": "Press {0} to send · {1} for new line",
26512656
"AI_CHAT_BASH_CONFIRM_TITLE": "Allow command?",
26522657
"AI_CHAT_BASH_ALLOW": "Allow",

src/styles/Extn-AIChatPanel.less

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,36 @@
131131
transition: opacity 0.5s ease;
132132
}
133133

134+
/* Model switcher — hover-revealed like .ai-chat-header-actions
135+
(Claude Code CLI doesn't persistently show the model either). */
136+
.ai-model-select {
137+
position: absolute;
138+
left: 10px;
139+
display: flex;
140+
align-items: center;
141+
gap: 5px;
142+
height: 26px;
143+
padding: 0 6px;
144+
color: @project-panel-text-2;
145+
font-size: @label-font-size;
146+
cursor: pointer;
147+
opacity: 0;
148+
pointer-events: none;
149+
transition: opacity 0.5s ease;
150+
151+
.ai-model-select-label {
152+
max-width: 90px;
153+
overflow: hidden;
154+
text-overflow: ellipsis;
155+
white-space: nowrap;
156+
}
157+
158+
i {
159+
font-size: 9px;
160+
opacity: 0.7;
161+
}
162+
}
163+
134164
.ai-history-btn,
135165
.ai-settings-btn {
136166
display: flex;
@@ -162,12 +192,67 @@
162192
transition: opacity 0.15s ease;
163193
}
164194

195+
.ai-tab-container:hover .ai-model-select,
196+
.ai-tab-container .ai-model-select.dropdown-open {
197+
opacity: 1;
198+
pointer-events: auto;
199+
transition: opacity 0.15s ease;
200+
}
165201

166-
/* Left-align title when sidebar is narrow to free space for action buttons */
202+
/* Left-align title when sidebar is narrow to free space for action buttons.
203+
The model switcher joins the flow so it can't overlap the title. */
167204
@container (max-width: 380px) {
168205
.ai-chat-header {
169206
justify-content: flex-start;
207+
208+
.ai-model-select {
209+
position: static;
210+
margin-right: 8px;
211+
padding-left: 0;
212+
}
213+
}
214+
}
215+
216+
/* ── Model selector dropdown ───────────────────────────────────────── */
217+
/* Rendered by the standard DropdownButton widget (native Phoenix dropdown
218+
look) — only the two-line item layout needs styling here. */
219+
.dropdownbutton-popup.ai-model-dropdown-popup {
220+
min-width: 220px;
221+
max-width: 320px;
222+
max-height: 420px; // global 160px cap scrolls 5 two-line items
223+
224+
li a {
225+
white-space: normal;
226+
overflow-wrap: break-word;
170227
}
228+
229+
.ai-model-item-name {
230+
display: block;
231+
}
232+
233+
.ai-model-item-desc {
234+
display: block;
235+
font-size: @ai-text-meta;
236+
opacity: 0.7;
237+
padding-left: 17px;
238+
line-height: @ai-line-compact;
239+
}
240+
}
241+
242+
/* One-time inline notice after switching model mid-conversation.
243+
Sentence-length status prose: secondary tier (12px), reading line-height,
244+
left-aligned — meta 11px/centered is for one-line stats, not sentences. */
245+
.ai-model-switch-notice {
246+
padding: 8px 12px;
247+
margin: 8px;
248+
background: rgba(255, 255, 255, 0.04);
249+
border: 1px solid rgba(255, 255, 255, 0.08);
250+
border-radius: 6px;
251+
font-size: @ai-text-secondary;
252+
line-height: @ai-line-prose;
253+
color: @project-panel-text-2;
254+
white-space: normal;
255+
overflow-wrap: break-word;
171256
}
172257

173258
/* ── Session history dropdown ──────────────────────────────────────── */

0 commit comments

Comments
 (0)