Skip to content

Commit 7b0b370

Browse files
committed
feat: add Claude Code settings dialog for custom API provider
- Settings gear button in AI chat header (visible on hover) - Modal dialog with API Key, Base URL, and API Timeout fields - Preferences persist via PreferencesManager - env overrides passed to Claude Code SDK merged with process.env - Custom endpoint notice shown once per session - Connection timeout (60s) with actionable error messages - Detailed error hints for invalid API key / unreachable endpoint
1 parent 56bd284 commit 7b0b370

3 files changed

Lines changed: 175 additions & 5 deletions

File tree

src-node/claude-code-agent.js

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ exports.checkAvailability = async function () {
140140
* aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete
141141
*/
142142
exports.sendPrompt = async function (params) {
143-
const { prompt, projectPath, sessionAction, model, locale, selectionContext, images } = params;
143+
const { prompt, projectPath, sessionAction, model, locale, selectionContext, images, envOverrides } = params;
144144
const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
145145

146146
// Handle session
@@ -183,7 +183,7 @@ exports.sendPrompt = async function (params) {
183183
}
184184

185185
// Run the query asynchronously — don't await here so we return requestId immediately
186-
_runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images)
186+
_runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images, envOverrides)
187187
.catch(err => {
188188
console.error("[Phoenix AI] Query error:", err);
189189
});
@@ -287,10 +287,11 @@ exports.clearClarification = async function () {
287287
/**
288288
* Internal: run a Claude SDK query and stream results back to the browser.
289289
*/
290-
async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images) {
290+
async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides) {
291291
let editCount = 0;
292292
let toolCounter = 0;
293293
let queryFn;
294+
let connectionTimer = null;
294295

295296
try {
296297
queryFn = await getQueryFn();
@@ -315,10 +316,24 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
315316
phase: "start"
316317
});
317318

