From 63a8f39760958c93cab9ad411e0280eaaec94e87 Mon Sep 17 00:00:00 2001
From: Dragonzz <82201310+Dragonzz27@users.noreply.github.com>
Date: Sat, 19 Sep 2026 14:06:24 +0800
Subject: [PATCH] fix(ui): isolate theme hook from refreshable provider
Signed-off-by: Dragonzz <82201310+Dragonzz27@users.noreply.github.com>
---
ui/src/App.tsx | 3 +-
ui/src/appExtensions/index.ts | 2 +-
ui/src/components/Structure/AppSidebar.tsx | 2 +-
ui/src/components/Structure/SidebarFooter.tsx | 2 +-
ui/src/components/agent/AgentRail.tsx | 2 +-
ui/src/components/branding/KagentLogo.tsx | 2 +-
ui/src/components/chat/CheckpointDivider.tsx | 2 +-
ui/src/components/chat/MermaidDiagram.tsx | 2 +-
ui/src/pages/SubstratePage.tsx | 2 +-
ui/src/theme/themeMode.test.tsx | 58 +++++++++++++++++++
ui/src/theme/themeMode.tsx | 40 +------------
ui/src/theme/useThemeMode.ts | 39 +++++++++++++
12 files changed, 108 insertions(+), 48 deletions(-)
create mode 100644 ui/src/theme/themeMode.test.tsx
create mode 100644 ui/src/theme/useThemeMode.ts
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index dc876c1424..c81c64ca6e 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -5,7 +5,8 @@ import { RouterProvider } from "react-router-dom";
import { Toaster } from "react-hot-toast";
import { SWRConfig } from "swr";
import { GlobalStyles } from "./theme/GlobalStyles";
-import { ThemeModeProvider, useThemeMode } from "./theme/themeMode";
+import { ThemeModeProvider } from "./theme/themeMode";
+import { useThemeMode } from "./theme/useThemeMode";
import {
resolveAntdTheme,
resolveAppTheme,
diff --git a/ui/src/appExtensions/index.ts b/ui/src/appExtensions/index.ts
index e2ac9da2d5..4c6552b0db 100644
--- a/ui/src/appExtensions/index.ts
+++ b/ui/src/appExtensions/index.ts
@@ -105,7 +105,7 @@ export { readEnv } from "@/env";
// The palette currently showing. Re-exported because a contribution that wants to
// look like the application's own chrome has to pick the same one — antd's Menu
// and Table both take a light/dark choice that no design token can stand in for.
-export { useThemeMode } from "@/theme/themeMode";
+export { useThemeMode } from "@/theme/useThemeMode";
export type { ThemeMode } from "@/theme/theme";
export {
diff --git a/ui/src/components/Structure/AppSidebar.tsx b/ui/src/components/Structure/AppSidebar.tsx
index 882d924951..21c44e2628 100644
--- a/ui/src/components/Structure/AppSidebar.tsx
+++ b/ui/src/components/Structure/AppSidebar.tsx
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Layout, Menu } from "antd";
import { useTheme } from "@emotion/react";
import { Link, useLocation, useNavigate } from "react-router-dom";
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
import { SidebarFooter } from "./SidebarFooter";
import { coreNavItems } from "./navItems";
import type { NavItem } from "./navItems";
diff --git a/ui/src/components/Structure/SidebarFooter.tsx b/ui/src/components/Structure/SidebarFooter.tsx
index 967041bba1..1f94a1f1c6 100644
--- a/ui/src/components/Structure/SidebarFooter.tsx
+++ b/ui/src/components/Structure/SidebarFooter.tsx
@@ -8,7 +8,7 @@ import {
PanelLeftOpen,
Sun,
} from "lucide-react";
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
/** Where the docs link points. The project's own documentation, not a deployment's. */
const DOCS_URL = "https://kagent.dev/docs/kagent";
diff --git a/ui/src/components/agent/AgentRail.tsx b/ui/src/components/agent/AgentRail.tsx
index 6e1053fcd4..a1886cd561 100644
--- a/ui/src/components/agent/AgentRail.tsx
+++ b/ui/src/components/agent/AgentRail.tsx
@@ -44,7 +44,7 @@ import {
relativeAge,
shortInstanceId,
} from "@/components/agent-instances/instanceLabels";
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
import { useCollapsedBelow } from "@/components/chat/useNarrowViewport";
import {
useExtensionAgentLinks,
diff --git a/ui/src/components/branding/KagentLogo.tsx b/ui/src/components/branding/KagentLogo.tsx
index a115173b8c..96ebfb8bb9 100644
--- a/ui/src/components/branding/KagentLogo.tsx
+++ b/ui/src/components/branding/KagentLogo.tsx
@@ -1,4 +1,4 @@
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
/**
* The kagent marks.
diff --git a/ui/src/components/chat/CheckpointDivider.tsx b/ui/src/components/chat/CheckpointDivider.tsx
index 8438d854c5..da3f1f01de 100644
--- a/ui/src/components/chat/CheckpointDivider.tsx
+++ b/ui/src/components/chat/CheckpointDivider.tsx
@@ -2,7 +2,7 @@ import type { KeyboardEvent } from "react";
import { Button, Popconfirm, Tooltip, Typography } from "antd";
import { Eraser, GitFork, Pencil, Save } from "lucide-react";
import { useTheme } from "@emotion/react";
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
import type { Checkpoint } from "@/api";
import { ExtensionSlot } from "@/appExtensions/ExtensionSlot";
import { snapshotLabel } from "./snapshotLabel";
diff --git a/ui/src/components/chat/MermaidDiagram.tsx b/ui/src/components/chat/MermaidDiagram.tsx
index 4abffb697f..6172d1114e 100644
--- a/ui/src/components/chat/MermaidDiagram.tsx
+++ b/ui/src/components/chat/MermaidDiagram.tsx
@@ -1,7 +1,7 @@
import { useEffect, useId, useState } from "react";
import { css, useTheme } from "@emotion/react";
import type { Theme } from "@emotion/react";
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
/**
* A fenced ` ```mermaid ` block, rendered as a diagram.
diff --git a/ui/src/pages/SubstratePage.tsx b/ui/src/pages/SubstratePage.tsx
index da6f8d33d9..e449c10e8d 100644
--- a/ui/src/pages/SubstratePage.tsx
+++ b/ui/src/pages/SubstratePage.tsx
@@ -22,7 +22,7 @@ import {
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { useTheme, type CSSObject, type Theme } from "@emotion/react";
-import { useThemeMode } from "@/theme/themeMode";
+import { useThemeMode } from "@/theme/useThemeMode";
import { Radio, Search } from "lucide-react";
import { PageFrame } from "@/components/Structure/PageFrame";
import { StatTile } from "@/components/dashboard/StatTile";
diff --git a/ui/src/theme/themeMode.test.tsx b/ui/src/theme/themeMode.test.tsx
new file mode 100644
index 0000000000..ce8bda4fc4
--- /dev/null
+++ b/ui/src/theme/themeMode.test.tsx
@@ -0,0 +1,58 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { THEME_MODE_STORAGE_KEY } from "./storedMode";
+import { ThemeModeProvider } from "./themeMode";
+import { useThemeMode } from "./useThemeMode";
+
+describe("theme mode context", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.spyOn(window, "matchMedia").mockReturnValue({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as unknown as MediaQueryList);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ window.localStorage.clear();
+ delete document.documentElement.dataset.theme;
+ document.documentElement.style.colorScheme = "";
+ });
+
+ it("keeps the dark, non-toggleable fallback outside a provider", () => {
+ const { result } = renderHook(useThemeMode);
+ expect(result.current).toMatchObject({ mode: "dark", isExplicit: false, canToggle: false });
+ act(() => result.current.toggle());
+ expect(result.current.mode).toBe("dark");
+ });
+
+ it("reads the provider's system mode and persists an explicit toggle", () => {
+ const { result } = renderHook(useThemeMode, { wrapper: ThemeModeProvider });
+ expect(result.current).toMatchObject({ mode: "light", isExplicit: false, canToggle: true });
+ expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBeNull();
+
+ act(() => result.current.toggle());
+ expect(result.current).toMatchObject({ mode: "dark", isExplicit: true });
+ expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe("dark");
+ expect(document.documentElement.dataset.theme).toBe("dark");
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+ });
+
+ it("shares a stored preference between the provider and hook modules", () => {
+ window.localStorage.setItem(THEME_MODE_STORAGE_KEY, "dark");
+ const { result } = renderHook(useThemeMode, { wrapper: ThemeModeProvider });
+ expect(result.current).toMatchObject({ mode: "dark", isExplicit: true, canToggle: true });
+ });
+
+ it("clamps the stored preference to an extension's supported palette", () => {
+ window.localStorage.setItem(THEME_MODE_STORAGE_KEY, "light");
+ const { result } = renderHook(useThemeMode, {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+ expect(result.current).toMatchObject({ mode: "dark", isExplicit: true, canToggle: false });
+ });
+});
diff --git a/ui/src/theme/themeMode.tsx b/ui/src/theme/themeMode.tsx
index 653dc94b9f..6670afc05d 100644
--- a/ui/src/theme/themeMode.tsx
+++ b/ui/src/theme/themeMode.tsx
@@ -1,35 +1,16 @@
import {
- createContext,
useCallback,
- useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { ThemeMode } from "./theme";
+import { ThemeModeContext, type ThemeModeContextValue } from "./useThemeMode";
import { THEME_MODE_STORAGE_KEY, storedMode } from "./storedMode";
const ALL_MODES: readonly ThemeMode[] = ["dark", "light"];
-interface ThemeModeContextValue {
- mode: ThemeMode;
- /** Whether the mode is the reader's own choice rather than the system's. */
- isExplicit: boolean;
- /**
- * Whether there is more than one palette to switch between.
- *
- * False when the installed extension supports only one. The control that toggles
- * should not be drawn at all in that case — a toggle that cannot change anything
- * is worse than its absence, because pressing it looks like a bug.
- */
- canToggle: boolean;
- setMode: (mode: ThemeMode) => void;
- toggle: () => void;
-}
-
-const ThemeModeContext = createContext(undefined);
-
function systemMode(): ThemeMode {
return typeof window !== "undefined" &&
window.matchMedia?.("(prefers-color-scheme: light)").matches
@@ -102,22 +83,3 @@ export function ThemeModeProvider({
{children}
);
}
-
-/**
- * The current mode and the toggle.
- *
- * Falls back to dark outside a provider rather than throwing: this is read by
- * chrome that a test may mount on its own, and a missing provider should cost a
- * default palette rather than a blank page.
- */
-export function useThemeMode(): ThemeModeContextValue {
- return (
- useContext(ThemeModeContext) ?? {
- mode: "dark",
- isExplicit: false,
- canToggle: false,
- setMode: () => {},
- toggle: () => {},
- }
- );
-}
diff --git a/ui/src/theme/useThemeMode.ts b/ui/src/theme/useThemeMode.ts
new file mode 100644
index 0000000000..ad30a71cf0
--- /dev/null
+++ b/ui/src/theme/useThemeMode.ts
@@ -0,0 +1,39 @@
+import { createContext, useContext } from "react";
+import type { ThemeMode } from "./theme";
+
+export interface ThemeModeContextValue {
+ mode: ThemeMode;
+ /** Whether the mode is the reader's own choice rather than the system's. */
+ isExplicit: boolean;
+ /**
+ * Whether there is more than one palette to switch between.
+ *
+ * False when the installed extension supports only one. The control that toggles
+ * should not be drawn at all in that case — a toggle that cannot change anything
+ * is worse than its absence, because pressing it looks like a bug.
+ */
+ canToggle: boolean;
+ setMode: (mode: ThemeMode) => void;
+ toggle: () => void;
+}
+
+export const ThemeModeContext = createContext(undefined);
+
+/**
+ * The current mode and the toggle.
+ *
+ * Falls back to dark outside a provider rather than throwing: this is read by
+ * chrome that a test may mount on its own, and a missing provider should cost a
+ * default palette rather than a blank page.
+ */
+export function useThemeMode(): ThemeModeContextValue {
+ return (
+ useContext(ThemeModeContext) ?? {
+ mode: "dark",
+ isExplicit: false,
+ canToggle: false,
+ setMode: () => {},
+ toggle: () => {},
+ }
+ );
+}