diff --git a/api/_summarize-handler.js b/api/_summarize-handler.js index 51a550809..ce7172774 100644 --- a/api/_summarize-handler.js +++ b/api/_summarize-handler.js @@ -149,6 +149,7 @@ Rules: * @property {string} apiUrl - Full chat-completions endpoint URL * @property {string} model - Model identifier * @property {Record} headers - Request headers (incl. auth) + * @property {Record} [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 ... reasoning tokens (common in DeepSeek-R1, QwQ, etc.) + rawContent = rawContent.replace(/[\s\S]*?<\/think>/gi, '').trim(); + + // Some models output unterminated blocks — strip from to end if no closing tag + if (rawContent.includes('') && !rawContent.includes('')) { + rawContent = rawContent.replace(/[\s\S]*/gi, '').trim(); + } + + const summary = rawContent; if (!summary) { return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), { diff --git a/api/ollama-summarize.js b/api/ollama-summarize.js index 43d0e170d..e90464a36 100644 --- a/api/ollama-summarize.js +++ b/api/ollama-summarize.js @@ -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 }, }; }, }); diff --git a/settings.html b/settings.html index df0e3bf67..604062bda 100644 --- a/settings.html +++ b/settings.html @@ -6,16 +6,20 @@ World Monitor Settings - +
- + +

-
-
+
+
Loading...
+
+
+
Loading...
diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 393d3e92f..efeec0f01 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -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'; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3aa9df251..f4dfb0b6f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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>, } +/// 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>, +} + +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::>(&json) { + let secrets: HashMap = 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 { - 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) -> 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 { } #[tauri::command] -fn get_secret(key: String) -> Result, 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, 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 { - 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 { + 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 { @@ -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::(); + 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, diff --git a/src/App.ts b/src/App.ts index 20e11daeb..1291e1c89 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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); }); } diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index c0c321a41..ee1b4de16 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -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); }); diff --git a/src/components/LiveWebcamsPanel.ts b/src/components/LiveWebcamsPanel.ts index c973b601c..380a8d30a 100644 --- a/src/components/LiveWebcamsPanel.ts +++ b/src/components/LiveWebcamsPanel.ts @@ -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(); diff --git a/src/components/RuntimeConfigPanel.ts b/src/components/RuntimeConfigPanel.ts index b0f6e0533..0c02c7d92 100644 --- a/src/components/RuntimeConfigPanel.ts +++ b/src/components/RuntimeConfigPanel.ts @@ -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(); private validatedKeys = new Map(); private validationMessages = new Map(); @@ -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>; + // 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>(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('select[data-model-select]'); + const modelManual = this.content.querySelector('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 = `
- ${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')}
- ${RUNTIME_FEATURES.map(feature => this.renderFeature(feature)).join('')} + ${features.map(feature => this.renderFeature(feature)).join('')}
`; @@ -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 ` +
+
${escapeHtml(key)}
+ ${escapeHtml(status)} + + ${helpText ? `
${escapeHtml(helpText)}
` : ''} + + + ${hintText ? `${escapeHtml(hintText)}` : ''} +
+ `; + } + return `
${escapeHtml(key)}${linkHtml}
${escapeHtml(status)} ${helpText ? `
${escapeHtml(helpText)}
` : ''} - + ${hintText ? `${escapeHtml(hintText)}` : ''}
`; @@ -293,6 +367,22 @@ export class RuntimeConfigPanel extends Panel { return; } + // Ollama model dropdown: fetch models and handle selection + const modelSelect = this.content.querySelector('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('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(`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('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 { + const snapshot = getRuntimeConfigSnapshot(); + const ollamaUrl = this.pendingSecrets.get('OLLAMA_API_URL') + || snapshot.secrets['OLLAMA_API_URL']?.value + || ''; + if (!ollamaUrl) { + select.innerHTML = ''; + 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 => + `` + ).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); + } + } } diff --git a/src/main.ts b/src/main.ts index 8d01bcec8..a6bc2bb78 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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() diff --git a/src/services/runtime-config.ts b/src/services/runtime-config.ts index efc8f9ef5..06211d443 100644 --- a/src/services/runtime-config.ts +++ b/src/services/runtime-config.ts @@ -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.', }, { diff --git a/src/settings-main.ts b/src/settings-main.ts index c142dc5fe..bd06108e5 100644 --- a/src/settings-main.ts +++ b/src/settings-main.ts @@ -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('close_settings_window').then(() => { }, () => window.close()); } +const LLM_FEATURES: Array = ['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 { - 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 { })(); }); - // 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('plugin:window|show', { label: 'settings' }); - void tryInvokeTauri('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(); diff --git a/src/styles/main.css b/src/styles/main.css index b1192fae6..61ae0d6c4 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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; } diff --git a/src/styles/settings-window.css b/src/styles/settings-window.css index b032d7402..e21575d0c 100644 --- a/src/styles/settings-window.css +++ b/src/styles/settings-window.css @@ -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;