319+
if (envOverrides) {
320+
const keys = Object.keys(envOverrides);
321+
console.log("[AI] Using env overrides:", keys.map(k => k + "=" + (k.includes("TOKEN") || k.includes("KEY") ? "***" : envOverrides[k])).join(", "));
322+
}
323+
324+
let _lastStderrLines = [];
325+
const MAX_STDERR_LINES = 20;
326+
318327
const queryOptions = {
319328
cwd: projectPath || process.cwd(),
320329
maxTurns: undefined,
321-
stderr: (data) => console.log("[AI stderr]", data),
330+
stderr: (data) => {
331+
console.log("[AI stderr]", data);
332+
_lastStderrLines.push(data);
333+
if (_lastStderrLines.length > MAX_STDERR_LINES) {
334+
_lastStderrLines.shift();
335+
}
336+
},
322337
allowedTools: [
323338
"Read", "Edit", "Write", "Glob", "Grep", "Bash",
324339
"AskUserQuestion", "Task",
@@ -377,6 +392,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
377392
: ""),
378393
includePartialMessages: true,
379394
abortController: currentAbortController,
395+
env: envOverrides ? Object.assign({}, process.env, envOverrides) : undefined,
380396
hooks: {
381397
PreToolUse: [
382398
{
@@ -650,7 +666,37 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
650666
let textDeltaCount = 0;
651667
let textStreamSendCount = 0;
652668

669+
// Connection timeout — abort if no messages within 60s
670+
let receivedFirstMessage = false;
671+
const CONNECTION_TIMEOUT_MS = 60000;
672+
connectionTimer = setTimeout(() => {
673+
if (!receivedFirstMessage && !signal.aborted) {
674+
_log("Connection timeout — no response in " + (CONNECTION_TIMEOUT_MS / 1000) + "s");
675+
const stderrHint = _lastStderrLines
676+
.filter(line => !line.startsWith("Spawning Claude Code"))
677+
.join("\n").trim();
678+
let timeoutMsg = "Connection timed out — no response from API after " +
679+
(CONNECTION_TIMEOUT_MS / 1000) + " seconds.";
680+
if (envOverrides && envOverrides.ANTHROPIC_BASE_URL) {
681+
timeoutMsg += " Check that the Base URL (" + envOverrides.ANTHROPIC_BASE_URL +
682+
") is correct and reachable.";
683+
}
684+
if (stderrHint) {
685+
timeoutMsg += "\n" + stderrHint;
686+
}
687+
nodeConnector.triggerPeer("aiError", {
688+
requestId: requestId,
689+
error: timeoutMsg
690+
});
691+
currentAbortController.abort();
692+
}
693+
}, CONNECTION_TIMEOUT_MS);
694+
653695
for await (const message of result) {
696+
if (!receivedFirstMessage) {
697+
receivedFirstMessage = true;
698+
clearTimeout(connectionTimer);
699+
}
654700
// Check abort
655701
if (signal.aborted) {
656702
_log("Aborted");
@@ -858,6 +904,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
858904
});
859905
}
860906

907+
clearTimeout(connectionTimer);
861908
_log("Complete: tools=" + toolCounter, "edits=" + editCount,
862909
"textDeltas=" + textDeltaCount, "textSent=" + textStreamSendCount);
863910

@@ -868,6 +915,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
868915
});
869916

870917
} catch (err) {
918+
clearTimeout(connectionTimer);
871919
const errMsg = err.message || String(err);
872920
const isAbort = signal.aborted || /abort/i.test(errMsg);
873921

@@ -886,12 +934,31 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
886934

887935
_log("Error:", errMsg.slice(0, 200));
888936

937+
// Build a detailed error message including stderr context
938+
let detailedError = errMsg;
939+
const stderrContext = _lastStderrLines
940+
.filter(line => !line.startsWith("Spawning Claude Code"))
941+
.join("\n").trim();
942+
if (stderrContext) {
943+
detailedError += "\n" + stderrContext;
944+
}
945+
// Add hint for custom API settings when process exits with code 1
946+
if (/exited with code 1/.test(errMsg) && envOverrides) {
947+
if (envOverrides.ANTHROPIC_AUTH_TOKEN) {
948+
detailedError += "\nThis may be caused by an invalid API key. " +
949+
"Check your API key in Claude Code Settings.";
950+
}
951+
if (envOverrides.ANTHROPIC_BASE_URL) {
952+
detailedError += "\nCustom Base URL: " + envOverrides.ANTHROPIC_BASE_URL;
953+
}
954+
}
955+
889956
// Clear session after error to prevent cascading failures from resuming a broken session
890957
currentSessionId = null;
891958

892959
nodeConnector.triggerPeer("aiError", {
893960
requestId: requestId,
894-
error: errMsg
961+
error: detailedError
895962
});
896963

897964
// Always send aiComplete after aiError so the UI exits streaming state

src/nls/root/strings.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,16 @@ define({
19301930
"AI_CHAT_HISTORY_DELETE_CONFIRM": "Delete this session?",
19311931
"AI_CHAT_SWITCH_PROJECT_TITLE": "AI is working",
19321932
"AI_CHAT_SWITCH_PROJECT_MSG": "AI is currently working on a task. Switching projects will stop it. Continue?",
1933+
"AI_CHAT_SETTINGS_TITLE": "Claude Code Settings",
1934+
"AI_SETTINGS_API_KEY": "API Key",
1935+
"AI_SETTINGS_BASE_URL": "Base URL",
1936+
"AI_SETTINGS_API_TIMEOUT": "API Timeout (ms)",
1937+
"AI_SETTINGS_API_KEY_PLACEHOLDER": "Enter your Anthropic API key",
1938+
"AI_SETTINGS_BASE_URL_PLACEHOLDER": "Leave blank for default",
1939+
"AI_SETTINGS_API_TIMEOUT_PLACEHOLDER": "Leave blank for default",
1940+
"AI_SETTINGS_SAVE": "Save",
1941+
"AI_SETTINGS_RESET": "Reset",
1942+
"AI_SETTINGS_CUSTOM_ENDPOINT_NOTICE": "Using custom API endpoint: {0}",
19331943

19341944
// demo start - Phoenix Code Playground - Interactive Onboarding
19351945
"DEMO_SECTION1_TITLE": "Edit in Live Preview",

src/styles/Extn-AIChatPanel.less

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@
8989
}
9090
}
9191

92+
.ai-settings-btn {
93+
display: flex;
94+
align-items: center;
95+
justify-content: center;
96+
background: none;
97+
border: none;
98+
color: @project-panel-text-2;
99+
font-size: @menu-item-font-size;
100+
width: 26px;
101+
height: 26px;
102+
border-radius: 3px;
103+
cursor: pointer;
104+
opacity: 0;
105+
transition: opacity 0.15s ease, background-color 0.15s ease;
106+
pointer-events: none;
107+
108+
&:hover {
109+
opacity: 1;
110+
background-color: rgba(255, 255, 255, 0.06);
111+
}
112+
}
113+
92114
.ai-new-session-btn {
93115
display: flex;
94116
align-items: center;
@@ -110,6 +132,12 @@
110132
}
111133
}
112134

