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
+
+