OneAgent — every agent, one clear lane
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ee708d3..4114f88 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -12,6 +12,7 @@ import { ProvidersPage } from "./pages/ProvidersPage";
import { ReviewPage } from "./pages/ReviewPage";
import { I18nProvider, useI18n } from "./i18n";
import { TaskCenterProvider } from "./state/TaskCenterContext";
+import { ThemeProvider } from "./state/ThemeContext";
import { WizardProvider, useWizard } from "./state/WizardContext";
function SetupGuard({ stage, children }: { stage: "provider" | "model" | "review" | "activation"; children: React.ReactNode }) {
@@ -78,12 +79,14 @@ function WorkspaceRoutes() {
export default function App() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/components/NavigationSidebar.tsx b/frontend/src/components/NavigationSidebar.tsx
index 40ceb9d..98b1c48 100644
--- a/frontend/src/components/NavigationSidebar.tsx
+++ b/frontend/src/components/NavigationSidebar.tsx
@@ -3,6 +3,7 @@ import { NavLink } from "react-router-dom";
import { type TranslationKey, useI18n } from "../i18n";
import { TaskCenter } from "./TaskCenter";
+import { ThemePicker } from "./ThemePicker";
// Only real destinations belong here. /setup/* are wizard steps behind
// SetupGuard: listing them made the sidebar look broken, because clicking one
@@ -37,6 +38,10 @@ export function NavigationSidebar() {
))}
+ {/* First of the bottom group, so its margin-top: auto pushes appearance,
+ language and the task centre down together. */}
+
+
- {/* Last child, so the language picker's margin-top: auto pushes both to
- the bottom of the sidebar as one group. */}
);
diff --git a/frontend/src/components/ThemePicker.tsx b/frontend/src/components/ThemePicker.tsx
new file mode 100644
index 0000000..733a5b0
--- /dev/null
+++ b/frontend/src/components/ThemePicker.tsx
@@ -0,0 +1,40 @@
+import { Moon, Sun, SunMoon } from "lucide-react";
+
+import { useI18n } from "../i18n";
+import { type ThemePreference, useTheme } from "../state/ThemeContext";
+
+const icons: Record = {
+ system: SunMoon,
+ light: Sun,
+ dark: Moon,
+};
+
+/**
+ * The appearance setting, shaped like the language picker beside it.
+ *
+ * A select rather than a two-state switch: "system" is one of three real
+ * choices, and a toggle cannot express it. The icon reflects what is in force,
+ * so "system" shows the sun or the moon once resolved rather than staying
+ * ambiguous.
+ */
+export function ThemePicker() {
+ const { t } = useI18n();
+ const { preference, setPreference, resolved } = useTheme();
+ const Icon = preference === "system" ? icons[resolved] : icons[preference];
+
+ return (
+
+ );
+}
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx
index 7b418c2..4c65c2b 100644
--- a/frontend/src/i18n.tsx
+++ b/frontend/src/i18n.tsx
@@ -7,6 +7,10 @@ const english = {
"激活环境": "Environment",
"配置模板": "Profiles",
"语言": "Language",
+ "外观": "Appearance",
+ "跟随系统": "System",
+ "浅色": "Light",
+ "深色": "Dark",
"返回": "Back",
"返回总览": "Back to overview",
"继续": "Continue",
diff --git a/frontend/src/state/ThemeContext.test.tsx b/frontend/src/state/ThemeContext.test.tsx
new file mode 100644
index 0000000..5be4e11
--- /dev/null
+++ b/frontend/src/state/ThemeContext.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { I18nProvider, LOCALE_STORAGE_KEY } from "../i18n";
+import { ThemePicker } from "../components/ThemePicker";
+import { THEME_STORAGE_KEY, ThemeProvider, storedPreference } from "./ThemeContext";
+
+/** matchMedia is absent in jsdom; each test declares what the desktop reports. */
+function stubSystem(dark: boolean) {
+ const listeners = new Set<(event: MediaQueryListEvent) => void>();
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({
+ matches: dark,
+ addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
+ removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener),
+ })),
+ );
+ return (next: boolean) => listeners.forEach((listener) => listener({ matches: next } as MediaQueryListEvent));
+}
+
+function mount() {
+ render(
+
+
+
+
+ ,
+ );
+ return screen.getByRole("combobox", { name: "外观" });
+}
+
+const classes = () => document.documentElement.className;
+
+beforeEach(() => {
+ localStorage.clear();
+ // jsdom reports navigator.language as en-US, so pin the locale rather than
+ // assert against whichever one the host implies.
+ localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN");
+ document.documentElement.className = "";
+ vi.unstubAllGlobals();
+});
+
+describe("ThemeProvider", () => {
+ it("carries no class while following the system", () => {
+ // No class is what lets the media query in tokens.css decide. A class for
+ // "system" would pin the palette and defeat the whole point.
+ stubSystem(true);
+ mount();
+ expect(classes()).toBe("");
+ });
+
+ it("forces a palette independently of the desktop", async () => {
+ stubSystem(true);
+ const select = mount();
+ await userEvent.selectOptions(select, "light");
+ // theme-light on a dark desktop is the case that needs the :not() in the
+ // media query, otherwise the dark block still wins.
+ expect(document.documentElement.classList.contains("theme-light")).toBe(true);
+ expect(document.documentElement.classList.contains("theme-dark")).toBe(false);
+ });
+
+ it("persists the choice", async () => {
+ stubSystem(false);
+ await userEvent.selectOptions(mount(), "dark");
+ expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
+ expect(storedPreference()).toBe("dark");
+ });
+
+ it("follows a live system change only while set to system", () => {
+ const flip = stubSystem(false);
+ mount();
+ flip(true);
+ // Still no class: "system" delegates to CSS rather than mirroring the state.
+ expect(classes()).toBe("");
+ });
+
+ it("keeps an explicit choice when the desktop flips", async () => {
+ const flip = stubSystem(false);
+ await userEvent.selectOptions(mount(), "light");
+ flip(true);
+ expect(document.documentElement.classList.contains("theme-light")).toBe(true);
+ });
+
+ it("returns to the system palette when asked", async () => {
+ stubSystem(true);
+ const select = mount();
+ await userEvent.selectOptions(select, "dark");
+ await userEvent.selectOptions(select, "system");
+ expect(classes()).toBe("");
+ expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe("system");
+ });
+
+ it("overrides the media-driven window colour", async () => {
+ // index.html's two theme-color tags are media-driven and cannot see a forced
+ // palette, so the window chrome would keep the desktop's colour.
+ stubSystem(false);
+ await userEvent.selectOptions(mount(), "dark");
+ expect(document.head.querySelector("meta#theme-color-resolved")?.content).toBe("#151517");
+ });
+
+ it("treats a barred storage as no stored choice", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("denied");
+ });
+ // Hardened webviews reject storage; the app must still render.
+ expect(storedPreference()).toBe("system");
+ vi.restoreAllMocks();
+ });
+
+ it("ignores a stored value that is not a preference", () => {
+ localStorage.setItem(THEME_STORAGE_KEY, "chartreuse");
+ expect(storedPreference()).toBe("system");
+ });
+});
+
+describe("tokens.css", () => {
+ it("declares the same dark variables for the media query and the class", () => {
+ // CSS cannot share a declaration list between a media query and a class, so
+ // the palette is written twice. This is the guard against the two drifting.
+ const css = readFileSync(join(process.cwd(), "src/styles/tokens.css"), "utf8");
+ const blocks = [...css.matchAll(/:root(?::not\(\.theme-light\)|\.theme-dark)\s*\{([^}]*)\}/g)];
+ expect(blocks).toHaveLength(2);
+ const variables = blocks.map((block) =>
+ [...block[1].matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((match) => `${match[1]}:${match[2].trim()}`).sort(),
+ );
+ expect(variables[0]).toEqual(variables[1]);
+ });
+
+ it("keeps sizing tokens out of the dark blocks", () => {
+ // Repeating them there would drop them whenever light is forced.
+ const css = readFileSync(join(process.cwd(), "src/styles/tokens.css"), "utf8");
+ for (const block of css.matchAll(/:root(?::not\(\.theme-light\)|\.theme-dark)\s*\{([^}]*)\}/g)) {
+ expect(block[1]).not.toMatch(/--radius-|--sidebar-width|--footer-height/);
+ }
+ });
+});
diff --git a/frontend/src/state/ThemeContext.tsx b/frontend/src/state/ThemeContext.tsx
new file mode 100644
index 0000000..0acaa4a
--- /dev/null
+++ b/frontend/src/state/ThemeContext.tsx
@@ -0,0 +1,114 @@
+import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from "react";
+
+/**
+ * The appearance setting.
+ *
+ * Three states, not two: "system" is a real choice, not the absence of one. A
+ * two-state toggle would have to guess what "off" means the moment the desktop
+ * flips, and a user who wants to track their desktop has no way to say so.
+ *
+ * The resolved palette lives in CSS (styles/tokens.css). This only decides which
+ * class sits on ; no colour values belong here.
+ */
+export type ThemePreference = "system" | "light" | "dark";
+
+export const THEME_STORAGE_KEY = "oneagent.theme";
+
+function isPreference(value: unknown): value is ThemePreference {
+ return value === "system" || value === "light" || value === "dark";
+}
+
+/** The stored choice, or "system" when nothing was stored or storage is barred. */
+export function storedPreference(): ThemePreference {
+ try {
+ const saved = localStorage.getItem(THEME_STORAGE_KEY);
+ if (isPreference(saved)) return saved;
+ } catch {
+ // Storage can be unavailable in hardened webviews; the system preference
+ // still applies, it just cannot be overridden across restarts.
+ }
+ return "system";
+}
+
+/**
+ * Put the preference on .
+ *
+ * "system" carries no class at all, which is what lets the media query in
+ * tokens.css decide. Exported so the pre-hydration script and the provider apply
+ * it exactly the same way.
+ */
+export function applyPreference(preference: ThemePreference): void {
+ const root = document.documentElement;
+ root.classList.toggle("theme-dark", preference === "dark");
+ root.classList.toggle("theme-light", preference === "light");
+}
+
+interface ThemeContextValue {
+ preference: ThemePreference;
+ setPreference: (preference: ThemePreference) => void;
+ /** What the preference resolves to right now, for anything that needs the answer rather than the setting. */
+ resolved: "light" | "dark";
+}
+
+const ThemeContext = createContext(undefined);
+
+function systemPrefersDark(): boolean {
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches === true;
+}
+
+export function ThemeProvider({ children }: PropsWithChildren) {
+ const [preference, setPreferenceState] = useState(storedPreference);
+ const [systemDark, setSystemDark] = useState(systemPrefersDark);
+
+ // A desktop change still moves the app while the preference is "system". The
+ // listener runs regardless of the current preference so switching back to
+ // "system" already has the right answer.
+ useEffect(() => {
+ const query = window.matchMedia?.("(prefers-color-scheme: dark)");
+ if (!query) return;
+ const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ }, []);
+
+ const resolved: "light" | "dark" = preference === "system" ? (systemDark ? "dark" : "light") : preference;
+
+ useEffect(() => {
+ applyPreference(preference);
+ }, [preference]);
+
+ // The two media-driven tags in index.html cannot see
+ // a forced palette, so the window chrome would keep the desktop's colour while
+ // the content switched. One resolved tag overrides both.
+ useEffect(() => {
+ const id = "theme-color-resolved";
+ let tag = document.head.querySelector(`meta#${id}`);
+ if (!tag) {
+ tag = document.createElement("meta");
+ tag.id = id;
+ tag.name = "theme-color";
+ document.head.append(tag);
+ }
+ tag.content = resolved === "dark" ? "#151517" : "#f5f5f7";
+ }, [resolved]);
+
+ const setPreference = useCallback((next: ThemePreference) => {
+ setPreferenceState(next);
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, next);
+ } catch {
+ // In-memory only, as with the locale: the choice holds for this session.
+ }
+ }, []);
+
+ const value = useMemo(() => ({ preference, setPreference, resolved }), [preference, setPreference, resolved]);
+ return {children};
+}
+
+export function useTheme(): ThemeContextValue {
+ const value = useContext(ThemeContext);
+ if (!value) {
+ throw new Error("useTheme must be used inside ThemeProvider");
+ }
+ return value;
+}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 4a029eb..a543854 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -101,9 +101,16 @@
text-transform: uppercase;
}
+/* The appearance and language rows are one group. `margin-top: auto` sits on the
+ first of them so the pair is pushed to the bottom together; putting it on the
+ language row would let the theme row float up on its own. */
+.theme-picker {
+ margin-top: auto;
+}
+
+.theme-picker,
.language-picker {
min-height: 38px;
- margin-top: auto;
padding: 0 8px;
display: flex;
align-items: center;
@@ -112,6 +119,21 @@
font-size: 12px;
}
+.theme-picker span {
+ flex: 1;
+}
+
+.theme-picker select {
+ width: 82px;
+ min-width: 0;
+ padding: 5px 6px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: var(--window-bg);
+ color: var(--text-primary);
+ font: inherit;
+}
+
.language-picker span {
flex: 1;
}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index f2adf26..4c035db 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -37,8 +37,20 @@
--footer-height: 68px;
}
+/* The dark palette is declared twice below, once per selector, because CSS has
+ no way to share a declaration list between a media query and a class. The two
+ blocks must stay in sync.
+
+ `.theme-light` opts out of the media query, so forcing light keeps the light
+ palette on a dark desktop.
+
+ Only colours belong here. The sizing tokens (--radius-*, --sidebar-width,
+ --footer-height) live in :root above and must stay there: duplicating them
+ would mean every future change had to be made twice, and forcing light mode
+ would drop whichever copy was missed. */
@media (prefers-color-scheme: dark) {
- :root {
+ /* Skipped when the user forced light: the class wins over the desktop. */
+ :root:not(.theme-light) {
--page-bg: #151517;
--window-bg: #1e1e20;
--sidebar-bg: rgba(24, 24, 26, 0.94);
@@ -68,3 +80,34 @@
--shadow-window: 0 22px 55px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35);
}
}
+
+/* An explicit choice, independent of the desktop setting. */
+:root.theme-dark {
+ --page-bg: #151517;
+ --window-bg: #1e1e20;
+ --sidebar-bg: rgba(24, 24, 26, 0.94);
+ --surface-subtle: #2c2c2e;
+ --surface-pressed: #3a3a3c;
+ --text-primary: #f5f5f7;
+ --text-secondary: #98989d;
+ --text-tertiary: #6e6e73;
+ --border: rgba(255, 255, 255, 0.14);
+ --border-strong: rgba(255, 255, 255, 0.22);
+ --border-faint: rgba(255, 255, 255, 0.08);
+ --icon-border: rgba(255, 255, 255, 0.12);
+ --icon-fg: #d1d1d6;
+ --footer-bg: rgba(28, 28, 30, 0.96);
+ --overlay-panel: rgba(0, 0, 0, 0.25);
+ --blue: #0a84ff;
+ --blue-hover: #409cff;
+ --blue-soft: rgba(10, 132, 255, 0.16);
+ --green: #30d158;
+ --green-soft: #1e3726;
+ --orange: #ff9f0a;
+ --orange-soft: #3a2c12;
+ --red: #ff453a;
+ --red-soft: #3a1213;
+ --info: #7cc4ff;
+ --info-soft: #12283a;
+ --shadow-window: 0 22px 55px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35);
+}