diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2f9742214a2..6c7c2d47c84 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts"; export const en: TranslationMap = { common: { + version: "Version", health: "Health", ok: "OK", offline: "Offline", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index d9b1ebae4d3..5827a96c92d 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -11,6 +11,7 @@ export const pt_BR: TranslationMap = { disabled: "Desativado", na: "n/a", docs: "Docs", + theme: "Tema", resources: "Recursos", search: "Pesquisar", }, diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 9560c2819e7..e4ded13cb12 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -11,6 +11,7 @@ export const zh_CN: TranslationMap = { disabled: "已禁用", na: "不适用", docs: "文档", + theme: "主题", resources: "资源", search: "搜索", }, diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index fcff39bdca3..6831167c666 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -11,6 +11,7 @@ export const zh_TW: TranslationMap = { disabled: "已禁用", na: "不適用", docs: "文檔", + theme: "主題", resources: "資源", search: "搜尋", }, diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index a6f2d3d9790..b3fc09f079d 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -128,11 +128,13 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://gateway.example:8443/openclaw", sessionKey: "agent", lastActiveSessionKey: "agent", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); expect(sessionStorage.length).toBe(0); @@ -151,11 +153,13 @@ describe("loadSettings default gateway URL derivation", () => { token: "session-token", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); @@ -178,11 +182,13 @@ describe("loadSettings default gateway URL derivation", () => { token: "gateway-a-token", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); @@ -192,11 +198,13 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://other-gateway.example:8443/openclaw", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }), ); @@ -220,11 +228,13 @@ describe("loadSettings default gateway URL derivation", () => { token: "memory-only-token", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); expect(loadSettings()).toMatchObject({ @@ -236,11 +246,13 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://gateway.example:8443/openclaw", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); expect(sessionStorage.length).toBe(1); @@ -259,11 +271,13 @@ describe("loadSettings default gateway URL derivation", () => { token: "stale-token", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); saveSettings({ @@ -271,15 +285,47 @@ describe("loadSettings default gateway URL derivation", () => { token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); expect(loadSettings().token).toBe(""); expect(sessionStorage.length).toBe(0); }); + + it("persists themeMode and navWidth alongside the selected theme", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "dash", + themeMode: "light", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 320, + navGroupsCollapsed: {}, + }); + + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ + theme: "dash", + themeMode: "light", + navWidth: 320, + }); + }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 5dc1e0b59a2..29c4fb8b5c7 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -19,7 +19,7 @@ export type UiSettings = { chatShowThinking: boolean; splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) navCollapsed: boolean; // Collapsible sidebar state - navWidth: number; // Sidebar width when expanded (240–400px) + navWidth: number; // Sidebar width when expanded (200–400px) navGroupsCollapsed: Record; // Which nav groups are collapsed locale?: string; }; @@ -194,10 +194,12 @@ function persistSettings(next: UiSettings) { sessionKey: next.sessionKey, lastActiveSessionKey: next.lastActiveSessionKey, theme: next.theme, + themeMode: next.themeMode, chatFocusMode: next.chatFocusMode, chatShowThinking: next.chatShowThinking, splitRatio: next.splitRatio, navCollapsed: next.navCollapsed, + navWidth: next.navWidth, navGroupsCollapsed: next.navGroupsCollapsed, ...(next.locale ? { locale: next.locale } : {}), }; diff --git a/ui/src/ui/theme-transition.ts b/ui/src/ui/theme-transition.ts index d770dda463f..7bafe8239cd 100644 --- a/ui/src/ui/theme-transition.ts +++ b/ui/src/ui/theme-transition.ts @@ -9,6 +9,8 @@ export type ThemeTransitionContext = { export type ThemeTransitionOptions = { nextTheme: ResolvedTheme; applyTheme: () => void; + // Retained so callers from stacked slices can keep passing pointer metadata + // while theme switching remains an immediate, non-animated update here. context?: ThemeTransitionContext; currentTheme?: ResolvedTheme | null; }; diff --git a/ui/src/ui/theme.test.ts b/ui/src/ui/theme.test.ts new file mode 100644 index 00000000000..a151d8cca96 --- /dev/null +++ b/ui/src/ui/theme.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseThemeSelection, resolveTheme } from "./theme.ts"; + +describe("resolveTheme", () => { + it("keeps the legacy mode-only signature working for existing callers", () => { + expect(resolveTheme("dark")).toBe("dark"); + expect(resolveTheme("light")).toBe("light"); + }); + + it("resolves named theme families when mode is provided", () => { + expect(resolveTheme("knot", "dark")).toBe("openknot"); + expect(resolveTheme("dash", "light")).toBe("dash-light"); + }); + + it("uses system preference when a named theme omits mode", () => { + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true })); + expect(resolveTheme("knot")).toBe("openknot-light"); + vi.unstubAllGlobals(); + }); +}); + +describe("parseThemeSelection", () => { + it("maps legacy stored values onto theme + mode", () => { + expect(parseThemeSelection("system", undefined)).toEqual({ + theme: "claw", + mode: "system", + }); + expect(parseThemeSelection("fieldmanual", undefined)).toEqual({ + theme: "dash", + mode: "dark", + }); + }); +}); diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index deb8d6c1f3e..e4a7b730147 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -62,12 +62,31 @@ function resolveMode(mode: ThemeMode): "light" | "dark" { return mode; } -export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme { - const resolvedMode = resolveMode(mode); - if (theme === "claw") { +function normalizeThemeArgs( + themeOrMode: ThemeName | ThemeMode, + mode: ThemeMode | undefined, +): { theme: ThemeName; mode: ThemeMode } { + if (VALID_THEME_NAMES.has(themeOrMode as ThemeName)) { + return { + theme: themeOrMode as ThemeName, + mode: mode ?? "system", + }; + } + return { + theme: "claw", + mode: themeOrMode as ThemeMode, + }; +} + +export function resolveTheme(mode: ThemeMode): ResolvedTheme; +export function resolveTheme(theme: ThemeName, mode?: ThemeMode): ResolvedTheme; +export function resolveTheme(themeOrMode: ThemeName | ThemeMode, mode?: ThemeMode): ResolvedTheme { + const normalized = normalizeThemeArgs(themeOrMode, mode); + const resolvedMode = resolveMode(normalized.mode); + if (normalized.theme === "claw") { return resolvedMode === "light" ? "light" : "dark"; } - if (theme === "knot") { + if (normalized.theme === "knot") { return resolvedMode === "light" ? "openknot-light" : "openknot"; } return resolvedMode === "light" ? "dash-light" : "dash";