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: () => {}, + } + ); +}