Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **The dashboard follows the system appearance** (`actionagent`). Settings
→ Appearance offers System, Light and Dark; System (the default when
nothing was chosen) follows the OS light/dark setting and changes with it
while the page is open. Light and Dark are fixed, as the old toggle's
choices were, and a choice that toggle saved still holds. The header's
sun/moon button flips to the opposite of what shows and saves it as a
fixed choice; pick System on the settings page to follow the OS again.

### Changed

- **One badge for every status and message role in the dashboard**
Expand Down
2 changes: 1 addition & 1 deletion actionagent/app/assets/builds/action_agent.css

Large diffs are not rendered by default.

118 changes: 59 additions & 59 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion actionagent/frontend/components/dashboard/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export default function Header({ user, account }) {

{/* Right side - User controls */}
<div className="flex items-center space-x-4">
{/* Dark mode toggle */}
{/* One-click flip to the opposite of what shows, saved as a fixed
choice; Settings → Appearance has System, Light and Dark. */}
<button
onClick={toggleDarkMode}
className="p-2 rounded-lg transition-colors"
Expand Down
26 changes: 9 additions & 17 deletions actionagent/frontend/components/dashboard/SettingsView.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { SegmentedControl } from './primitives';
import { themeDescription, THEME_OPTIONS } from '../../utils/theme.mjs';
import GithubIntegrationCard from './GithubIntegrationCard';
import ClaudeCodeIntegrationCard from './ClaudeCodeIntegrationCard';
import CodexIntegrationCard from './CodexIntegrationCard';
Expand All @@ -20,7 +22,7 @@ function initialQuery() {
}

export default function SettingsView({ user, account }) {
const { darkMode, toggleDarkMode } = useTheme();
const { darkMode, theme, systemDark, setTheme } = useTheme();
// With personal keys on, the API Keys tab manages the signed-in user's own
// provider keys; the organization's are on the Organization page.
const [providerKeyScope] = useState(() => (window.ACTIVE_AGENT_DASHBOARD?.meta?.personalProviderKeys ? 'personal' : undefined));
Expand Down Expand Up @@ -223,27 +225,17 @@ export default function SettingsView({ user, account }) {
<h3 className={`text-lg font-semibold mb-4 ${darkMode ? 'text-white' : 'text-gray-900'}`}>
Appearance
</h3>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<div>
<p className={`font-medium ${darkMode ? 'text-white' : 'text-gray-900'}`}>
Dark Mode
Theme
</p>
<p className={`text-sm ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Use dark theme across the dashboard
<p className={`text-sm ${darkMode ? 'text-gray-400' : 'text-gray-500'}`} data-testid="theme-description">
{themeDescription(theme, systemDark)}
</p>
</div>
<button
onClick={toggleDarkMode}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
darkMode ? 'bg-red-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
darkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
{/* System follows the OS appearance and changes with it. */}
<SegmentedControl options={THEME_OPTIONS} value={theme} onChange={setTheme} />
</div>
</div>
</div>
Expand Down
56 changes: 30 additions & 26 deletions actionagent/frontend/contexts/ThemeContext.jsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,44 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { readThemeChoice, resolveDarkMode, systemDarkQuery, THEME_CHOICES, toggledChoice, writeThemeChoice } from '../utils/theme.mjs';

const ThemeContext = createContext();

// `theme` is the viewer's choice: 'system' (the default) follows the OS
// appearance and changes with it; 'light' and 'dark' are fixed. `darkMode`
// is what shows, and what every component reads. `setTheme` saves a
// choice; `toggleDarkMode` is the one-click flip, which saves the opposite
// of what shows as a fixed choice.
export function ThemeProvider({ children }) {
const [darkMode, setDarkMode] = useState(() => {
// Check localStorage first
const saved = localStorage.getItem('dashboard-theme');
if (saved) return saved === 'dark';
// Fall back to system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const [theme, setThemeState] = useState(() => readThemeChoice(globalThis.localStorage));
const [systemDark, setSystemDark] = useState(() => Boolean(systemDarkQuery()?.matches));

// Follow the OS while the page is open. The listener stays on whatever
// the choice is, so switching back to 'system' shows the current
// appearance at once.
useEffect(() => {
localStorage.setItem('dashboard-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
const query = systemDarkQuery();
if (!query) return undefined;
const handler = (event) => setSystemDark(event.matches);
setSystemDark(query.matches);
query.addEventListener('change', handler);
return () => query.removeEventListener('change', handler);
}, []);

// Listen for system preference changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
// Only update if user hasn't explicitly set a preference
if (!localStorage.getItem('dashboard-theme')) {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
const setTheme = useCallback((choice) => {
const next = THEME_CHOICES.includes(choice) ? choice : 'system';
setThemeState(next);
writeThemeChoice(globalThis.localStorage, next);
}, []);

const toggleDarkMode = () => setDarkMode(!darkMode);
const darkMode = resolveDarkMode(theme, systemDark);
const toggleDarkMode = useCallback(() => setTheme(toggledChoice(darkMode)), [darkMode, setTheme]);

return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
{children}
</ThemeContext.Provider>
const value = useMemo(
() => ({ darkMode, theme, systemDark, setTheme, toggleDarkMode }),
[darkMode, theme, systemDark, setTheme, toggleDarkMode],
);

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
Expand Down
17 changes: 17 additions & 0 deletions actionagent/frontend/test/settingsCards.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ function listedRepositories(html) {
.map(([, checked, name]) => [name, Boolean(checked)]);
}

// The Appearance card is on the profile tab; the query string is read on
// each render, so the test points it there and back.
function renderProfile(props) {
const search = window.location.search;
window.location.search = '?tab=profile';
try { return render('SettingsView', props); } finally { window.location.search = search; }
}

test('the appearance card offers System, Light and Dark and marks the saved choice', () => {
const html = renderProfile({ user: { name: 'Ada', email: 'ada@example.com' }, account: { name: 'Acme' } });
const buttons = [...html.matchAll(/<button type="button" aria-pressed="(true|false)"[^>]*>(System|Light|Dark)<\/button>/g)]
.map(([, pressed, label]) => [label, pressed === 'true']);
assert.deepEqual(buttons, [['System', false], ['Light', true], ['Dark', false]], 'the storage stub answers light');
assert.match(html, /data-testid="theme-description">Light across the dashboard</);
assert.doesNotMatch(html, /Use dark theme across the dashboard/);
});

test('the repo picker lists every repository with a filter field', () => {
const html = render('RepoPicker', pickerProps());
assert.match(html, /placeholder="Filter repositories"/);
Expand Down
56 changes: 56 additions & 0 deletions actionagent/frontend/test/theme.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readThemeChoice, resolveDarkMode, systemDarkQuery, themeDescription, toggledChoice, writeThemeChoice, THEME_OPTIONS } from '../utils/theme.mjs';

const storageOf = (saved) => {
const store = new Map(saved === undefined ? [] : [['dashboard-theme', saved]]);
return { getItem: (key) => (store.has(key) ? store.get(key) : null), setItem: (key, value) => store.set(key, value), store };
};

test('the saved choice is read back, and anything else means system', () => {
assert.equal(readThemeChoice(storageOf('dark')), 'dark', 'the old toggle saved dark');
assert.equal(readThemeChoice(storageOf('light')), 'light');
assert.equal(readThemeChoice(storageOf('system')), 'system');
assert.equal(readThemeChoice(storageOf()), 'system', 'nothing saved');
assert.equal(readThemeChoice(storageOf('blue')), 'system');
assert.equal(readThemeChoice(undefined), 'system', 'no storage at all');
assert.equal(readThemeChoice({ getItem() { throw new Error('blocked'); } }), 'system', 'storage that throws');
});

test('a choice is saved under the old key; an unknown one saves as system; a throwing store is survived', () => {
const storage = storageOf();
writeThemeChoice(storage, 'dark');
assert.equal(storage.store.get('dashboard-theme'), 'dark');
writeThemeChoice(storage, 'sepia');
assert.equal(storage.store.get('dashboard-theme'), 'system');
assert.doesNotThrow(() => writeThemeChoice({ setItem() { throw new Error('blocked'); } }, 'light'));
assert.doesNotThrow(() => writeThemeChoice(undefined, 'light'));
});

test('system resolves to what the OS prefers; light and dark ignore it', () => {
assert.equal(resolveDarkMode('system', true), true);
assert.equal(resolveDarkMode('system', false), false);
assert.equal(resolveDarkMode('system', undefined), false);
assert.equal(resolveDarkMode('dark', false), true);
assert.equal(resolveDarkMode('light', true), false);
});

test('the one-click toggle picks the opposite of what shows, as a fixed choice', () => {
assert.equal(toggledChoice(true), 'light');
assert.equal(toggledChoice(false), 'dark');
});

test('the settings line says what system is showing right now', () => {
assert.equal(themeDescription('system', true), "Follows your system's appearance, dark right now");
assert.equal(themeDescription('system', false), "Follows your system's appearance, light right now");
assert.equal(themeDescription('dark', false), 'Dark across the dashboard');
assert.equal(themeDescription('light', true), 'Light across the dashboard');
assert.deepEqual(THEME_OPTIONS.map((option) => option.label), ['System', 'Light', 'Dark']);
});

test('the system query is absent where the window has no matchMedia', () => {
assert.equal(systemDarkQuery(undefined), null);
assert.equal(systemDarkQuery({}), null);
const query = { matches: true };
assert.equal(systemDarkQuery({ matchMedia: (q) => (q === '(prefers-color-scheme: dark)' ? query : null) }), query);
});
103 changes: 103 additions & 0 deletions actionagent/frontend/test/themeProvider.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test, { after, before } from 'node:test';
import { build } from 'esbuild';
import { JSDOM } from 'jsdom';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

// ThemeProvider mounted in a jsdom window whose prefers-color-scheme query
// this suite controls: the system choice follows the query and its change
// events, the fixed choices ignore them, and every choice is saved under
// the key the old light/dark toggle used.

const output = fileURLToPath(new URL(`../node_modules/.cache/theme-provider-${process.pid}.mjs`, import.meta.url));
let view, window, root, container;
// The one query the page asks for; `prefer(dark)` flips it and fires its listeners.
const query = { matches: false, listeners: new Set(), addEventListener(_, fn) { this.listeners.add(fn); }, removeEventListener(_, fn) { this.listeners.delete(fn); } };
const prefer = (dark) => view.act(async () => { query.matches = dark; query.listeners.forEach((fn) => fn({ matches: dark })); });

before(async () => {
({ window } = new JSDOM('<!doctype html><body><div id="root"></div></body>', { url: 'http://localhost/dashboard' }));
for (const key of ['window', 'document', 'navigator', 'localStorage']) Object.defineProperty(globalThis, key, { value: key === 'window' ? window : window[key], configurable: true });
window.matchMedia = (q) => (q === '(prefers-color-scheme: dark)' ? query : { matches: false, addEventListener() {}, removeEventListener() {} });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const result = await build({ stdin: { contents: `
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider, useTheme } from './contexts/ThemeContext.jsx';
export { act };
function Probe() {
const { darkMode, theme, systemDark, setTheme, toggleDarkMode } = useTheme();
return <div data-testid="probe" data-dark={String(darkMode)} data-theme={theme} data-system-dark={String(systemDark)}>
<button data-testid="toggle" onClick={toggleDarkMode} />
{['system', 'light', 'dark'].map((c) => <button key={c} data-testid={'pick-' + c} onClick={() => setTheme(c)} />)}
</div>;
}
export function mount(node) { const root = createRoot(node); root.render(<ThemeProvider><Probe /></ThemeProvider>); return root; }
`, resolveDir: fileURLToPath(new URL('..', import.meta.url)), loader: 'jsx' }, bundle: true, write: false, platform: 'node', format: 'esm', external: ['react', 'react-dom/client'], logLevel: 'error' });
mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, result.outputFiles[0].text);
view = await import(pathToFileURL(output).href);
container = window.document.getElementById('root');
});
after(async () => { if (root) await view.act(async () => root.unmount()); rmSync(output, { force: true }); window.close(); });

const probe = () => container.querySelector('[data-testid="probe"]').dataset;
const click = (id) => view.act(async () => container.querySelector(`[data-testid="${id}"]`).click());
async function mountWith(saved, dark) {
if (root) await view.act(async () => root.unmount());
localStorage.clear();
if (saved !== undefined) localStorage.setItem('dashboard-theme', saved);
query.matches = dark;
await view.act(async () => { root = view.mount(container); });
}

test('with nothing saved the dashboard follows the system and changes with it', async () => {
await mountWith(undefined, true);
assert.deepEqual([probe().theme, probe().dark], ['system', 'true']);
await prefer(false);
assert.equal(probe().dark, 'false');
await prefer(true);
assert.equal(probe().dark, 'true');
assert.equal(localStorage.getItem('dashboard-theme'), null, 'following the system saves nothing by itself');
});

test('a choice the old toggle saved still holds, and a fixed choice ignores the system', async () => {
await mountWith('light', true);
assert.deepEqual([probe().theme, probe().dark], ['light', 'false']);
await prefer(false);
await prefer(true);
assert.equal(probe().dark, 'false');
await click('pick-dark');
assert.deepEqual([probe().theme, probe().dark, localStorage.getItem('dashboard-theme')], ['dark', 'true', 'dark']);
await prefer(false);
assert.equal(probe().dark, 'true');
});

test('picking System again shows the current appearance at once', async () => {
await mountWith('dark', false);
assert.equal(probe().dark, 'true');
await click('pick-system');
assert.deepEqual([probe().theme, probe().dark, localStorage.getItem('dashboard-theme')], ['system', 'false', 'system']);
await prefer(true);
assert.equal(probe().dark, 'true');
});

test('the one-click toggle flips what shows and saves it as a fixed choice', async () => {
await mountWith(undefined, true);
await click('toggle');
assert.deepEqual([probe().theme, probe().dark, localStorage.getItem('dashboard-theme')], ['light', 'false', 'light']);
await prefer(false);
await prefer(true);
assert.equal(probe().dark, 'false', 'a fixed choice no longer follows the system');
await click('toggle');
assert.deepEqual([probe().theme, probe().dark], ['dark', 'true']);
});

test('the listener comes off with the provider', async () => {
await mountWith(undefined, false);
assert.equal(query.listeners.size, 1);
await view.act(async () => root.unmount());
root = null;
assert.equal(query.listeners.size, 0);
});
57 changes: 57 additions & 0 deletions actionagent/frontend/utils/theme.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// The dashboard's appearance: the choice a viewer saved (system, light or
// dark) and what it shows as right now. Saved under the key the old
// light/dark toggle used, so a dashboard that only ever saw that toggle
// keeps the choice it made.

export const THEME_STORAGE_KEY = 'dashboard-theme';
export const THEME_CHOICES = ['system', 'light', 'dark'];
export const THEME_OPTIONS = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
];
export const SYSTEM_DARK_QUERY = '(prefers-color-scheme: dark)';

// The saved choice; 'system' when nothing valid is saved.
export function readThemeChoice(storage) {
try {
const saved = storage?.getItem?.(THEME_STORAGE_KEY);
return THEME_CHOICES.includes(saved) ? saved : 'system';
} catch {
return 'system';
}
}

// Storage may be unavailable (private windows, blocked site data); the
// choice then lasts for the page.
export function writeThemeChoice(storage, choice) {
try {
storage?.setItem?.(THEME_STORAGE_KEY, THEME_CHOICES.includes(choice) ? choice : 'system');
} catch {
// nothing to do
}
}

// Whether the dashboard shows dark for a choice, given what the system
// prefers right now.
export function resolveDarkMode(choice, systemDark) {
return choice === 'system' ? Boolean(systemDark) : choice === 'dark';
}

// What the one-click toggle (the header's sun/moon) picks: the opposite of
// what shows now, as an explicit choice.
export function toggledChoice(darkMode) {
return darkMode ? 'light' : 'dark';
}

// The line under "Theme" on the settings page.
export function themeDescription(choice, systemDark) {
if (choice === 'system') return `Follows your system's appearance, ${systemDark ? 'dark' : 'light'} right now`;
return choice === 'dark' ? 'Dark across the dashboard' : 'Light across the dashboard';
}

// The system's media query, where the page has one (a server render and
// some test windows do not).
export function systemDarkQuery(win = globalThis.window) {
return typeof win?.matchMedia === 'function' ? win.matchMedia(SYSTEM_DARK_QUERY) : null;
}
Loading