feat: split settings into LLMs and API Keys tabs, fix keychain vault and Ollama UX

- Split settings window into 3 tabs: LLMs (Ollama/Groq/OpenRouter),
  API Keys (data feeds), and Debug & Logs
- Add featureFilter option to RuntimeConfigPanel for rendering subsets
- Consolidate keychain to single JSON vault entry (1 macOS prompt vs 20)
- Add Ollama model discovery with /api/tags + /v1/models fallback
- Strip <think> reasoning tokens from Ollama responses
- Suppress thinking with think:false in Ollama request body
- Parallel secret verification with 15s global timeout
- Fix manual model input overlapping dropdown (CSS grid-area + hidden-input class)
- Add loading spinners to settings tab panels
- Suppress notification popups when settings window is open
- Filter embed models from Ollama dropdown
- Fix settings window black screen flash with inline dark background
This commit is contained in:
Elie Habib
2026-02-20 00:02:48 +04:00
parent eedf43e94a
commit 6c3d2770f7
14 changed files with 629 additions and 194 deletions

View File

@@ -149,6 +149,7 @@ Rules:
* @property {string} apiUrl - Full chat-completions endpoint URL
* @property {string} model - Model identifier
* @property {Record<string, string>} headers - Request headers (incl. auth)
* @property {Record<string, unknown>} [extraBody] - Extra request body fields (e.g. think: false)
*
* @typedef {Object} ProviderConfig
* @property {string} name - Provider label in responses (e.g. 'groq')
@@ -195,7 +196,7 @@ export function createSummarizeHandler(providerConfig) {
});
}
const { apiUrl, model, headers: providerHeaders } = credentials;
const { apiUrl, model, headers: providerHeaders, extraBody } = credentials;
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
@@ -247,6 +248,7 @@ export function createSummarizeHandler(providerConfig) {
temperature: 0.3,
max_tokens: 150,
top_p: 0.9,
...extraBody,
}),
});
@@ -270,10 +272,18 @@ export function createSummarizeHandler(providerConfig) {
const data = await response.json();
const message = data.choices?.[0]?.message;
const summary = (
(typeof message?.content === 'string' ? message.content.trim() : '')
|| (typeof message?.reasoning === 'string' ? message.reasoning.trim() : '')
);
let rawContent = (typeof message?.content === 'string' ? message.content.trim() : '')
|| (typeof message?.reasoning === 'string' ? message.reasoning.trim() : '');
// Strip <think>...</think> reasoning tokens (common in DeepSeek-R1, QwQ, etc.)
rawContent = rawContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
// Some models output unterminated <think> blocks — strip from <think> to end if no closing tag
if (rawContent.includes('<think>') && !rawContent.includes('</think>')) {
rawContent = rawContent.replace(/<think>[\s\S]*/gi, '').trim();
}
const summary = rawContent;
if (!summary) {
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {

View File

@@ -29,6 +29,7 @@ export default createSummarizeHandler({
apiUrl: new URL('/v1/chat/completions', baseUrl).toString(),
model: process.env.OLLAMA_MODEL || DEFAULT_MODEL,
headers,
extraBody: { think: false },
};
},
});

View File

@@ -6,16 +6,20 @@
<title>World Monitor Settings</title>
<script>(function(){try{var t=localStorage.getItem('worldmonitor-theme');if(t==='light')document.documentElement.dataset.theme='light';}catch(e){}document.documentElement.classList.add('no-transition');})()</script>
</head>
<body>
<body style="background:#1a1c1e;color:#e8eaed;margin:0">
<div class="settings-shell">
<div class="settings-tabs" role="tablist">
<button class="settings-tab active" role="tab" aria-selected="true" aria-controls="tabPanelKeys" data-tab="keys">API Keys</button>
<button class="settings-tab active" role="tab" aria-selected="true" aria-controls="tabPanelLLMs" data-tab="llms">LLMs</button>
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelKeys" data-tab="keys">API Keys</button>
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelDebug" data-tab="debug">Debug &amp; Logs</button>
</div>
<p id="settingsActionStatus" class="settings-action-status" aria-live="polite"></p>
<div class="settings-tab-panels">
<div id="tabPanelKeys" class="settings-tab-panel active" role="tabpanel">
<main id="settingsApp" class="settings-content"></main>
<div id="tabPanelLLMs" class="settings-tab-panel active" role="tabpanel">
<main id="llmApp" class="settings-content"><div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:#9aa0a6;font-size:14px;gap:10px"><svg width="20" height="20" viewBox="0 0 24 24" style="animation:spin 1s linear infinite"><style>@keyframes spin{to{transform:rotate(360deg)}}</style><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31 31"/></svg>Loading...</div></main>
</div>
<div id="tabPanelKeys" class="settings-tab-panel" role="tabpanel">
<main id="apiKeysApp" class="settings-content"><div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:#9aa0a6;font-size:14px;gap:10px"><svg width="20" height="20" viewBox="0 0 24 24" style="animation:spin 1s linear infinite"><style>@keyframes spin{to{transform:rotate(360deg)}}</style><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31 31"/></svg>Loading...</div></main>
</div>
<div id="tabPanelDebug" class="settings-tab-panel" role="tabpanel">
<div class="debug-actions">

View File

@@ -872,7 +872,8 @@ export async function createLocalApiServer(options = {}) {
}
const start = Date.now();
const skipRecord = requestUrl.pathname === '/api/local-traffic-log'
const skipRecord = req.method === 'OPTIONS'
|| requestUrl.pathname === '/api/local-traffic-log'
|| requestUrl.pathname === '/api/local-debug-toggle'
|| requestUrl.pathname === '/api/local-env-update'
|| requestUrl.pathname === '/api/local-validate-secret';

View File

@@ -4,6 +4,7 @@ use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::env;
@@ -53,17 +54,77 @@ struct LocalApiState {
token: Mutex<Option<String>>,
}
/// In-memory cache for keychain secrets. Populated once at startup to avoid
/// repeated macOS Keychain prompts (each `Entry::get_password()` triggers one).
struct SecretsCache {
secrets: Mutex<HashMap<String, String>>,
}
impl SecretsCache {
fn load_from_keychain() -> Self {
// Try consolidated vault first — single keychain prompt
if let Ok(entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") {
if let Ok(json) = entry.get_password() {
if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) {
let secrets: HashMap<String, String> = map
.into_iter()
.filter(|(k, v)| {
SUPPORTED_SECRET_KEYS.contains(&k.as_str()) && !v.trim().is_empty()
})
.map(|(k, v)| (k, v.trim().to_string()))
.collect();
return SecretsCache { secrets: Mutex::new(secrets) };
}
}
}
// Migration: read individual keys (old format), consolidate into vault.
// This triggers one keychain prompt per key — happens only once.
let mut secrets = HashMap::new();
for key in SUPPORTED_SECRET_KEYS.iter() {
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
if let Ok(value) = entry.get_password() {
let trimmed = value.trim().to_string();
if !trimmed.is_empty() {
secrets.insert((*key).to_string(), trimmed);
}
}
}
}
// Write consolidated vault and clean up individual entries
if !secrets.is_empty() {
if let Ok(json) = serde_json::to_string(&secrets) {
if let Ok(vault_entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") {
if vault_entry.set_password(&json).is_ok() {
for key in SUPPORTED_SECRET_KEYS.iter() {
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
let _ = entry.delete_credential();
}
}
}
}
}
}
SecretsCache { secrets: Mutex::new(secrets) }
}
}
#[derive(Serialize)]
struct DesktopRuntimeInfo {
os: String,
arch: String,
}
fn secret_entry(key: &str) -> Result<Entry, String> {
if !SUPPORTED_SECRET_KEYS.contains(&key) {
return Err(format!("Unsupported secret key: {key}"));
}
Entry::new(KEYRING_SERVICE, key).map_err(|e| format!("Keyring init failed: {e}"))
fn save_vault(cache: &HashMap<String, String>) -> Result<(), String> {
let json = serde_json::to_string(cache)
.map_err(|e| format!("Failed to serialize vault: {e}"))?;
let entry = Entry::new(KEYRING_SERVICE, "secrets-vault")
.map_err(|e| format!("Keyring init failed: {e}"))?;
entry.set_password(&json)
.map_err(|e| format!("Failed to write vault: {e}"))?;
Ok(())
}
fn generate_local_token() -> String {
@@ -106,46 +167,49 @@ fn list_supported_secret_keys() -> Vec<String> {
}
#[tauri::command]
fn get_secret(key: String) -> Result<Option<String>, String> {
let entry = secret_entry(&key)?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read keyring secret: {err}")),
fn get_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<Option<String>, String> {
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
return Err(format!("Unsupported secret key: {key}"));
}
let secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
Ok(secrets.get(&key).cloned())
}
#[tauri::command]
fn get_all_secrets() -> std::collections::HashMap<String, String> {
let mut result = std::collections::HashMap::new();
for key in SUPPORTED_SECRET_KEYS.iter() {
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
if let Ok(value) = entry.get_password() {
if !value.trim().is_empty() {
result.insert((*key).to_string(), value.trim().to_string());
}
}
}
}
result
fn get_all_secrets(cache: tauri::State<'_, SecretsCache>) -> HashMap<String, String> {
cache.secrets.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
#[tauri::command]
fn set_secret(key: String, value: String) -> Result<(), String> {
let entry = secret_entry(&key)?;
entry
.set_password(&value)
.map_err(|e| format!("Failed to write keyring secret: {e}"))
fn set_secret(key: String, value: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> {
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
return Err(format!("Unsupported secret key: {key}"));
}
let mut secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
let trimmed = value.trim().to_string();
// Build proposed state, persist first, then commit to cache
let mut proposed = secrets.clone();
if trimmed.is_empty() {
proposed.remove(&key);
} else {
proposed.insert(key, trimmed);
}
save_vault(&proposed)?;
*secrets = proposed;
Ok(())
}
#[tauri::command]
fn delete_secret(key: String) -> Result<(), String> {
let entry = secret_entry(&key)?;
match entry.delete_credential() {
Ok(_) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(format!("Failed to delete keyring secret: {err}")),
fn delete_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> {
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
return Err(format!("Unsupported secret key: {key}"));
}
let mut secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
let mut proposed = secrets.clone();
proposed.remove(&key);
save_vault(&proposed)?;
*secrets = proposed;
Ok(())
}
fn cache_file_path(app: &AppHandle) -> Result<PathBuf, String> {
@@ -360,7 +424,6 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
.inner_size(980.0, 760.0)
.min_inner_size(820.0, 620.0)
.resizable(true)
.visible(false)
.build()
.map_err(|e| format!("Failed to create settings window: {e}"))?;
@@ -658,16 +721,13 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
cmd.current_dir(parent);
}
// Pass keychain secrets to sidecar as env vars
// Pass cached keychain secrets to sidecar as env vars (no keychain re-read)
let mut secret_count = 0u32;
for key in SUPPORTED_SECRET_KEYS.iter() {
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
if let Ok(value) = entry.get_password() {
if !value.trim().is_empty() {
cmd.env(key, value.trim());
secret_count += 1;
}
}
let secrets_cache = app.state::<SecretsCache>();
if let Ok(secrets) = secrets_cache.secrets.lock() {
for (key, value) in secrets.iter() {
cmd.env(key, value);
secret_count += 1;
}
}
append_desktop_log(app, "INFO", &format!("injected {secret_count} keychain secrets into sidecar env"));
@@ -696,6 +756,7 @@ fn main() {
.menu(build_app_menu)
.on_menu_event(handle_menu_event)
.manage(LocalApiState::default())
.manage(SecretsCache::load_from_keychain())
.invoke_handler(tauri::generate_handler![
list_supported_secret_keys,
get_secret,

View File

@@ -348,10 +348,12 @@ export class App {
this.findingsBadge = new IntelligenceGapBadge();
this.findingsBadge.setOnSignalClick((signal) => {
if (this.countryBriefPage?.isVisible()) return;
if (localStorage.getItem('wm-settings-open') === '1') return;
this.signalModal?.showSignal(signal);
});
this.findingsBadge.setOnAlertClick((alert) => {
if (this.countryBriefPage?.isVisible()) return;
if (localStorage.getItem('wm-settings-open') === '1') return;
this.signalModal?.showAlert(alert);
});
}

View File

@@ -185,10 +185,11 @@ export class LiveNewsPanel extends Panel {
// Track user activity to detect idle (pauses after 5 min inactivity)
this.boundIdleResetHandler = () => {
if (this.idleTimeout) clearTimeout(this.idleTimeout);
this.resumeFromIdle();
this.idleTimeout = setTimeout(() => this.pauseForIdle(), this.IDLE_PAUSE_MS);
};
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
document.addEventListener(event, this.boundIdleResetHandler, { passive: true });
});
@@ -265,7 +266,12 @@ export class LiveNewsPanel extends Panel {
this.isPlaying = !this.isPlaying;
this.wasPlayingBeforeIdle = this.isPlaying;
this.updateLiveIndicator();
this.syncPlayerState();
if (this.isPlaying && !this.player && !this.desktopEmbedIframe) {
this.ensurePlayerContainer();
void this.initializePlayer();
} else {
this.syncPlayerState();
}
}
private createMuteButton(): void {
@@ -684,7 +690,7 @@ export class LiveNewsPanel extends Panel {
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
window.removeEventListener('message', this.boundMessageHandler);
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
document.removeEventListener(event, this.boundIdleResetHandler);
});

View File

@@ -311,7 +311,7 @@ export class LiveWebcamsPanel extends Panel {
}, this.IDLE_PAUSE_MS);
};
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
document.addEventListener(event, this.boundIdleResetHandler, { passive: true });
});
@@ -330,7 +330,7 @@ export class LiveWebcamsPanel extends Panel {
this.idleTimeout = null;
}
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
document.removeEventListener(event, this.boundIdleResetHandler);
});
this.observer?.disconnect();

View File

@@ -12,6 +12,7 @@ import {
validateSecret,
verifySecretWithApi,
type RuntimeFeatureDefinition,
type RuntimeFeatureId,
type RuntimeSecretKey,
} from '@/services/runtime-config';
import { invokeTauri } from '@/services/tauri-bridge';
@@ -52,12 +53,14 @@ const MASKED_SENTINEL = '__WM_MASKED__';
interface RuntimeConfigPanelOptions {
mode?: 'full' | 'alert';
buffered?: boolean;
featureFilter?: RuntimeFeatureId[];
}
export class RuntimeConfigPanel extends Panel {
private unsubscribe: (() => void) | null = null;
private readonly mode: 'full' | 'alert';
private readonly buffered: boolean;
private readonly featureFilter?: RuntimeFeatureId[];
private pendingSecrets = new Map<RuntimeSecretKey, string>();
private validatedKeys = new Map<RuntimeSecretKey, boolean>();
private validationMessages = new Map<RuntimeSecretKey, string>();
@@ -66,6 +69,7 @@ export class RuntimeConfigPanel extends Panel {
super({ id: 'runtime-config', title: t('modals.runtimeConfig.title'), showCount: false });
this.mode = options.mode ?? (isDesktopRuntime() ? 'alert' : 'full');
this.buffered = options.buffered ?? false;
this.featureFilter = options.featureFilter;
this.unsubscribe = subscribeRuntimeConfig(() => this.render());
this.render();
}
@@ -94,6 +98,29 @@ export class RuntimeConfigPanel extends Panel {
return this.pendingSecrets.size > 0;
}
private getFilteredFeatures(): RuntimeFeatureDefinition[] {
return this.featureFilter
? RUNTIME_FEATURES.filter(f => this.featureFilter!.includes(f.id))
: RUNTIME_FEATURES;
}
/** Returns missing required secrets for enabled features that have at least one pending key. */
public getMissingRequiredSecrets(): string[] {
const missing: string[] = [];
for (const feature of this.getFilteredFeatures()) {
if (!isFeatureEnabled(feature.id)) continue;
const secrets = getEffectiveSecrets(feature);
const hasPending = secrets.some(k => this.pendingSecrets.has(k));
if (!hasPending) continue;
for (const key of secrets) {
if (!getSecretState(key).valid && !this.pendingSecrets.has(key)) {
missing.push(key);
}
}
}
return missing;
}
public getValidationErrors(): string[] {
const errors: string[] = [];
for (const [key, value] of this.pendingSecrets) {
@@ -108,22 +135,40 @@ export class RuntimeConfigPanel extends Panel {
const errors: string[] = [];
const context = Object.fromEntries(this.pendingSecrets.entries()) as Partial<Record<RuntimeSecretKey, string>>;
// Split into local-only failures vs keys needing remote verification
const toVerifyRemotely: Array<[RuntimeSecretKey, string]> = [];
for (const [key, value] of this.pendingSecrets) {
const localResult = validateSecret(key, value);
if (!localResult.valid) {
this.validatedKeys.set(key, false);
this.validationMessages.set(key, localResult.hint || 'Invalid format');
errors.push(`${key}: ${localResult.hint || 'Invalid format'}`);
continue;
}
const verifyResult = await verifySecretWithApi(key, value, context);
this.validatedKeys.set(key, verifyResult.valid);
if (!verifyResult.valid) {
this.validationMessages.set(key, verifyResult.message || 'Verification failed');
errors.push(`${key}: ${verifyResult.message || 'Verification failed'}`);
} else {
this.validationMessages.delete(key);
toVerifyRemotely.push([key, value]);
}
}
// Run all remote verifications in parallel with a 15s global timeout
if (toVerifyRemotely.length > 0) {
const results = await Promise.race([
Promise.all(toVerifyRemotely.map(async ([key, value]) => {
const result = await verifySecretWithApi(key, value, context);
return { key, result };
})),
new Promise<Array<{ key: RuntimeSecretKey; result: { valid: boolean; message?: string } }>>(resolve =>
setTimeout(() => resolve(toVerifyRemotely.map(([key]) => ({
key, result: { valid: true, message: 'Saved (verification timed out)' },
}))), 15000)
),
]);
for (const { key, result: verifyResult } of results) {
this.validatedKeys.set(key, verifyResult.valid);
if (!verifyResult.valid) {
this.validationMessages.set(key, verifyResult.message || 'Verification failed');
errors.push(`${key}: ${verifyResult.message || 'Verification failed'}`);
} else {
this.validationMessages.delete(key);
}
}
}
@@ -153,6 +198,14 @@ export class RuntimeConfigPanel extends Panel {
this.validationMessages.set(key, result.hint || 'Invalid format');
}
});
// Capture model from select or manual input
const modelSelect = this.content.querySelector<HTMLSelectElement>('select[data-model-select]');
const modelManual = this.content.querySelector<HTMLInputElement>('input[data-model-manual]');
const modelValue = (modelManual && !modelManual.classList.contains('hidden-input') ? modelManual.value.trim() : modelSelect?.value) || '';
if (modelValue && !this.pendingSecrets.has('OLLAMA_MODEL')) {
this.pendingSecrets.set('OLLAMA_MODEL', modelValue);
this.validatedKeys.set('OLLAMA_MODEL', true);
}
}
protected render(): void {
@@ -160,6 +213,8 @@ export class RuntimeConfigPanel extends Panel {
const snapshot = getRuntimeConfigSnapshot();
const desktop = isDesktopRuntime();
const features = this.getFilteredFeatures();
if (desktop && this.mode === 'alert') {
const totalFeatures = RUNTIME_FEATURES.length;
const availableFeatures = RUNTIME_FEATURES.filter((feature) => isFeatureAvailable(feature.id)).length;
@@ -194,10 +249,10 @@ export class RuntimeConfigPanel extends Panel {
this.content.innerHTML = `
<div class="runtime-config-summary">
${desktop ? t('modals.runtimeConfig.summary.desktop') : t('modals.runtimeConfig.summary.web')} · ${Object.keys(snapshot.secrets).length} ${t('modals.runtimeConfig.summary.secrets')} · ${RUNTIME_FEATURES.filter(f => isFeatureAvailable(f.id)).length}/${RUNTIME_FEATURES.length} ${t('modals.runtimeConfig.summary.available')}
${desktop ? t('modals.runtimeConfig.summary.desktop') : t('modals.runtimeConfig.summary.web')} · ${features.filter(f => isFeatureAvailable(f.id)).length}/${features.length} ${t('modals.runtimeConfig.summary.available')}
</div>
<div class="runtime-config-list">
${RUNTIME_FEATURES.map(feature => this.renderFeature(feature)).join('')}
${features.map(feature => this.renderFeature(feature)).join('')}
</div>
`;
@@ -238,7 +293,7 @@ export class RuntimeConfigPanel extends Panel {
const pendingValid = pending ? this.validatedKeys.get(key) : undefined;
const status = pending
? (pendingValid === false ? t('modals.runtimeConfig.status.invalid') : t('modals.runtimeConfig.status.staged'))
: !state.present ? t('modals.runtimeConfig.status.missing') : state.valid ? `${t('modals.runtimeConfig.status.valid')} (${state.source})` : t('modals.runtimeConfig.status.looksInvalid');
: !state.present ? t('modals.runtimeConfig.status.missing') : state.valid ? t('modals.runtimeConfig.status.valid') : t('modals.runtimeConfig.status.looksInvalid');
const statusClass = pending
? (pendingValid === false ? 'warn' : 'staged')
: state.valid ? 'ok' : 'warn';
@@ -256,13 +311,32 @@ export class RuntimeConfigPanel extends Panel {
? (this.validationMessages.get(key) || validateSecret(key, this.pendingSecrets.get(key) || '').hint || 'Invalid value')
: null;
if (key === 'OLLAMA_MODEL') {
const storedModel = pending
? this.pendingSecrets.get(key) || ''
: getRuntimeConfigSnapshot().secrets[key]?.value || '';
return `
<div class="runtime-secret-row">
<div class="runtime-secret-key"><code>${escapeHtml(key)}</code></div>
<span class="runtime-secret-status ${statusClass}">${escapeHtml(status)}</span>
<span class="runtime-secret-check ${checkClass}">&#x2713;</span>
${helpText ? `<div class="runtime-secret-meta">${escapeHtml(helpText)}</div>` : ''}
<select data-model-select class="${inputClass}" ${isDesktopRuntime() ? '' : 'disabled'}>
${storedModel ? `<option value="${escapeHtml(storedModel)}" selected>${escapeHtml(storedModel)}</option>` : '<option value="" selected disabled>Loading models...</option>'}
</select>
<input type="text" data-model-manual class="${inputClass} hidden-input" placeholder="Or type model name" autocomplete="off" ${isDesktopRuntime() ? '' : 'disabled'} ${storedModel ? `value="${escapeHtml(storedModel)}"` : ''}>
${hintText ? `<span class="runtime-secret-hint">${escapeHtml(hintText)}</span>` : ''}
</div>
`;
}
return `
<div class="runtime-secret-row">
<div class="runtime-secret-key"><code>${escapeHtml(key)}</code>${linkHtml}</div>
<span class="runtime-secret-status ${statusClass}">${escapeHtml(status)}</span>
<span class="runtime-secret-check ${checkClass}">&#x2713;</span>
${helpText ? `<div class="runtime-secret-meta">${escapeHtml(helpText)}</div>` : ''}
<input type="${PLAINTEXT_KEYS.has(key) ? 'text' : 'password'}" data-secret="${key}" placeholder="${pending ? t('modals.runtimeConfig.placeholder.staged') : t('modals.runtimeConfig.placeholder.setSecret')}" autocomplete="off" ${isDesktopRuntime() ? '' : 'disabled'} class="${inputClass}" ${pending ? `value="${PLAINTEXT_KEYS.has(key) ? escapeHtml(this.pendingSecrets.get(key) || '') : MASKED_SENTINEL}"` : ''}>
<input type="${PLAINTEXT_KEYS.has(key) ? 'text' : 'password'}" data-secret="${key}" placeholder="${pending ? t('modals.runtimeConfig.placeholder.staged') : t('modals.runtimeConfig.placeholder.setSecret')}" autocomplete="off" ${isDesktopRuntime() ? '' : 'disabled'} class="${inputClass}" ${pending ? `value="${PLAINTEXT_KEYS.has(key) ? escapeHtml(this.pendingSecrets.get(key) || '') : MASKED_SENTINEL}"` : (PLAINTEXT_KEYS.has(key) && state.present ? `value="${escapeHtml(getRuntimeConfigSnapshot().secrets[key]?.value || '')}"` : '')}>
${hintText ? `<span class="runtime-secret-hint">${escapeHtml(hintText)}</span>` : ''}
</div>
`;
@@ -293,6 +367,22 @@ export class RuntimeConfigPanel extends Panel {
return;
}
// Ollama model dropdown: fetch models and handle selection
const modelSelect = this.content.querySelector<HTMLSelectElement>('select[data-model-select]');
if (modelSelect) {
modelSelect.addEventListener('change', () => {
const model = modelSelect.value;
if (model && this.buffered) {
this.pendingSecrets.set('OLLAMA_MODEL', model);
this.validatedKeys.set('OLLAMA_MODEL', true);
modelSelect.classList.remove('invalid');
modelSelect.classList.add('valid-staged');
this.updateFeatureCardStatus('OLLAMA_MODEL');
}
});
void this.fetchOllamaModels(modelSelect);
}
this.content.querySelectorAll<HTMLInputElement>('input[data-toggle]').forEach((input) => {
input.addEventListener('change', () => {
const featureId = input.dataset.toggle as RuntimeFeatureDefinition['id'] | undefined;
@@ -366,6 +456,7 @@ export class RuntimeConfigPanel extends Panel {
row?.appendChild(hint);
}
}
this.updateFeatureCardStatus(key);
} else {
void setSecretValue(key, raw);
input.value = '';
@@ -373,4 +464,119 @@ export class RuntimeConfigPanel extends Panel {
});
});
}
private updateFeatureCardStatus(secretKey: RuntimeSecretKey): void {
const feature = RUNTIME_FEATURES.find(f => getEffectiveSecrets(f).includes(secretKey));
if (!feature) return;
const section = Array.from(this.content.querySelectorAll('.runtime-feature')).find(el => {
const toggle = el.querySelector<HTMLInputElement>(`input[data-toggle="${feature.id}"]`);
return !!toggle;
});
if (!section) return;
const available = isFeatureAvailable(feature.id);
const effectiveSecrets = getEffectiveSecrets(feature);
const allStaged = !available && effectiveSecrets.every(
(k) => getSecretState(k).valid || (this.pendingSecrets.has(k) && this.validatedKeys.get(k) !== false)
);
section.className = `runtime-feature ${available ? 'available' : allStaged ? 'staged' : 'degraded'}`;
const pill = section.querySelector('.runtime-pill');
if (pill) {
pill.className = `runtime-pill ${available ? 'ok' : allStaged ? 'staged' : 'warn'}`;
pill.textContent = available ? t('modals.runtimeConfig.status.ready') : allStaged ? t('modals.runtimeConfig.status.staged') : t('modals.runtimeConfig.status.needsKeys');
}
const fallback = section.querySelector('.runtime-feature-fallback');
if (available || allStaged) {
fallback?.remove();
}
}
private static makeTimeout(ms: number): AbortSignal {
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), ms);
return ctrl.signal;
}
private showManualModelInput(select: HTMLSelectElement): void {
const manual = select.parentElement?.querySelector<HTMLInputElement>('input[data-model-manual]');
if (!manual) return;
select.style.display = 'none';
manual.classList.remove('hidden-input');
manual.addEventListener('blur', () => {
const model = manual.value.trim();
if (model && this.buffered) {
this.pendingSecrets.set('OLLAMA_MODEL', model);
this.validatedKeys.set('OLLAMA_MODEL', true);
manual.classList.remove('invalid');
manual.classList.add('valid-staged');
this.updateFeatureCardStatus('OLLAMA_MODEL');
}
});
}
private async fetchOllamaModels(select: HTMLSelectElement): Promise<void> {
const snapshot = getRuntimeConfigSnapshot();
const ollamaUrl = this.pendingSecrets.get('OLLAMA_API_URL')
|| snapshot.secrets['OLLAMA_API_URL']?.value
|| '';
if (!ollamaUrl) {
select.innerHTML = '<option value="" disabled selected>Set Ollama URL first</option>';
return;
}
const currentModel = this.pendingSecrets.get('OLLAMA_MODEL')
|| snapshot.secrets['OLLAMA_MODEL']?.value
|| '';
try {
// Try Ollama-native /api/tags first, fall back to OpenAI-compatible /v1/models
let models: string[] = [];
try {
const res = await fetch(new URL('/api/tags', ollamaUrl).toString(), {
signal: RuntimeConfigPanel.makeTimeout(5000),
});
if (res.ok) {
const data = await res.json() as { models?: Array<{ name: string }> };
models = (data.models?.map(m => m.name) || []).filter(n => !n.includes('embed'));
}
} catch { /* Ollama endpoint not available, try OpenAI format */ }
if (models.length === 0) {
try {
const res = await fetch(new URL('/v1/models', ollamaUrl).toString(), {
signal: RuntimeConfigPanel.makeTimeout(5000),
});
if (res.ok) {
const data = await res.json() as { data?: Array<{ id: string }> };
models = (data.data?.map(m => m.id) || []).filter(n => !n.includes('embed'));
}
} catch { /* OpenAI endpoint also unavailable */ }
}
if (models.length === 0) {
// No models discovered — show manual text input as fallback
this.showManualModelInput(select);
return;
}
select.innerHTML = models.map(name =>
`<option value="${escapeHtml(name)}" ${name === currentModel ? 'selected' : ''}>${escapeHtml(name)}</option>`
).join('');
// Auto-select first model if none stored
if (!currentModel && models.length > 0) {
const first = models[0]!;
select.value = first;
if (this.buffered) {
this.pendingSecrets.set('OLLAMA_MODEL', first);
this.validatedKeys.set('OLLAMA_MODEL', true);
select.classList.add('valid-staged');
this.updateFeatureCardStatus('OLLAMA_MODEL');
}
}
} catch {
// Complete failure — fall back to manual input
this.showManualModelInput(select);
}
}
}

View File

@@ -97,6 +97,9 @@ requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
// Clear stale settings-open flag (survives ungraceful shutdown)
localStorage.removeItem('wm-settings-open');
const app = new App('app');
app
.init()

View File

@@ -86,7 +86,7 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
id: 'aiOllama',
name: 'Ollama local summarization',
description: 'Local LLM provider via OpenAI-compatible endpoint (Ollama or LM Studio, desktop-first).',
requiredSecrets: ['OLLAMA_API_URL'],
requiredSecrets: ['OLLAMA_API_URL', 'OLLAMA_MODEL'],
fallback: 'Falls back to Groq, then OpenRouter, then local browser model.',
},
{

View File

@@ -1,7 +1,7 @@
import './styles/main.css';
import './styles/settings-window.css';
import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel';
import { loadDesktopSecrets } from '@/services/runtime-config';
import { RUNTIME_FEATURES, loadDesktopSecrets } from '@/services/runtime-config';
import { tryInvokeTauri } from '@/services/tauri-bridge';
import { escapeHtml } from '@/utils/sanitize';
import { initI18n, t } from '@/services/i18n';
@@ -63,41 +63,60 @@ function closeSettingsWindow(): void {
void tryInvokeTauri<void>('close_settings_window').then(() => { }, () => window.close());
}
const LLM_FEATURES: Array<import('@/services/runtime-config').RuntimeFeatureId> = ['aiOllama', 'aiGroq', 'aiOpenRouter'];
function mountPanel(panel: RuntimeConfigPanel, container: HTMLElement): void {
container.innerHTML = '';
const el = panel.getElement();
el.classList.remove('resized', 'span-2', 'span-3', 'span-4');
el.classList.add('settings-runtime-panel');
container.appendChild(el);
}
async function initSettingsWindow(): Promise<void> {
await initI18n(); // Initialize i18n first
await initI18n();
applyStoredTheme();
// Remove no-transition class after first paint to enable smooth theme transitions
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
await loadDesktopSecrets();
const mount = document.getElementById('settingsApp');
if (!mount) return;
const llmMount = document.getElementById('llmApp');
const apiMount = document.getElementById('apiKeysApp');
if (!llmMount || !apiMount) return;
const panel = new RuntimeConfigPanel({ mode: 'full', buffered: true });
const panelElement = panel.getElement();
panelElement.classList.remove('resized', 'span-2', 'span-3', 'span-4');
panelElement.classList.add('settings-runtime-panel');
mount.appendChild(panelElement);
const llmPanel = new RuntimeConfigPanel({ mode: 'full', buffered: true, featureFilter: LLM_FEATURES });
const apiPanel = new RuntimeConfigPanel({
mode: 'full',
buffered: true,
featureFilter: RUNTIME_FEATURES.filter(f => !LLM_FEATURES.includes(f.id)).map(f => f.id),
});
window.addEventListener('beforeunload', () => panel.destroy());
mountPanel(llmPanel, llmMount);
mountPanel(apiPanel, apiMount);
const panels = [llmPanel, apiPanel];
window.addEventListener('beforeunload', () => panels.forEach(p => p.destroy()));
document.getElementById('okBtn')?.addEventListener('click', () => {
void (async () => {
try {
if (!panel.hasPendingChanges()) {
if (!panels.some(p => p.hasPendingChanges())) {
closeSettingsWindow();
return;
}
setActionStatus(t('modals.settingsWindow.validating'), 'ok');
const errors = await panel.verifyPendingSecrets();
console.log('[settings] verify done, errors:', errors.length, errors);
await panel.commitVerifiedSecrets();
console.log('[settings] commit done, remaining pending:', panel.hasPendingChanges());
if (errors.length > 0) {
setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: errors.join(', ') }), 'error');
const missingRequired = panels.flatMap(p => p.getMissingRequiredSecrets());
if (missingRequired.length > 0) {
setActionStatus(`Missing required: ${missingRequired.join(', ')}`, 'error');
return;
}
const allErrors = (await Promise.all(panels.map(p => p.verifyPendingSecrets()))).flat();
await Promise.all(panels.map(p => p.commitVerifiedSecrets()));
if (allErrors.length > 0) {
setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: allErrors.join(', ') }), 'error');
} else {
setActionStatus(t('modals.settingsWindow.saved'), 'ok');
closeSettingsWindow();
@@ -109,18 +128,15 @@ async function initSettingsWindow(): Promise<void> {
})();
});
// Cancel: discard pending, close
document.getElementById('cancelBtn')?.addEventListener('click', () => {
closeSettingsWindow();
});
const openLogsBtn = document.getElementById('openLogsBtn');
openLogsBtn?.addEventListener('click', () => {
document.getElementById('openLogsBtn')?.addEventListener('click', () => {
void invokeDesktopAction('open_logs_folder', t('modals.settingsWindow.openLogs'));
});
const openSidecarLogBtn = document.getElementById('openSidecarLogBtn');
openSidecarLogBtn?.addEventListener('click', () => {
document.getElementById('openSidecarLogBtn')?.addEventListener('click', () => {
void invokeDesktopAction('open_sidecar_log_file', t('modals.settingsWindow.openApiLog'));
});
@@ -221,7 +237,8 @@ function initDiagnostics(): void {
startAutoRefresh();
}
void initSettingsWindow().finally(() => {
void tryInvokeTauri<void>('plugin:window|show', { label: 'settings' });
void tryInvokeTauri<void>('plugin:window|set_focus', { label: 'settings' });
});
// Signal main window that settings is open (suppresses alert popups)
localStorage.setItem('wm-settings-open', '1');
window.addEventListener('beforeunload', () => localStorage.removeItem('wm-settings-open'));
void initSettingsWindow();

View File

@@ -13374,7 +13374,8 @@ body.has-critical-banner .panels-grid {
color: var(--text-secondary);
}
.runtime-secret-row input {
.runtime-secret-row input,
.runtime-secret-row select {
grid-area: input;
width: 100%;
background: var(--overlay-medium);
@@ -13385,6 +13386,10 @@ body.has-critical-banner .panels-grid {
font-size: 11px;
}
.runtime-secret-row input.hidden-input {
display: none !important;
}
.runtime-secret-check {
grid-area: check;
}

View File

@@ -1,35 +1,37 @@
/* Native-style settings window */
/* Palantir-style settings window — dark, readable, color-coded */
.settings-shell {
--settings-bg: var(--bg-secondary);
--settings-surface: var(--surface-hover);
--settings-surface-inset: var(--surface);
--settings-border: var(--overlay-medium);
--settings-border-strong: var(--overlay-heavy);
--settings-text: var(--text);
--settings-text-secondary: var(--text-dim);
--settings-text: #e8eaed;
--settings-text-secondary: #9aa0a6;
--settings-accent: var(--semantic-info);
--settings-green: var(--status-live);
--settings-yellow: var(--yellow);
--settings-green: #34d399;
--settings-yellow: #fbbf24;
--settings-red: var(--semantic-critical);
--settings-blue: #60a5fa;
height: 100vh;
background: var(--settings-bg);
color: var(--settings-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
font-size: 14px;
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Tab bar — macOS segmented control style */
/* ── Tab bar ── */
.settings-tabs {
display: flex;
justify-content: center;
gap: 0;
padding: 12px 16px 0;
padding: 14px 20px 0;
background: var(--settings-bg);
border-bottom: 1px solid var(--settings-border);
}
@@ -41,12 +43,13 @@
border-radius: 6px 6px 0 0;
color: var(--settings-text-secondary);
font: inherit;
font-size: 12px;
font-size: 13px;
font-weight: 500;
padding: 7px 20px;
padding: 9px 24px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
margin-bottom: -1px;
letter-spacing: 0.01em;
}
.settings-tab:hover {
@@ -57,16 +60,17 @@
.settings-tab.active {
color: var(--settings-text);
background: var(--settings-surface);
border-bottom-color: var(--settings-surface);
font-weight: 600;
border-bottom: 2px solid var(--settings-accent);
}
/* Status bar */
/* ── Status bar ── */
.settings-action-status {
margin: 0;
padding: 0 20px;
min-height: 20px;
line-height: 20px;
font-size: 11px;
padding: 0 24px;
min-height: 24px;
line-height: 24px;
font-size: 12px;
color: var(--settings-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
@@ -76,12 +80,31 @@
.settings-action-status.ok { color: var(--settings-green); }
.settings-action-status.error { color: var(--settings-red); }
/* Tab panels */
/* ── Tab panels ── */
.settings-tab-panels {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 20px;
padding: 16px 24px;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.12) transparent;
}
.settings-tab-panels::-webkit-scrollbar {
width: 6px;
}
.settings-tab-panels::-webkit-scrollbar-track {
background: transparent;
}
.settings-tab-panels::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
border-radius: 3px;
}
.settings-tab-panels::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.2);
}
.settings-tab-panel {
@@ -96,7 +119,7 @@
min-height: 0;
}
/* Runtime config panel overrides for settings window */
/* ── Runtime config panel overrides ── */
.settings-runtime-panel {
cursor: default;
min-height: 0;
@@ -123,68 +146,91 @@
padding: 0;
}
/* ── Summary bar ── */
.settings-runtime-panel .runtime-config-summary {
font-size: 12px;
font-size: 13px;
color: var(--settings-text-secondary);
margin-bottom: 12px;
padding: 8px 12px;
margin-bottom: 16px;
padding: 10px 14px;
background: var(--settings-surface);
border-radius: 8px;
border-left: 3px solid var(--settings-accent);
line-height: 1.5;
}
/* ── Feature card grid ── */
.settings-runtime-panel .runtime-config-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 10px;
}
/* ── Feature cards ── */
.settings-runtime-panel .runtime-feature {
margin: 0;
background: var(--settings-surface);
border: 1px solid var(--settings-border);
border-radius: 8px;
padding: 10px 12px;
padding: 14px 16px;
border-left: 3px solid var(--settings-border);
transition: border-color 0.2s;
}
.settings-runtime-panel .runtime-feature.available {
border-color: var(--settings-border);
border-left-color: var(--settings-green);
}
.settings-runtime-panel .runtime-feature.staged {
border-left-color: var(--settings-blue);
}
.settings-runtime-panel .runtime-feature.degraded {
border-left-color: var(--settings-yellow);
}
/* ── Feature header labels ── */
.settings-runtime-panel .runtime-feature-header label {
font-size: 13px;
font-weight: 500;
font-size: 14px;
font-weight: 600;
color: var(--settings-text);
}
/* ── Status pills ── */
.settings-runtime-panel .runtime-pill {
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.settings-runtime-panel .runtime-pill.ok {
color: var(--settings-green);
border-color: color-mix(in srgb, var(--settings-green) 30%, transparent);
background: rgba(52, 211, 153, 0.12);
border-color: rgba(52, 211, 153, 0.25);
}
.settings-runtime-panel .runtime-pill.warn {
color: var(--settings-yellow);
border-color: color-mix(in srgb, var(--settings-yellow) 30%, transparent);
background: rgba(251, 191, 36, 0.12);
border-color: rgba(251, 191, 36, 0.25);
}
.settings-runtime-panel .runtime-pill.staged {
color: var(--settings-green);
border-color: color-mix(in srgb, var(--settings-green) 30%, transparent);
opacity: 0.7;
color: var(--settings-blue);
background: rgba(96, 165, 250, 0.12);
border-color: rgba(96, 165, 250, 0.25);
}
/* ── Secret row inputs ── */
.settings-runtime-panel .runtime-secret-row input {
background: var(--settings-surface-inset);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--settings-border-strong);
border-radius: 6px;
color: var(--settings-text);
padding: 6px 10px;
font-size: 12px;
padding: 8px 12px;
font-size: 13px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
transition: border-color 0.15s;
}
@@ -192,28 +238,75 @@
.settings-runtime-panel .runtime-secret-row input:focus {
outline: none;
border-color: var(--settings-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--settings-accent) 25%, transparent);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
.settings-runtime-panel .runtime-secret-row input::placeholder {
color: var(--text-ghost);
color: rgba(255, 255, 255, 0.2);
}
.settings-runtime-panel .runtime-secret-row select[data-model-select] {
grid-area: input;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--settings-border-strong);
border-radius: 6px;
color: var(--settings-text);
padding: 8px 12px;
font-size: 13px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
width: 100%;
cursor: pointer;
transition: border-color 0.15s;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239aa0a6'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
/* Hide manual model input until activated */
.settings-runtime-panel .runtime-secret-row input[data-model-manual].hidden-input {
display: none !important;
}
.settings-runtime-panel .runtime-secret-row select[data-model-select]:focus {
outline: none;
border-color: var(--settings-accent);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
.settings-runtime-panel .runtime-secret-row select[data-model-select].valid-staged {
border-color: rgba(52, 211, 153, 0.5);
}
.settings-runtime-panel .runtime-secret-row select[data-model-select] option {
background: #1e1e2e;
color: var(--settings-text);
}
/* ── Secret meta & status ── */
.settings-runtime-panel .runtime-secret-meta {
color: var(--text-faint);
color: var(--settings-text-secondary);
font-size: 12px;
}
.settings-runtime-panel .runtime-secret-status.ok {
color: var(--settings-green);
font-size: 12px;
font-weight: 500;
}
.settings-runtime-panel .runtime-secret-status.warn {
color: var(--settings-yellow);
font-size: 12px;
font-weight: 500;
}
.settings-runtime-panel .runtime-secret-status.staged {
color: var(--settings-green);
opacity: 0.7;
color: var(--settings-blue);
font-size: 12px;
font-weight: 500;
}
.settings-runtime-panel .runtime-feature-fallback.fallback {
@@ -224,10 +317,10 @@
accent-color: var(--settings-accent);
}
/* Validation feedback */
/* ── Validation feedback ── */
.runtime-secret-check {
color: var(--settings-green);
font-size: 14px;
font-size: 15px;
margin-left: 6px;
display: none;
}
@@ -238,29 +331,29 @@
.runtime-secret-hint {
color: var(--settings-red);
font-size: 11px;
font-size: 12px;
}
.settings-runtime-panel .runtime-secret-row input.valid-staged {
border-color: color-mix(in srgb, var(--settings-green) 50%, transparent);
border-color: rgba(52, 211, 153, 0.5);
}
.settings-runtime-panel .runtime-secret-row input.invalid {
border-color: var(--settings-red);
}
/* Signup link icon */
/* ── Signup link icon ── */
.runtime-secret-key {
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
grid-area: key;
}
.runtime-secret-link {
color: var(--settings-text-secondary);
text-decoration: none;
font-size: 12px;
font-size: 13px;
line-height: 1;
opacity: 0.5;
transition: opacity 0.15s, color 0.15s;
@@ -271,13 +364,13 @@
opacity: 1;
}
/* Footer with OK / Cancel */
/* ── Footer ── */
.settings-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px 20px;
gap: 10px;
padding: 14px 24px;
border-top: 1px solid var(--settings-border);
background: var(--settings-bg);
flex-shrink: 0;
@@ -286,41 +379,48 @@
.settings-btn {
font: inherit;
font-size: 13px;
font-weight: 500;
padding: 6px 20px;
font-weight: 600;
padding: 8px 24px;
border-radius: 6px;
cursor: pointer;
min-width: 70px;
min-width: 80px;
text-align: center;
transition: background 0.15s, border-color 0.15s;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
letter-spacing: 0.01em;
}
.settings-btn:active {
transform: scale(0.98);
}
.settings-btn-secondary {
background: var(--settings-surface);
background: transparent;
border: 1px solid var(--settings-border-strong);
color: var(--settings-text);
color: var(--settings-text-secondary);
}
.settings-btn-secondary:hover {
background: var(--overlay-medium);
background: var(--overlay-subtle);
color: var(--settings-text);
border-color: var(--settings-text-secondary);
}
.settings-btn-primary {
background: var(--settings-accent);
border: 1px solid var(--settings-accent);
color: var(--accent);
color: #fff;
}
.settings-btn-primary:hover {
background: color-mix(in srgb, var(--settings-accent) 85%, black);
border-color: color-mix(in srgb, var(--settings-accent) 85%, black);
background: color-mix(in srgb, var(--settings-accent) 85%, white);
border-color: color-mix(in srgb, var(--settings-accent) 85%, white);
}
/* Debug tab */
/* ── Debug tab ── */
.debug-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 16px;
}
.debug-actions button {
@@ -328,8 +428,8 @@
background: var(--settings-surface);
color: var(--settings-text);
font: inherit;
font-size: 12px;
padding: 7px 14px;
font-size: 13px;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
@@ -339,37 +439,38 @@
background: var(--overlay-medium);
}
/* Diagnostics */
/* ── Diagnostics ── */
.settings-diagnostics {
border: 1px solid var(--settings-border);
background: var(--settings-surface);
border-radius: 8px;
padding: 12px 14px;
padding: 14px 16px;
}
.diag-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
margin-bottom: 12px;
}
.diag-header h2 {
margin: 0;
font-size: 14px;
font-size: 15px;
font-weight: 600;
color: var(--settings-text);
}
.diag-toggles {
display: flex;
gap: 16px;
gap: 18px;
}
.diag-toggles label {
font-size: 12px;
font-size: 13px;
display: flex;
align-items: center;
gap: 5px;
gap: 6px;
cursor: pointer;
color: var(--settings-text-secondary);
}
@@ -382,13 +483,13 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
margin-bottom: 10px;
}
.diag-traffic-bar h3 {
margin: 0;
font-size: 12px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
color: var(--settings-text-secondary);
}
@@ -398,15 +499,15 @@
.diag-traffic-controls {
display: flex;
gap: 8px;
gap: 10px;
align-items: center;
}
.diag-traffic-controls label {
font-size: 11px;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
gap: 5px;
color: var(--settings-text-secondary);
cursor: pointer;
}
@@ -416,8 +517,8 @@
background: var(--settings-surface-inset);
color: var(--settings-text-secondary);
font: inherit;
font-size: 11px;
padding: 3px 10px;
font-size: 12px;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
@@ -431,37 +532,55 @@
.diag-traffic-log {
max-height: 300px;
overflow-y: auto;
font-size: 11px;
font-size: 12px;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.12) transparent;
}
.diag-traffic-log::-webkit-scrollbar {
width: 5px;
}
.diag-traffic-log::-webkit-scrollbar-track {
background: transparent;
}
.diag-traffic-log::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
border-radius: 3px;
}
.diag-empty {
color: var(--settings-text-secondary);
font-style: italic;
margin: 10px 0;
font-size: 12px;
margin: 12px 0;
font-size: 13px;
}
/* ── Diagnostics table ── */
.diag-table {
width: 100%;
border-collapse: collapse;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
font-size: 12px;
}
.diag-table th {
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--settings-border);
color: var(--settings-text-secondary);
font-weight: 500;
font-size: 10px;
padding: 6px 10px;
border-bottom: 1px solid var(--settings-border-strong);
color: var(--settings-text);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
background: var(--settings-surface);
}
.diag-table td {
padding: 3px 8px;
padding: 5px 10px;
border-bottom: 1px solid var(--overlay-subtle);
white-space: nowrap;
overflow: hidden;