From b28e8f1c4e80ce92fc3e729889461989b8cdf7b2 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 29 Aug 2026 11:00:11 +0200 Subject: [PATCH] agent: add a settings menu with a theme override, defaulted to dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports swarm-ui's `SettingsMenu` (mara: "agent terminal page should get the settings panel from swarm ui as well") — same shape as `MetaNav` already in this header: a `Badge` icon trigger ("⚙"), popover, close on outside-click/Escape. `theme-apply.ts` + `settings-storage.ts` are near-verbatim ports of swarm-ui's own (duplicated rather than moved into `@hive/shared` for this pass — lower risk than reworking swarm-ui's imports in the same change). Defaults the stored override to 'dark', not 'system', for the same reason as swarm-ui's own default flip: `prefers-color-scheme` has no real "unset" value, so 'system' silently reads as light for anyone who's never touched an OS dark-mode toggle. Motion NOT ported — agent has no animation gated behind `data-motion` yet, so that plumbing would have nothing to control. Verified end-to-end: default dark on a fresh load, and an explicit localStorage override to 'light' correctly re-themes the whole page via the existing `colors.css` `:root[data-theme='light']` block (already shipped, previously only reachable from swarm-ui). --- frontend/packages/agent/src/Root.tsx | 4 + .../agent/src/components/SettingsMenu.css | 44 +++++++++++ .../agent/src/components/SettingsMenu.tsx | 66 ++++++++++++++++ .../agent/src/lib/settings-storage.ts | 77 +++++++++++++++++++ .../packages/agent/src/lib/theme-apply.ts | 42 ++++++++++ 5 files changed, 233 insertions(+) create mode 100644 frontend/packages/agent/src/components/SettingsMenu.css create mode 100644 frontend/packages/agent/src/components/SettingsMenu.tsx create mode 100644 frontend/packages/agent/src/lib/settings-storage.ts create mode 100644 frontend/packages/agent/src/lib/theme-apply.ts diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index d2124074..044947ed 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import type { BadgeTone } from '@hive/shared/badge.js'; import { Header } from './components/Header.js'; import { MetaNav } from './components/MetaNav.js'; +import { SettingsMenu } from './components/SettingsMenu.js'; import { StatusChips } from './components/StatusChips.js'; import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js'; import { HeaderPill } from './components/HeaderPill.js'; @@ -22,6 +23,7 @@ import { fmtAge, fmtTokens } from './lib/format.js'; import { resolveDashboardBase } from './lib/dashboardBase.js'; import { submitPauseResume } from './lib/pauseAction.js'; import { postModel, postEffort } from './lib/modelEffort.js'; +import { useApplyThemeOverride } from './lib/theme-apply.js'; import type { TokenUsage } from './types.js'; type OpenPanel = 'inbox' | 'todos' | null; @@ -53,6 +55,7 @@ function tokenTotal(u: TokenUsage | null): number | null { } export function Root() { + useApplyThemeOverride(); const { state, refresh } = useAgentState(); const { todos, refresh: refreshTodos } = useTodos(); const [openPanel, setOpenPanel] = useState(null); @@ -100,6 +103,7 @@ export function Root() { dashboardBase={resolveDashboardBase(state.dashboard_port)} /> ) : null} + ); // Kept mounted regardless of `openPanel` (open/closed toggles just the diff --git a/frontend/packages/agent/src/components/SettingsMenu.css b/frontend/packages/agent/src/components/SettingsMenu.css new file mode 100644 index 00000000..cef0faa9 --- /dev/null +++ b/frontend/packages/agent/src/components/SettingsMenu.css @@ -0,0 +1,44 @@ +/* popover — matches `MetaNav`'s popover values exactly + (`../components/MetaNav.css`), which in turn match `@hive/shared`'s + `Dropdown` (`.ui-dropdown`) — same "reuse the values, not the + component" call, this popover holds a ` setTheme((e.target as HTMLSelectElement).value as ThemeOverride)} + > + {THEME_OPTIONS.map((o) => ( + + ))} + + + + ) : null} + + ); +} diff --git a/frontend/packages/agent/src/lib/settings-storage.ts b/frontend/packages/agent/src/lib/settings-storage.ts new file mode 100644 index 00000000..43d9389a --- /dev/null +++ b/frontend/packages/agent/src/lib/settings-storage.ts @@ -0,0 +1,77 @@ +// Generic localStorage-backed setting — ported from swarm-ui's +// `lib/settings-storage.ts` verbatim (mara: "agent terminal page should +// get the settings panel from swarm ui as well"). Not merged into +// `@hive/shared` for this pass — duplicating ~60 lines of already- +// working, dependency-free plumbing is lower-risk than reworking +// swarm-ui's own imports to point at a new shared module in the same +// change; worth revisiting if a third package ever needs this too. +// +// `localStorage`'s own `storage` event only fires in *other* tabs, never +// the tab that made the write — so two components in the same tab both +// watching the same key (e.g. `SettingsMenu` writing, a top-level effect +// reading) need their own same-tab signal. The tiny module-level pub/sub +// below is that signal; it's deliberately not exported, callers only see +// the hook. +import { useEffect, useState } from 'preact/hooks'; + +const listeners = new Map void>>(); + +function subscribe(key: string, onChange: () => void): () => void { + let set = listeners.get(key); + if (!set) { + set = new Set(); + listeners.set(key, set); + } + set.add(onChange); + return () => { + set!.delete(onChange); + if (set!.size === 0) listeners.delete(key); + }; +} + +function notify(key: string): void { + listeners.get(key)?.forEach((fn) => fn()); +} + +// Reads outside a component (e.g. an early top-level check) can call +// this directly; `useLocalSetting` builds on it rather than duplicating +// the try/catch. +export function readLocalSetting(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw === null ? fallback : (JSON.parse(raw) as T); + } catch { + // Private-browsing storage bans, a corrupted value, quota errors — + // all the same answer: behave as if nothing were stored. + return fallback; + } +} + +function writeLocalSetting(key: string, value: T): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Best-effort: the setting just won't persist this time, not worth + // surfacing an error for a client-local preference. + } + notify(key); +} + +// `[value, setValue]`, same shape as `useState` — deliberately, so a +// caller that later needs to swap a plain `useState` for a persisted +// setting (or vice versa) changes one line, not its whole call site. +// +// `setValue` only writes + notifies; it doesn't also call this +// component's own `setValue` state setter directly. The subscription +// below already fires for a same-instance write (it's in the same +// `listeners` set as every other subscriber), so a self-write reaches +// this component's state through the identical "react to a change" +// path every other subscriber uses — one path, not two that have to +// agree. +export function useLocalSetting(key: string, fallback: T): [T, (value: T) => void] { + const [value, setValue] = useState(() => readLocalSetting(key, fallback)); + + useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]); + + return [value, (next: T) => writeLocalSetting(key, next)]; +} diff --git a/frontend/packages/agent/src/lib/theme-apply.ts b/frontend/packages/agent/src/lib/theme-apply.ts new file mode 100644 index 00000000..84f3fbfd --- /dev/null +++ b/frontend/packages/agent/src/lib/theme-apply.ts @@ -0,0 +1,42 @@ +// Applies the stored theme override by setting `data-theme="light"|"dark"` +// on ``, cleared when the override is "system" (letting the +// `prefers-color-scheme` media query in `@hive/shared/colors.css` resume +// control). No palette values live here — `colors.css`'s own +// `:root[data-theme='light']` / `:root[data-theme='dark']` blocks (already +// shipped, previously only reachable from swarm-ui) are what actually +// apply the colours. +// +// Default 'dark', not 'system' — same reasoning as swarm-ui's own default +// flip: `prefers-color-scheme` has no real "unset" value the way +// `prefers-reduced-motion` does, so a browser with no explicit dark-mode +// toggle reports `light`, and 'system' would silently read as light by +// default for most first-time visitors (mara: "light mode seems to be +// default"). Still fully overridable via the settings menu — this only +// changes what a user with no *saved* preference sees. +import { useEffect } from 'preact/hooks'; +import { useLocalSetting } from './settings-storage.js'; + +export type ThemeOverride = 'system' | 'light' | 'dark'; + +export const THEME_OVERRIDE_KEY = 'agent:theme-override'; + +export function useThemeOverride() { + return useLocalSetting(THEME_OVERRIDE_KEY, 'dark'); +} + +// Mounted once, high in the tree (`Root`) — same rationale as swarm-ui's +// `Shell`: one mount point applies the override regardless of what else +// is rendering, and `useLocalSetting`'s same-tab subscription means +// `SettingsMenu` changing the value re-runs this effect without either +// component needing to know about the other directly. +export function useApplyThemeOverride(): void { + const [override] = useThemeOverride(); + useEffect(() => { + const root = document.documentElement; + if (override === 'system') { + delete root.dataset.theme; + } else { + root.dataset.theme = override; + } + }, [override]); +}