Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ui/src/appExtensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/Structure/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/Structure/SidebarFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/agent/AgentRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/branding/KagentLogo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useThemeMode } from "@/theme/themeMode";
import { useThemeMode } from "@/theme/useThemeMode";

/**
* The kagent marks.
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/chat/CheckpointDivider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/chat/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/SubstratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
58 changes: 58 additions & 0 deletions ui/src/theme/themeMode.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ThemeModeProvider supportedModes={["dark"]}>{children}</ThemeModeProvider>
),
});
expect(result.current).toMatchObject({ mode: "dark", isExplicit: true, canToggle: false });
});
});
40 changes: 1 addition & 39 deletions ui/src/theme/themeMode.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeModeContextValue | undefined>(undefined);

function systemMode(): ThemeMode {
return typeof window !== "undefined" &&
window.matchMedia?.("(prefers-color-scheme: light)").matches
Expand Down Expand Up @@ -102,22 +83,3 @@ export function ThemeModeProvider({
<ThemeModeContext.Provider value={value}>{children}</ThemeModeContext.Provider>
);
}

/**
* 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: () => {},
}
);
}
39 changes: 39 additions & 0 deletions ui/src/theme/useThemeMode.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeModeContextValue | undefined>(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: () => {},
}
);
}
Loading