diff --git a/src/agent/job_monitor.rs b/src/agent/job_monitor.rs index 3f038764c6..675d042674 100644 --- a/src/agent/job_monitor.rs +++ b/src/agent/job_monitor.rs @@ -421,6 +421,7 @@ mod tests { job_id: job_id.to_string(), status: "completed".to_string(), session_id: None, + fallback_deliverable: None, }, )) .unwrap(); @@ -468,6 +469,7 @@ mod tests { job_id: job_id.to_string(), status: "failed".to_string(), session_id: None, + fallback_deliverable: None, }, )) .unwrap(); @@ -506,6 +508,7 @@ mod tests { job_id: job_id.to_string(), status: "completed".to_string(), session_id: None, + fallback_deliverable: None, }, )) .unwrap(); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 501852d462..169bb0bff8 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -344,6 +344,7 @@ pub async fn start_server( .route("/", get(index_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)) + .route("/theme-init.js", get(theme_init_handler)) .route("/favicon.ico", get(favicon_handler)) .route("/i18n/index.js", get(i18n_index_handler)) .route("/i18n/en.js", get(i18n_en_handler)) @@ -465,6 +466,16 @@ async fn js_handler() -> impl IntoResponse { ) } +async fn theme_init_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/theme-init.js"), + ) +} + async fn favicon_handler() -> impl IntoResponse { ( [ diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 4cb5644c61..e8e84132ed 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -1,5 +1,69 @@ // IronClaw Web Gateway - Client +// --- Theme Management (dark / light / system) --- +// Icon switching is handled by pure CSS via data-theme-mode on . + +function getSystemTheme() { + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +const VALID_THEME_MODES = { dark: true, light: true, system: true }; + +function getThemeMode() { + const stored = localStorage.getItem('ironclaw-theme'); + return (stored && VALID_THEME_MODES[stored]) ? stored : 'system'; +} + +function resolveTheme(mode) { + return mode === 'system' ? getSystemTheme() : mode; +} + +function applyTheme(mode) { + const resolved = resolveTheme(mode); + document.documentElement.setAttribute('data-theme', resolved); + document.documentElement.setAttribute('data-theme-mode', mode); + const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' }; + const btn = document.getElementById('theme-toggle'); + if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode); + const announce = document.getElementById('theme-announce'); + if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode); +} + +function toggleTheme() { + const cycle = { dark: 'light', light: 'system', system: 'dark' }; + const current = getThemeMode(); + const next = cycle[current] || 'dark'; + localStorage.setItem('ironclaw-theme', next); + applyTheme(next); +} + +// Apply theme immediately (FOUC prevention is done via inline script in , +// but we call again here to ensure tooltip is set after DOM is ready). +applyTheme(getThemeMode()); + +// Delay enabling theme transition to avoid flash on initial load. +requestAnimationFrame(function() { + requestAnimationFrame(function() { + document.body.classList.add('theme-transition'); + }); +}); + +// Listen for OS theme changes — only re-apply when in 'system' mode. +const mql = window.matchMedia('(prefers-color-scheme: light)'); +const onSchemeChange = function() { + if (getThemeMode() === 'system') { + applyTheme('system'); + } +}; +if (mql.addEventListener) { + mql.addEventListener('change', onSchemeChange); +} else if (mql.addListener) { + mql.addListener(onSchemeChange); +} + +// Bind theme toggle button (CSP-compliant — no inline onclick). +document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + let token = ''; let eventSource = null; let logEventSource = null; diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index cd57a400a5..de08c7dbf2 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -24,6 +24,12 @@ I18n.register('en', { 'restart.progressSubtitle': 'Please wait for the process to restart...', 'restart.checkLogs': 'Check the Logs tab for details after restart completes.', + // Theme + 'theme.tooltipDark': 'Theme: Dark (click for Light)', + 'theme.tooltipLight': 'Theme: Light (click for System)', + 'theme.tooltipSystem': 'Theme: System (click for Dark)', + 'theme.announce': 'Theme: {mode}', + // Tabs 'tab.chat': 'Chat', 'tab.memory': 'Memory', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 028ff5fc2c..8bc6edd444 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -24,6 +24,12 @@ I18n.register('zh-CN', { 'restart.progressSubtitle': '请等待进程重启...', 'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。', + // 主题 + 'theme.tooltipDark': '主题:深色(点击切换浅色)', + 'theme.tooltipLight': '主题:浅色(点击切换跟随系统)', + 'theme.tooltipSystem': '主题:跟随系统(点击切换深色)', + 'theme.announce': '主题:{mode}', + // 标签页 'tab.chat': '聊天', 'tab.memory': '记忆', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 45e14fa41d..113d144e0f 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -25,6 +25,7 @@ integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG" crossorigin="anonymous" > + @@ -109,6 +110,18 @@

Restart IronClaw Instance

+ +