Skip to content
2 changes: 1 addition & 1 deletion tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(usagePanel, /sessionsUsageTimeRange === 'all'/);
assert.match(usagePanel, />\{\{\s*t\('usage\.range\.all'\)\s*\}\}<\/button>/);
assert.match(usagePanel, /sessionsUsageList\.length/);
assert.match(usagePanel, /loadSessionsUsage\(\{ forceRefresh: true \}\)/);
assert.match(usagePanel, /loadSessionsUsage\(\{ forceRefresh: true, range: sessionsUsageTimeRange \}\)/);
assert.match(usagePanel, /sessionUsageSummaryCards/);
assert.match(usagePanel, /class="usage-summary-value" :title="card\.title \|\| null"/);
assert.match(usagePanel, /v-if="card\.note" class="usage-summary-note"/);
Expand Down
18 changes: 13 additions & 5 deletions tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'openclawPendingAuthProfileUpdates',
'sessionTrashEnabled',
'shareCommandPrefix',
'sessionsUsageLoadedLimit',
'taskOrchestrationTabEnabled',
'taskOrchestration',
'_taskOrchestrationPollTimer'
Expand All @@ -345,6 +346,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'sessionsUsageError',
'sessionsUsageList',
'sessionsUsageLoadedOnce',
'sessionsUsageLoadedLimit',
'sessionsUsageLoading',
'sessionsViewMode',
'taskOrchestrationTabEnabled',
Expand Down Expand Up @@ -430,6 +432,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'isValidProviderNameInput',
'isValidProviderUrlInput',
'loadSessionsUsage',
'setSessionsUsageTimeRange',
'onSessionListScroll',
'normalizeSessionTrashEnabled',
'normalizeShareCommandPrefix',
Expand Down Expand Up @@ -503,7 +506,10 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'usePromptTemplateInComposer',
'resetPromptComposer',
'setPromptComposerVarValue',
'copyPromptComposerRendered'
'copyPromptComposerRendered',
'getInstallStatusTarget',
'isInstallTargetInstalled',
'shouldShowCliInstallPlaceholder'
);
const allowedMissingCurrentMethodKeys = [
'closeInstallModal',
Expand Down Expand Up @@ -684,8 +690,10 @@ test('loadAll keeps success transitions aligned with HEAD and stops after startu
throw new Error(`Unexpected action: ${action}`);
}, () => headMethods.loadAll.call(headSuccessContext));

const normalizeLoadAllCalls = (calls) => (Array.isArray(calls) ? calls.filter((item) => item && item.action !== 'install-status') : []);

