fix(panels): keep desktop runtime config alert hidden when disabled (#1679)

* fix(panels): keep desktop runtime config alert hidden when disabled

* refactor(panels): let render() own visibility in alert-mode show()

Removes the unconditional setEffectiveVisibility(true) before render() in
the show() override. For alert mode, render() already decides visibility
based on config state — calling setEffectiveVisibility(true) first meant
the element briefly appeared then was immediately re-hidden for fully-
configured panels. Non-alert mode is unchanged.

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
Eyüp Can Akman
2026-03-19 10:05:41 +03:00
committed by GitHub
parent ee0f124b3f
commit 29ed8b0fb3
3 changed files with 827 additions and 6 deletions

View File

@@ -34,6 +34,7 @@ export class RuntimeConfigPanel extends Panel {
private readonly mode: 'full' | 'alert';
private readonly buffered: boolean;
private readonly featureFilter?: RuntimeFeatureId[];
private hiddenByUser = false;
private pendingSecrets = new Map<RuntimeSecretKey, string>();
private validatedKeys = new Map<RuntimeSecretKey, boolean>();
private validationMessages = new Map<RuntimeSecretKey, string>();
@@ -157,6 +158,25 @@ export class RuntimeConfigPanel extends Panel {
this.unsubscribe = null;
}
private setEffectiveVisibility(visible: boolean): void {
if (visible) super.show();
else super.hide();
}
public override show(): void {
this.hiddenByUser = false;
if (this.mode === 'alert') {
this.render();
} else {
this.setEffectiveVisibility(true);
}
}
public override hide(): void {
this.hiddenByUser = true;
this.setEffectiveVisibility(false);
}
private captureUnsavedInputs(): void {
if (!this.buffered) return;
this.content.querySelectorAll<HTMLInputElement>('input[data-secret]').forEach((input) => {
@@ -194,24 +214,30 @@ export class RuntimeConfigPanel extends Panel {
const features = this.getFilteredFeatures();
if (desktop && this.mode === 'alert') {
if (this.hiddenByUser) {
this.setEffectiveVisibility(false);
return;
}
const totalFeatures = RUNTIME_FEATURES.length;
const availableFeatures = RUNTIME_FEATURES.filter((feature) => isFeatureAvailable(feature.id)).length;
const missingFeatures = Math.max(0, totalFeatures - availableFeatures);
const configuredCount = Object.keys(snapshot.secrets).length;
const alertState = configuredCount > 0
? (missingFeatures > 0 ? 'some' : 'configured')
: 'needsKeys';
if (missingFeatures === 0 && configuredCount >= totalFeatures) {
this.hide();
this.setEffectiveVisibility(false);
return;
}
const alertTitle = configuredCount > 0
? (missingFeatures > 0 ? t('modals.runtimeConfig.alertTitle.some') : t('modals.runtimeConfig.alertTitle.configured'))
: t('modals.runtimeConfig.alertTitle.needsKeys');
const alertTitle = t(`modals.runtimeConfig.alertTitle.${alertState}`);
const alertClass = missingFeatures > 0 ? 'warn' : 'ok';
this.show();
this.setEffectiveVisibility(true);
this.content.innerHTML = `
<section class="runtime-alert runtime-alert-${alertClass}">
<section class="runtime-alert runtime-alert-${alertClass}" data-alert-state="${alertState}">
<h3>${alertTitle}</h3>
<p>
${availableFeatures}/${totalFeatures} ${t('modals.runtimeConfig.summary.available')}${configuredCount > 0 ? ` · ${configuredCount} ${t('modals.runtimeConfig.summary.secrets')}` : ''}.

View File

@@ -0,0 +1,642 @@
import { build } from 'esbuild';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..', '..');
const entry = resolve(root, 'src/components/RuntimeConfigPanel.ts');
class MiniClassList {
constructor() {
this.values = new Set();
}
add(...tokens) {
tokens.forEach((token) => this.values.add(token));
}
remove(...tokens) {
tokens.forEach((token) => this.values.delete(token));
}
contains(token) {
return this.values.has(token);
}
toggle(token, force) {
if (force === true) {
this.values.add(token);
return true;
}
if (force === false) {
this.values.delete(token);
return false;
}
if (this.values.has(token)) {
this.values.delete(token);
return false;
}
this.values.add(token);
return true;
}
setFromString(value) {
this.values = new Set(String(value).split(/\s+/).filter(Boolean));
}
toString() {
return Array.from(this.values).join(' ');
}
}
class MiniNode extends EventTarget {
constructor() {
super();
this.childNodes = [];
this.parentNode = null;
this.parentElement = null;
}
appendChild(child) {
if (child instanceof MiniDocumentFragment) {
const children = [...child.childNodes];
children.forEach((node) => this.appendChild(node));
return child;
}
if (child.parentNode) {
child.parentNode.removeChild(child);
}
child.parentNode = this;
child.parentElement = this instanceof MiniElement ? this : null;
this.childNodes.push(child);
return child;
}
removeChild(child) {
const index = this.childNodes.indexOf(child);
if (index >= 0) {
this.childNodes.splice(index, 1);
child.parentNode = null;
child.parentElement = null;
}
return child;
}
insertBefore(child, referenceNode) {
if (referenceNode == null) {
return this.appendChild(child);
}
if (child.parentNode) {
child.parentNode.removeChild(child);
}
const index = this.childNodes.indexOf(referenceNode);
if (index === -1) {
return this.appendChild(child);
}
child.parentNode = this;
child.parentElement = this instanceof MiniElement ? this : null;
this.childNodes.splice(index, 0, child);
return child;
}
get firstChild() {
return this.childNodes[0] ?? null;
}
get lastChild() {
return this.childNodes.at(-1) ?? null;
}
get textContent() {
return this.childNodes.map((child) => child.textContent ?? '').join('');
}
set textContent(value) {
this.childNodes = [new MiniText(value ?? '')];
}
}
class MiniText extends MiniNode {
constructor(value) {
super();
this.value = String(value);
}
get textContent() {
return this.value;
}
set textContent(value) {
this.value = String(value);
}
get outerHTML() {
return this.value;
}
}
class MiniDocumentFragment extends MiniNode {
get outerHTML() {
return this.childNodes.map((child) => child.outerHTML ?? child.textContent ?? '').join('');
}
}
class MiniElement extends MiniNode {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.attributes = new Map();
this.classList = new MiniClassList();
this.dataset = {};
this.style = {};
this._innerHTML = '';
this.id = '';
this.title = '';
this.disabled = false;
}
get className() {
return this.classList.toString();
}
set className(value) {
this.classList.setFromString(value);
}
get innerHTML() {
if (this._innerHTML) return this._innerHTML;
return this.childNodes.map((child) => child.outerHTML ?? child.textContent ?? '').join('');
}
set innerHTML(value) {
this._innerHTML = String(value);
this.childNodes = [];
}
appendChild(child) {
this._innerHTML = '';
return super.appendChild(child);
}
insertBefore(child, referenceNode) {
this._innerHTML = '';
return super.insertBefore(child, referenceNode);
}
removeChild(child) {
this._innerHTML = '';
return super.removeChild(child);
}
setAttribute(name, value) {
const stringValue = String(value);
this.attributes.set(name, stringValue);
if (name === 'class') {
this.className = stringValue;
} else if (name === 'id') {
this.id = stringValue;
} else if (name.startsWith('data-')) {
const key = name
.slice(5)
.split('-')
.map((part, index) => (index === 0 ? part : `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`))
.join('');
this.dataset[key] = stringValue;
}
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
hasAttribute(name) {
return this.attributes.has(name);
}
removeAttribute(name) {
this.attributes.delete(name);
if (name === 'class') this.className = '';
}
querySelector() {
return null;
}
querySelectorAll() {
return [];
}
closest() {
return null;
}
remove() {
if (this.parentNode) {
this.parentNode.removeChild(this);
}
}
getBoundingClientRect() {
return { width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 };
}
get nextElementSibling() {
if (!this.parentNode) return null;
const siblings = this.parentNode.childNodes.filter((child) => child instanceof MiniElement);
const index = siblings.indexOf(this);
return index >= 0 ? siblings[index + 1] ?? null : null;
}
get isConnected() {
let current = this.parentNode;
while (current) {
if (current === globalThis.document?.body || current === globalThis.document?.documentElement) {
return true;
}
current = current.parentNode;
}
return false;
}
get outerHTML() {
return `<${this.tagName.toLowerCase()}>${this.innerHTML}</${this.tagName.toLowerCase()}>`;
}
}
class MiniStorage {
constructor() {
this.values = new Map();
}
getItem(key) {
return this.values.has(key) ? this.values.get(key) : null;
}
setItem(key, value) {
this.values.set(key, String(value));
}
removeItem(key) {
this.values.delete(key);
}
clear() {
this.values.clear();
}
}
class MiniDocument extends EventTarget {
constructor() {
super();
this.documentElement = new MiniElement('html');
this.documentElement.clientHeight = 800;
this.documentElement.clientWidth = 1200;
this.body = new MiniElement('body');
this.documentElement.appendChild(this.body);
}
createElement(tagName) {
return new MiniElement(tagName);
}
createTextNode(value) {
return new MiniText(value);
}
createDocumentFragment() {
return new MiniDocumentFragment();
}
}
function createBrowserEnvironment() {
const document = new MiniDocument();
const localStorage = new MiniStorage();
const window = {
document,
localStorage,
innerHeight: 800,
innerWidth: 1200,
addEventListener() {},
removeEventListener() {},
open() {},
getComputedStyle() {
return {
display: '',
visibility: '',
gridTemplateColumns: 'none',
columnGap: '0',
};
},
};
return {
document,
localStorage,
window,
requestAnimationFrame() {
return 1;
},
cancelAnimationFrame() {},
};
}
function snapshotGlobal(name) {
return {
exists: Object.prototype.hasOwnProperty.call(globalThis, name),
value: globalThis[name],
};
}
function restoreGlobal(name, snapshot) {
if (snapshot.exists) {
globalThis[name] = snapshot.value;
return;
}
delete globalThis[name];
}
function createRuntimeState() {
return {
features: [],
availableIds: new Set(),
configuredCount: 0,
listeners: new Set(),
};
}
async function loadRuntimeConfigPanel() {
const tempDir = mkdtempSync(join(tmpdir(), 'wm-runtime-config-panel-'));
const outfile = join(tempDir, 'RuntimeConfigPanel.bundle.mjs');
const stubModules = new Map([
['runtime-config-stub', `
const state = globalThis.__wmRuntimeConfigPanelTestState;
export const RUNTIME_FEATURES = state.features;
export function getEffectiveSecrets() {
return [];
}
export function getRuntimeConfigSnapshot() {
const secrets = Object.fromEntries(
Array.from({ length: state.configuredCount }, (_, index) => [
'SECRET_' + (index + 1),
{ value: 'set', source: 'vault' },
]),
);
return { featureToggles: {}, secrets };
}
export function getSecretState() {
return { present: false, valid: false, source: 'missing' };
}
export function isFeatureAvailable(featureId) {
return state.availableIds.has(featureId);
}
export function isFeatureEnabled() {
return true;
}
export function setFeatureToggle() {}
export async function setSecretValue() {}
export function subscribeRuntimeConfig(listener) {
state.listeners.add(listener);
return () => state.listeners.delete(listener);
}
export function validateSecret() {
return { valid: true };
}
export async function verifySecretWithApi() {
return { valid: true };
}
`],
['runtime-stub', `export function isDesktopRuntime() { return true; }`],
['tauri-bridge-stub', `export async function invokeTauri() {}`],
['i18n-stub', `export function t(key) { return key; }`],
['dom-utils-stub', `
function append(parent, child) {
if (child == null || child === false) return;
if (typeof child === 'string' || typeof child === 'number') {
parent.appendChild(document.createTextNode(String(child)));
return;
}
parent.appendChild(child);
}
export function h(tag, propsOrChild, ...children) {
const el = document.createElement(tag);
let allChildren = children;
if (
propsOrChild != null &&
typeof propsOrChild === 'object' &&
!('tagName' in propsOrChild) &&
!('textContent' in propsOrChild)
) {
for (const [key, value] of Object.entries(propsOrChild)) {
if (value == null || value === false) continue;
if (key === 'className') {
el.className = value;
} else if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (key === 'dataset' && typeof value === 'object') {
Object.assign(el.dataset, value);
} else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (value === true) {
el.setAttribute(key, '');
} else {
el.setAttribute(key, String(value));
}
}
} else {
allChildren = [propsOrChild, ...children];
}
allChildren.forEach((child) => append(el, child));
return el;
}
export function replaceChildren(el, ...children) {
el.innerHTML = '';
children.forEach((child) => append(el, child));
}
export function safeHtml() {
return document.createDocumentFragment();
}
`],
['analytics-stub', `export function trackPanelResized() {} export function trackFeatureToggle() {}`],
['ai-flow-settings-stub', `export function getAiFlowSettings() { return { badgeAnimation: false }; }`],
['sanitize-stub', `export function escapeHtml(value) { return String(value); }`],
['ollama-models-stub', `export async function fetchOllamaModels() { return []; }`],
['settings-constants-stub', `
export const SIGNUP_URLS = {};
export const PLAINTEXT_KEYS = new Set();
export const MASKED_SENTINEL = '***';
`],
]);
const aliasMap = new Map([
['@/services/runtime-config', 'runtime-config-stub'],
['../services/runtime', 'runtime-stub'],
['@/services/runtime', 'runtime-stub'],
['../services/tauri-bridge', 'tauri-bridge-stub'],
['@/services/tauri-bridge', 'tauri-bridge-stub'],
['../services/i18n', 'i18n-stub'],
['@/services/i18n', 'i18n-stub'],
['../utils/dom-utils', 'dom-utils-stub'],
['@/services/analytics', 'analytics-stub'],
['@/services/ai-flow-settings', 'ai-flow-settings-stub'],
['@/utils/sanitize', 'sanitize-stub'],
['@/services/ollama-models', 'ollama-models-stub'],
['@/services/settings-constants', 'settings-constants-stub'],
]);
const plugin = {
name: 'runtime-config-panel-test-stubs',
setup(buildApi) {
buildApi.onResolve({ filter: /.*/ }, (args) => {
const target = aliasMap.get(args.path);
return target ? { path: target, namespace: 'stub' } : null;
});
buildApi.onLoad({ filter: /.*/, namespace: 'stub' }, (args) => ({
contents: stubModules.get(args.path),
loader: 'js',
}));
},
};
const result = await build({
entryPoints: [entry],
bundle: true,
format: 'esm',
platform: 'browser',
target: 'es2020',
write: false,
plugins: [plugin],
});
writeFileSync(outfile, result.outputFiles[0].text, 'utf8');
const mod = await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`);
return {
RuntimeConfigPanel: mod.RuntimeConfigPanel,
cleanupBundle() {
rmSync(tempDir, { recursive: true, force: true });
},
};
}
export async function createRuntimeConfigPanelHarness() {
const originalGlobals = {
document: snapshotGlobal('document'),
window: snapshotGlobal('window'),
localStorage: snapshotGlobal('localStorage'),
requestAnimationFrame: snapshotGlobal('requestAnimationFrame'),
cancelAnimationFrame: snapshotGlobal('cancelAnimationFrame'),
};
const browserEnvironment = createBrowserEnvironment();
const runtimeState = createRuntimeState();
globalThis.document = browserEnvironment.document;
globalThis.window = browserEnvironment.window;
globalThis.localStorage = browserEnvironment.localStorage;
globalThis.requestAnimationFrame = browserEnvironment.requestAnimationFrame;
globalThis.cancelAnimationFrame = browserEnvironment.cancelAnimationFrame;
globalThis.__wmRuntimeConfigPanelTestState = runtimeState;
let RuntimeConfigPanel;
let cleanupBundle;
try {
({ RuntimeConfigPanel, cleanupBundle } = await loadRuntimeConfigPanel());
} catch (error) {
delete globalThis.__wmRuntimeConfigPanelTestState;
restoreGlobal('document', originalGlobals.document);
restoreGlobal('window', originalGlobals.window);
restoreGlobal('localStorage', originalGlobals.localStorage);
restoreGlobal('requestAnimationFrame', originalGlobals.requestAnimationFrame);
restoreGlobal('cancelAnimationFrame', originalGlobals.cancelAnimationFrame);
throw error;
}
const activePanels = [];
function setRuntimeState({
totalFeatures,
availableFeatures,
configuredCount,
}) {
runtimeState.features.splice(
0,
runtimeState.features.length,
...Array.from({ length: totalFeatures }, (_, index) => ({ id: `feature-${index + 1}` })),
);
runtimeState.availableIds = new Set(
runtimeState.features.slice(0, availableFeatures).map((feature) => feature.id),
);
runtimeState.configuredCount = configuredCount;
}
function createPanel(options = { mode: 'alert' }) {
const panel = new RuntimeConfigPanel(options);
activePanels.push(panel);
return panel;
}
function emitRuntimeConfigChange() {
for (const listener of [...runtimeState.listeners]) {
listener();
}
}
function isHidden(panel) {
return panel.getElement().classList.contains('hidden');
}
function getAlertState(panel) {
const match = panel.content.innerHTML.match(/data-alert-state="([^"]+)"/);
return match?.[1] ?? null;
}
function reset() {
while (activePanels.length > 0) {
activePanels.pop()?.destroy();
}
runtimeState.features.length = 0;
runtimeState.availableIds = new Set();
runtimeState.configuredCount = 0;
runtimeState.listeners.clear();
browserEnvironment.localStorage.clear();
}
function cleanup() {
reset();
cleanupBundle();
delete globalThis.__wmRuntimeConfigPanelTestState;
restoreGlobal('document', originalGlobals.document);
restoreGlobal('window', originalGlobals.window);
restoreGlobal('localStorage', originalGlobals.localStorage);
restoreGlobal('requestAnimationFrame', originalGlobals.requestAnimationFrame);
restoreGlobal('cancelAnimationFrame', originalGlobals.cancelAnimationFrame);
}
return {
createPanel,
emitRuntimeConfigChange,
getAlertState,
isHidden,
reset,
cleanup,
setRuntimeState,
};
}

View File

@@ -0,0 +1,153 @@
import { after, afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRuntimeConfigPanelHarness } from './helpers/runtime-config-panel-harness.mjs';
const harness = await createRuntimeConfigPanelHarness();
afterEach(() => {
harness.reset();
});
after(() => {
harness.cleanup();
});
describe('runtime config panel visibility', () => {
it('keeps a fully configured desktop alert hidden when panel settings replay toggle(true)', () => {
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 4,
configuredCount: 4,
});
const panel = harness.createPanel();
assert.equal(harness.isHidden(panel), true, 'configured alert should auto-hide on initial render');
panel.toggle(true);
assert.equal(
harness.isHidden(panel),
true,
'reapplying enabled panel settings must not re-show an already configured alert',
);
});
it('rerenders the current alert state when reopening after an explicit hide', () => {
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 1,
configuredCount: 0,
});
const panel = harness.createPanel();
panel.hide();
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 2,
configuredCount: 1,
});
panel.toggle(true);
assert.equal(harness.isHidden(panel), false, 'reopening should make the panel visible again');
assert.equal(
harness.getAlertState(panel),
'some',
'reopening should recompute the partial-configuration alert state',
);
});
it('reappears when configuration becomes incomplete after auto-hiding as configured', () => {
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 4,
configuredCount: 4,
});
const panel = harness.createPanel();
assert.equal(harness.isHidden(panel), true, 'configured alert should start hidden');
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 2,
configuredCount: 1,
});
harness.emitRuntimeConfigChange();
assert.equal(
harness.isHidden(panel),
false,
'subscription updates should reshow the alert when a configured setup becomes incomplete',
);
assert.equal(
harness.getAlertState(panel),
'some',
'the reshow path should expose the partial-configuration alert state',
);
});
it('shows the configured alert when all desktop features are available but setup is only partially configured', () => {
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 4,
configuredCount: 1,
});
const panel = harness.createPanel();
assert.equal(
harness.isHidden(panel),
false,
'all-available desktop setups with only some secrets configured should stay visible',
);
assert.equal(
harness.getAlertState(panel),
'configured',
'the visible all-available branch should use the configured alert state',
);
});
it('stays hidden when runtime-config subscriptions fire after the panel was disabled', () => {
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 1,
configuredCount: 0,
});
const panel = harness.createPanel();
panel.hide();
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 2,
configuredCount: 1,
});
harness.emitRuntimeConfigChange();
assert.equal(
harness.isHidden(panel),
true,
'runtime-config subscription rerenders must respect an explicit hidden panel state',
);
});
it('shows the needsKeys alert for first-run desktop setup', () => {
harness.setRuntimeState({
totalFeatures: 4,
availableFeatures: 0,
configuredCount: 0,
});
const panel = harness.createPanel();
assert.equal(harness.isHidden(panel), false, 'first-run setup should show the alert');
assert.equal(
harness.getAlertState(panel),
'needsKeys',
'first-run setup should use the needsKeys alert state',
);
});
});