135+
/* Show settings gear on tab container hover */
136+
.ai-tab-container:hover .ai-settings-btn {
137+
opacity: 0.7;
138+
pointer-events: auto;
139+
}
140+
113141
/* ── Session history dropdown ──────────────────────────────────────── */
114142
/* When history is open, hide chat content and show the dropdown instead */
115143
.ai-chat-panel.ai-history-open {
@@ -1589,3 +1617,68 @@
15891617
}
15901618
}
15911619
}
1620+
1621+
/* ── AI Settings Dialog ────────────────────────────────────────────── */
1622+
.ai-settings-dialog {
1623+
.ai-settings-form {
1624+
display: flex;
1625+
flex-direction: column;
1626+
gap: 14px;
1627+
padding: 4px 0;
1628+
}
1629+
1630+
.ai-settings-field {
1631+
display: flex;
1632+
flex-direction: column;
1633+
gap: 4px;
1634+
1635+
label {
1636+
font-size: 12px;
1637+
font-weight: 600;
1638+
color: @project-panel-text-2;
1639+
}
1640+
1641+
input {
1642+
width: 100%;
1643+
box-sizing: border-box;
1644+
padding: 8px 10px;
1645+
height: 30px;
1646+
border: 1px solid rgba(255, 255, 255, 0.15);
1647+
border-radius: 3px;
1648+
background: rgba(0, 0, 0, 0.2);
1649+
color: @project-panel-text-1;
1650+
font-size: 13px;
1651+
outline: none;
1652+
1653+
&:focus {
1654+
border-color: rgba(76, 175, 80, 0.5);
1655+
}
1656+
1657+
&::placeholder {
1658+
color: @project-panel-text-2;
1659+
opacity: 0.5;
1660+
}
1661+
}
1662+
}
1663+
1664+
.ai-settings-footer {
1665+
display: flex;
1666+
align-items: center;
1667+
}
1668+
1669+
.ai-settings-footer-spacer {
1670+
flex: 1;
1671+
}
1672+
}
1673+
1674+
/* ── Custom endpoint notice ────────────────────────────────────────── */
1675+
.ai-custom-endpoint-notice {
1676+
padding: 6px 10px;
1677+
margin: 0 8px 4px 8px;
1678+
background: rgba(76, 175, 80, 0.08);
1679+
border: 1px solid rgba(76, 175, 80, 0.2);
1680+
border-radius: 4px;
1681+
font-size: @sidebar-xs-font-size;
1682+
color: @project-panel-text-2;
1683+
text-align: center;
1684+
}

0 commit comments

Comments
 (0)