assert.deepStrictEqual({
calls: currentSuccess.calls,
calls: normalizeLoadAllCalls(currentSuccess.calls),
snapshot: {
loading: currentSuccessContext.loading,
initError: currentSuccessContext.initError,
Expand All @@ -697,10 +705,10 @@ test('loadAll keeps success transitions aligned with HEAD and stops after startu
modelAutoCompactTokenLimitInput: currentSuccessContext.modelAutoCompactTokenLimitInput,
providersList: currentSuccessContext.providersList,
messages: currentSuccessContext.messages,
calls: currentSuccessContext.calls
calls: normalizeLoadAllCalls(currentSuccessContext.calls)
}
}, {
calls: headSuccess.calls,
calls: normalizeLoadAllCalls(headSuccess.calls),
snapshot: {
loading: headSuccessContext.loading,
initError: headSuccessContext.initError,
Expand All @@ -712,7 +720,7 @@ test('loadAll keeps success transitions aligned with HEAD and stops after startu
modelAutoCompactTokenLimitInput: headSuccessContext.modelAutoCompactTokenLimitInput,
providersList: headSuccessContext.providersList,
messages: headSuccessContext.messages,
calls: headSuccessContext.calls
calls: normalizeLoadAllCalls(headSuccessContext.calls)
}
});

Expand Down
38 changes: 38 additions & 0 deletions web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ document.addEventListener('DOMContentLoaded', () => {
sessionsUsageTimeRange: '7d',
sessionsUsageList: [],
sessionsUsageLoadedOnce: false,
sessionsUsageLoadedLimit: 0,
sessionsUsageLoading: false,
sessionsUsageError: '',
sessionsList: [],
Expand Down Expand Up @@ -430,6 +431,43 @@ document.addEventListener('DOMContentLoaded', () => {
this.agentsModalTitle = this.t('modal.agents.title');
this.agentsModalHint = this.t('modal.agents.hint');
}
{
const NAV_STATE_STORAGE_KEY = 'codexmateNavState.v1';
const mainTabSet = new Set(['config', 'sessions', 'usage', 'orchestration', 'market', 'plugins', 'docs', 'settings']);
let restored = null;
try {
const raw = localStorage.getItem(NAV_STATE_STORAGE_KEY) || '';
restored = raw ? JSON.parse(raw) : null;
} catch (_) {
restored = null;
}
const nextMainTab = restored && typeof restored.mainTab === 'string'
? restored.mainTab.trim().toLowerCase()
: '';
const nextConfigMode = restored && typeof restored.configMode === 'string'
? restored.configMode.trim().toLowerCase()
: '';
if (nextConfigMode && typeof this.switchConfigMode === 'function') {
this.__navStateRestoring = true;
try {
if (nextConfigMode === 'codex' || nextConfigMode === 'claude' || nextConfigMode === 'openclaw') {
this.configMode = nextConfigMode;
}
if (nextMainTab && mainTabSet.has(nextMainTab) && nextMainTab !== this.mainTab) {
this.switchMainTab(nextMainTab);
}
} finally {
this.__navStateRestoring = false;
}
} else if (nextMainTab && mainTabSet.has(nextMainTab) && nextMainTab !== this.mainTab) {
this.__navStateRestoring = true;
try {
this.switchMainTab(nextMainTab);
} finally {
this.__navStateRestoring = false;
}
}
}
this.initSessionStandalone();
this.updateCompactLayoutMode();
if (!this.taskOrchestrationTabEnabled && this.mainTab === 'orchestration') {
Expand Down
16 changes: 16 additions & 0 deletions web-ui/modules/app.methods.install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ export function createInstallMethods() {

setInstallRegistryPreset(presetName) {
this.installRegistryPreset = this.normalizeInstallRegistryPreset(presetName);
},

getInstallStatusTarget(targetId) {
const key = typeof targetId === 'string' ? targetId.trim() : '';
if (!key) return null;
const list = Array.isArray(this.installStatusTargets) ? this.installStatusTargets : [];
return list.find((item) => item && item.id === key) || null;
},

isInstallTargetInstalled(targetId) {
const target = this.getInstallStatusTarget(targetId);
return !!(target && target.installed === true);
},

shouldShowCliInstallPlaceholder(targetId) {
return Array.isArray(this.installStatusTargets) && !this.isInstallTargetInstalled(targetId);
Comment on lines +173 to +174

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Require a known target before showing the missing-CLI placeholder.

shouldShowCliInstallPlaceholder('') or a target omitted from installStatusTargets currently returns true, so partial/unknown status can hide the config UI. Resolve the target first and only show the placeholder when that known target is explicitly not installed.

🐛 Proposed fix
 shouldShowCliInstallPlaceholder(targetId) {
-    return Array.isArray(this.installStatusTargets) && !this.isInstallTargetInstalled(targetId);
+    const target = this.getInstallStatusTarget(targetId);
+    return !!(target && target.installed === false);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
shouldShowCliInstallPlaceholder(targetId) {
return Array.isArray(this.installStatusTargets) && !this.isInstallTargetInstalled(targetId);
shouldShowCliInstallPlaceholder(targetId) {
const target = this.getInstallStatusTarget(targetId);
return !!(target && target.installed === false);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/modules/app.methods.install.mjs` around lines 173 - 174, The method
shouldShowCliInstallPlaceholder currently returns true for empty or unknown
targetIds; update it to only show the placeholder when the targetId is a known
entry in installStatusTargets and is explicitly not installed. Change the
condition in shouldShowCliInstallPlaceholder to verify
Array.isArray(this.installStatusTargets) &&
this.installStatusTargets.includes(targetId) (and targetId is non-empty) before
calling !this.isInstallTargetInstalled(targetId), so unknown or omitted targets
do not trigger the placeholder.

}
};
}
43 changes: 43 additions & 0 deletions web-ui/modules/app.methods.navigation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ export function createNavigationMethods(options = {}) {
switchMainTabHelper,
loadMoreSessionMessagesHelper
} = options;
const NAV_STATE_STORAGE_KEY = 'codexmateNavState.v1';
const MAIN_TAB_SET = new Set([
'config',
'sessions',
'usage',
'orchestration',
'market',
'plugins',
'docs',
'settings'
]);
const readNavState = () => {
if (typeof localStorage === 'undefined') return null;
let raw = '';
try {
raw = localStorage.getItem(NAV_STATE_STORAGE_KEY) || '';
} catch (_) {
raw = '';
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (_) {
return null;
}
};
const persistNavState = (vm) => {
if (!vm || vm.__navStateRestoring) return;
if (typeof localStorage === 'undefined') return;
const mainTab = typeof vm.mainTab === 'string' ? vm.mainTab.trim().toLowerCase() : '';
const configMode = typeof vm.configMode === 'string' ? vm.configMode.trim().toLowerCase() : '';
const snapshot = {
mainTab: MAIN_TAB_SET.has(mainTab) ? mainTab : 'docs',
configMode: configModeSet && configModeSet.has(configMode) ? configMode : 'codex'
};
try {
localStorage.setItem(NAV_STATE_STORAGE_KEY, JSON.stringify(snapshot));
} catch (_) {}
Comment on lines +18 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Can accessing window.localStorage throw SecurityError in browsers for blocked storage or opaque origins?

💡 Result:

Yes, accessing window.localStorage can throw a SecurityError in browsers under specific conditions related to blocked storage or opaque origins. Key scenarios from authoritative sources: - Opaque origins (e.g., file://, data:, about:blank without proper URL, or certain sandboxed contexts): Browsers throw SecurityError because the origin lacks a valid scheme/host/port tuple. This is common in local file access, Jest/jsdom tests without configured URL, and some iframe setups. - Blocked storage due to privacy settings: - Chrome/Chromium: "Block third-party cookies and site data" setting denies access, especially in iframes from third-party contexts, throwing "Access is denied for this document." - Safari: "Block All Cookies" or restrictive "Prevent Cross-Site Tracking" settings throw SecurityError ("The operation is insecure"), even for same-origin access. Official MDN documentation for window.localStorage explicitly states it throws SecurityError if: - Origin is not a valid scheme/host/port tuple (opaque origins). - Request violates a policy decision, e.g., user-configured blocking of persistent data (browsers often extend cookie blocking to storage). Developers should wrap access in try-catch and provide fallbacks, as recommended in sources. To safely check availability: try { localStorage.setItem('test', 'test'); localStorage.removeItem('test'); // Available } catch (e) { // Not available, use fallback }

Citations:


Wrap localStorage acquisition in a helper to safely handle access errors.

Accessing localStorage directly can throw SecurityError in blocked-storage or opaque-origin contexts (e.g., sandboxed iframes, strict privacy settings). The current code uses typeof localStorage === 'undefined' which doesn't catch SecurityError, though the subsequent getItem/setItem calls are already wrapped in try-catch. Extract storage access into a dedicated helper for clarity and robustness.

Proposed fix
+    const getNavStorage = () => {
+        try {
+            return globalThis.localStorage || null;
+        } catch (_) {
+            return null;
+        }
+    };
     const readNavState = () => {
-        if (typeof localStorage === 'undefined') return null;
+        const storage = getNavStorage();
+        if (!storage) return null;
         let raw = '';
         try {
-            raw = localStorage.getItem(NAV_STATE_STORAGE_KEY) || '';
+            raw = storage.getItem(NAV_STATE_STORAGE_KEY) || '';
         } catch (_) {
             raw = '';
         }
@@
     const persistNavState = (vm) => {
         if (!vm || vm.__navStateRestoring) return;
-        if (typeof localStorage === 'undefined') return;
+        const storage = getNavStorage();
+        if (!storage) return;
         const mainTab = typeof vm.mainTab === 'string' ? vm.mainTab.trim().toLowerCase() : '';
         const configMode = typeof vm.configMode === 'string' ? vm.configMode.trim().toLowerCase() : '';
@@
         };
         try {
-            localStorage.setItem(NAV_STATE_STORAGE_KEY, JSON.stringify(snapshot));
+            storage.setItem(NAV_STATE_STORAGE_KEY, JSON.stringify(snapshot));
         } catch (_) {}
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const readNavState = () => {
if (typeof localStorage === 'undefined') return null;
let raw = '';
try {
raw = localStorage.getItem(NAV_STATE_STORAGE_KEY) || '';
} catch (_) {
raw = '';
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (_) {
return null;
}
};
const persistNavState = (vm) => {
if (!vm || vm.__navStateRestoring) return;
if (typeof localStorage === 'undefined') return;
const mainTab = typeof vm.mainTab === 'string' ? vm.mainTab.trim().toLowerCase() : '';
const configMode = typeof vm.configMode === 'string' ? vm.configMode.trim().toLowerCase() : '';
const snapshot = {
mainTab: MAIN_TAB_SET.has(mainTab) ? mainTab : 'docs',
configMode: configModeSet && configModeSet.has(configMode) ? configMode : 'codex'
};
try {
localStorage.setItem(NAV_STATE_STORAGE_KEY, JSON.stringify(snapshot));
} catch (_) {}
const getNavStorage = () => {
try {
return globalThis.localStorage || null;
} catch (_) {
return null;
}
};
const readNavState = () => {
const storage = getNavStorage();
if (!storage) return null;
let raw = '';
try {
raw = storage.getItem(NAV_STATE_STORAGE_KEY) || '';
} catch (_) {
raw = '';
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (_) {
return null;
}
};
const persistNavState = (vm) => {
if (!vm || vm.__navStateRestoring) return;
const storage = getNavStorage();
if (!storage) return;
const mainTab = typeof vm.mainTab === 'string' ? vm.mainTab.trim().toLowerCase() : '';
const configMode = typeof vm.configMode === 'string' ? vm.configMode.trim().toLowerCase() : '';
const snapshot = {
mainTab: MAIN_TAB_SET.has(mainTab) ? mainTab : 'docs',
configMode: configModeSet && configModeSet.has(configMode) ? configMode : 'codex'
};
try {
storage.setItem(NAV_STATE_STORAGE_KEY, JSON.stringify(snapshot));
} catch (_) {}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/modules/app.methods.navigation.mjs` around lines 18 - 45, Create a
small helper (e.g., safeLocalStorage()) and use it inside readNavState and
persistNavState to guard all access to the storage object itself and avoid
SecurityError from blocked/opaque contexts; replace direct checks of typeof
localStorage with calls to the helper, have readNavState call
helper().getItem(...) inside its existing try/catch and persistNavState call
helper().setItem(...) inside its try/catch, preserving existing behavior (keep
NAV_STATE_STORAGE_KEY, MAIN_TAB_SET and configModeSet usage and the snapshot
logic) so storage access failures are uniformly handled and clearer.

};

return {
switchConfigMode(mode) {
Expand Down Expand Up @@ -34,6 +74,7 @@ export function createNavigationMethods(options = {}) {
this.scheduleAfterFrame(() => {
this.clearMainTabSwitchIntent('config');
});
persistNavState(this);
return;
}
this.switchMainTab('config');
Expand Down Expand Up @@ -324,6 +365,7 @@ export function createNavigationMethods(options = {}) {
switchState.ticket += 1;
switchState.pendingTarget = '';
const result = switchMainTabHelper.call(this, targetTab);
persistNavState(this);
this.scheduleAfterFrame(() => {
this.clearMainTabSwitchIntent(normalizedTab);
});
Expand All @@ -338,6 +380,7 @@ export function createNavigationMethods(options = {}) {
const pendingTarget = liveState.pendingTarget || targetTab;
liveState.pendingTarget = '';
switchMainTabHelper.call(this, pendingTarget);
persistNavState(this);
this.clearMainTabSwitchIntent(normalizedTab);
});
},
Expand Down
22 changes: 21 additions & 1 deletion web-ui/modules/app.methods.session-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -464,21 +464,40 @@ export function createSessionBrowserMethods(options = {}) {

invalidateSessionsUsageData(options = {}) {
this.sessionsUsageLoadedOnce = false;
this.sessionsUsageLoadedLimit = 0;
this.sessionsUsageError = '';
if (options.preserveList !== true) {
this.sessionsUsageList = [];
}
},

setSessionsUsageTimeRange(nextRange) {
const normalized = typeof nextRange === 'string' ? nextRange.trim().toLowerCase() : '';
const range = normalized === 'all' ? 'all' : (normalized === '30d' ? '30d' : '7d');
this.sessionsUsageTimeRange = range;
void this.loadSessionsUsage({ range });
},

async loadSessionsUsage(options = {}) {
if (this.sessionsUsageLoading) return;
const normalizedRange = typeof options.range === 'string'
? options.range.trim().toLowerCase()
: (typeof this.sessionsUsageTimeRange === 'string' ? this.sessionsUsageTimeRange.trim().toLowerCase() : '');
const range = normalizedRange === 'all' ? 'all' : (normalizedRange === '30d' ? '30d' : '7d');
const defaultLimit = range === 'all' ? 2000 : (range === '30d' ? 1200 : 600);
const rawLimit = Number(options.limit);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 2000)) : defaultLimit;
const loadedLimit = Number(this.sessionsUsageLoadedLimit || 0);
if (this.sessionsUsageLoadedOnce && !options.forceRefresh && loadedLimit >= limit) {
return;
}
this.sessionsUsageLoading = true;
this.sessionsUsageError = '';
let loadSucceeded = false;
try {
const res = await api('list-sessions-usage', {
source: 'all',
limit: 2000,
limit,
forceRefresh: !!options.forceRefresh
});
if (res.error) {
Expand All @@ -495,6 +514,7 @@ export function createSessionBrowserMethods(options = {}) {
this.sessionsUsageLoading = false;
if (loadSucceeded) {
this.sessionsUsageLoadedOnce = true;
this.sessionsUsageLoadedLimit = limit;
}
}
},
Expand Down
12 changes: 12 additions & 0 deletions web-ui/modules/app.methods.startup-claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export function createStartupClaudeMethods(options = {}) {
startupOk = true;
this.currentProvider = statusRes.provider;
this.currentModel = statusRes.model;
try {
const installRes = await api('install-status');
if (installRes && !installRes.error) {
const targets = Array.isArray(installRes.targets) ? installRes.targets : null;
if (targets) {
this.installStatusTargets = targets;
}
if (typeof installRes.packageManager === 'string' && typeof this.normalizeInstallPackageManager === 'function') {
this.installPackageManager = this.normalizeInstallPackageManager(installRes.packageManager);
}
}
} catch (_) {}
Comment on lines +37 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t let install-status failures masquerade as missing CLIs.

This best-effort probe is swallowed on error, but the app’s initial targets are installed: false; if install-status fails, the config panels can remain behind the “missing CLI” placeholder for users who actually have the CLI installed. Track a “status loaded/unknown” state and gate placeholders on a completed probe; ideally run this after core startup state is committed so CLI probing does not block the main config load.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/modules/app.methods.startup-claude.mjs` around lines 37 - 48, The
probe swallow hides failures; add a boolean flag (e.g. this.installStatusLoaded
= false) and set it to true in a finally block around the existing
api('install-status') try/catch so the UI can differentiate “unknown/not probed”
from “installed:false”; keep using this.installStatusTargets and
this.installPackageManager (via this.normalizeInstallPackageManager) on success,
log or surface the error on failure instead of silently ignoring it, and ensure
UI placeholders check installStatusLoaded before showing “missing CLI”
placeholders (also move invocation to run after core startup state is committed
so probing does not block initial config load).

{
const tier = typeof statusRes.serviceTier === 'string'
? statusRes.serviceTier.trim().toLowerCase()
Expand Down
8 changes: 8 additions & 0 deletions web-ui/modules/i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const DICT = Object.freeze({
'common.notExistsWillCreateOnApply': '不存在,将在应用时创建',
'common.notExistsWillCreateOnSave': '不存在,将在保存时创建',
'common.none': '暂无',
'cli.missing.title': '{name} CLI 未安装',
'cli.missing.subtitle': '请先安装 {name} CLI 后再继续使用此页面。',
'cli.missing.openDocs': '打开安装指南',
'cli.missing.commandAria': '{name} CLI 安装命令',

// Brand
'brand.kicker.workspace': '工作区',
Expand Down Expand Up @@ -937,6 +941,10 @@ const DICT = Object.freeze({
'common.notExistsWillCreateOnApply': 'Not found. Will be created on apply.',
'common.notExistsWillCreateOnSave': 'Not found. Will be created on save.',
'common.none': 'None',
'cli.missing.title': '{name} CLI not installed',
'cli.missing.subtitle': 'Install {name} CLI before using this page.',
'cli.missing.openDocs': 'Open install guide',
'cli.missing.commandAria': '{name} CLI install command',

// Brand
'brand.kicker.workspace': 'Workspace',
Expand Down
21 changes: 21 additions & 0 deletions web-ui/partials/index/panel-config-claude.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@
id="panel-config-claude"
role="tabpanel"
:aria-labelledby="'tab-config-claude'">
<template v-if="shouldShowCliInstallPlaceholder('claude')">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gate the placeholder on a verified install-status response.

shouldShowCliInstallPlaceholder('claude') currently treats the default installed: false entry as authoritative. Since install-status is best-effort and failures are ignored in web-ui/modules/app.methods.startup-claude.mjs, a transient probe/API failure can hide the existing Claude config UI indefinitely.

Consider tracking a loaded/known status before showing this placeholder.

🛡️ Possible direction
-                <template v-if="shouldShowCliInstallPlaceholder('claude')">
+                <template v-if="installStatusLoaded && shouldShowCliInstallPlaceholder('claude')">

Then set installStatusLoaded only after a successful install-status response, or update the helper to return false while status is unknown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/partials/index/panel-config-claude.html` at line 8, The placeholder
gating uses shouldShowCliInstallPlaceholder('claude') which treats the default
installed:false as authoritative; change the logic so the placeholder only
appears after a verified install-status response by adding and checking a flag
(e.g., installStatusLoaded) that is set true only upon a successful
install-status API reply in web-ui/modules/app.methods.startup-claude.mjs, and
update shouldShowCliInstallPlaceholder (or its caller) to return false while
install status is unknown (installStatusLoaded === false) so transient probe
failures do not hide the Claude config UI.

<div class="selector-section">
<div class="empty-state">
<div class="empty-state-title">{{ t('cli.missing.title', { name: 'Claude' }) }}</div>
<div class="empty-state-subtitle">{{ t('cli.missing.subtitle', { name: 'Claude' }) }}</div>
<div class="docs-command-row">
<div class="docs-command-box" role="group" :aria-label="t('cli.missing.commandAria', { name: 'Claude' })">
<code class="install-command">{{ getInstallCommand('claude', 'install') }}</code>
<button
type="button"
class="btn-mini docs-copy-btn"
:disabled="!getInstallCommand('claude', 'install')"
@click="copyInstallCommand(getInstallCommand('claude', 'install'))">{{ t('common.copy') }}</button>
</div>
</div>
<button type="button" class="btn-tool btn-tool-compact" @click="mainTab = 'docs'; setInstallCommandAction('install')">{{ t('cli.missing.openDocs') }}</button>
</div>
</div>
</template>
<template v-else>
<!-- 添加提供商按钮 -->
<button class="btn-add" @click="openClaudeConfigModal" v-if="!loading && !initError">
<svg class="icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
Expand Down Expand Up @@ -135,4 +155,5 @@
</div>
</div>
</div>
</template>
</div>
21 changes: 21 additions & 0 deletions web-ui/partials/index/panel-config-codex.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@
id="panel-config-provider"
role="tabpanel"
:aria-labelledby="'tab-config-' + configMode">
<template v-if="isCodexConfigMode && shouldShowCliInstallPlaceholder('codex')">
<div class="selector-section">
<div class="empty-state">
<div class="empty-state-title">{{ t('cli.missing.title', { name: 'Codex' }) }}</div>
<div class="empty-state-subtitle">{{ t('cli.missing.subtitle', { name: 'Codex' }) }}</div>
<div class="docs-command-row">
<div class="docs-command-box" role="group" :aria-label="t('cli.missing.commandAria', { name: 'Codex' })">
<code class="install-command">{{ getInstallCommand('codex', 'install') }}</code>
<button
type="button"
class="btn-mini docs-copy-btn"
:disabled="!getInstallCommand('codex', 'install')"
@click="copyInstallCommand(getInstallCommand('codex', 'install'))">{{ t('common.copy') }}</button>
</div>
</div>
<button type="button" class="btn-tool btn-tool-compact" @click="mainTab = 'docs'; setInstallCommandAction('install')">{{ t('cli.missing.openDocs') }}</button>
</div>
</div>
</template>
<template v-else>
<!-- 添加提供商按钮 -->
<button class="btn-add" @click="showAddModal = true" v-if="!loading && !initError">
<svg class="icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
Expand Down Expand Up @@ -231,4 +251,5 @@
</div>
</div>
</div>
</template>
</div>
Loading
Loading