From 30d5d55f0f9672a08b2d53d6717bc980f4bfbb98 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Sat, 6 Jun 2026 12:06:31 -0700 Subject: [PATCH] feat: add dark mode support to web UI (#1436) * feat: add dark mode support to web UI Add a persisted, system-aware theme with a Light/Dark/System toggle in the sidebar user menu. An inline head script applies the stored theme before paint to avoid a flash, and the agents-ui surface gets dark overrides for its --ui-* variables. Co-authored-by: open-swe[bot] * fix: use static theme init script to satisfy CodeQL Build the no-FOUC theme script from a fully static string literal instead of interpolating THEME_STORAGE_KEY, so no value flows into code construction (CodeQL "Improper code sanitization"). Co-authored-by: open-swe[bot] * fix: avoid theme flip on hydration for persisted preferences Only apply a theme after the stored preference is read, instead of eagerly applying the default "system" theme on first effect. This prevented a brief flip to the OS theme (then back) on load for users with a saved preference that differs from their OS setting. Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] --- ui/src/components/SidebarUserMenu.tsx | 46 +++++++++++++++- ui/src/lib/theme.ts | 75 +++++++++++++++++++++++++++ ui/src/routes/__root.tsx | 3 ++ ui/src/styles/agents.css | 23 ++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/theme.ts diff --git a/ui/src/components/SidebarUserMenu.tsx b/ui/src/components/SidebarUserMenu.tsx index c04f7645..433b5f39 100644 --- a/ui/src/components/SidebarUserMenu.tsx +++ b/ui/src/components/SidebarUserMenu.tsx @@ -1,10 +1,24 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; -import { IoLogOutOutline, IoSettingsOutline } from "react-icons/io5"; +import { + IoDesktopOutline, + IoLogOutOutline, + IoMoonOutline, + IoSettingsOutline, + IoSunnyOutline, +} from "react-icons/io5"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { api, type SessionUser } from "@/lib/api"; +import { useTheme, type Theme } from "@/lib/theme"; +import { cn } from "@/lib/utils"; + +const THEME_OPTIONS: Array<{ value: Theme; label: string; icon: typeof IoSunnyOutline }> = [ + { value: "light", label: "Light", icon: IoSunnyOutline }, + { value: "dark", label: "Dark", icon: IoMoonOutline }, + { value: "system", label: "System", icon: IoDesktopOutline }, +]; interface SidebarUserMenuProps { user: SessionUser; @@ -14,6 +28,7 @@ interface SidebarUserMenuProps { export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserMenuProps) { const qc = useQueryClient(); const navigate = useNavigate(); + const { theme, setTheme } = useTheme(); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -67,6 +82,35 @@ export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserM role="menu" className="absolute right-0 bottom-full left-0 mb-2 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md" > +
+ + Theme + +
+ {THEME_OPTIONS.map((option) => { + const Icon = option.icon; + const active = theme === option.value; + return ( + + ); + })} +
+
+
{showSettingsLink && ( ("system") + const [resolvedTheme, setResolvedTheme] = useState("light") + const themeRef = useRef("system") + + useEffect(() => { + const stored = readStoredTheme() + themeRef.current = stored + setThemeState(stored) + setResolvedTheme(resolveTheme(stored)) + applyTheme(resolveTheme(stored)) + + const media = window.matchMedia("(prefers-color-scheme: dark)") + const onChange = () => { + if (themeRef.current !== "system") return + const next = systemPrefersDark() ? "dark" : "light" + setResolvedTheme(next) + applyTheme(next) + } + media.addEventListener("change", onChange) + return () => media.removeEventListener("change", onChange) + }, []) + + const setTheme = useCallback((next: Theme) => { + if (typeof window !== "undefined") { + window.localStorage.setItem(THEME_STORAGE_KEY, next) + } + themeRef.current = next + const resolved = resolveTheme(next) + setThemeState(next) + setResolvedTheme(resolved) + applyTheme(resolved) + }, []) + + const toggleTheme = useCallback(() => { + setTheme(resolvedTheme === "dark" ? "light" : "dark") + }, [resolvedTheme, setTheme]) + + return { theme, resolvedTheme, setTheme, toggleTheme } +} diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index 92205f66..de488a8f 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -13,6 +13,8 @@ import { useState } from "react" import appCss from "../styles.css?url" import { makeQueryClient } from "@/lib/query" +const themeInitScript = `(function(){try{var t=localStorage.getItem("open-swe-theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);var r=document.documentElement;r.classList.toggle("dark",d);r.style.colorScheme=d?"dark":"light";}catch(e){}})();` + export const Route = createRootRoute({ head: () => ({ meta: [ @@ -47,6 +49,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { return ( +