feat(plugins): add theme contributions

Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA.
This commit is contained in:
Okan YILDIRIM
2026-08-21 11:48:12 +02:00
committed by GitHub
parent a7f3bf7abb
commit e1805ef542
41 changed files with 1238 additions and 203 deletions
@@ -17,6 +17,7 @@ import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
@@ -49,6 +50,7 @@ export {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
@@ -403,6 +403,21 @@ describe("saveAppSettings", () => {
});
});
it("persists a selected plugin theme", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
await saveAppSettings({
queryClient,
updates: { theme: "plugin", pluginThemeId: "catppuccin/theme/mocha" },
deps,
});
const loaded = await loadAppSettingsFromStorage(deps);
expect(loaded.theme).toBe("plugin");
expect(loaded.pluginThemeId).toBe("catppuccin/theme/mocha");
});
// The row items are written as one object through one strict schema, so an item the schema
// does not know does not just fail to persist itself — it takes every sibling toggle with it.
it.each(SIDEBAR_ROW_ITEMS)("persists the %s row item being switched off", async (item) => {
+21 -4
View File
@@ -15,7 +15,12 @@ import {
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { isNative } from "@/constants/platform";
import { FONT_SIZE, THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
import {
FONT_SIZE,
PLUGIN_THEME_PREFERENCE,
THEME_OPTIONS,
type ThemePreference,
} from "@/styles/theme";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
@@ -32,8 +37,13 @@ export type WorkspaceTitleSource = "title" | "branch";
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name));
const ThemePreferenceSchema = z.enum([
...THEME_OPTIONS.map((option) => option.name),
PLUGIN_THEME_PREFERENCE,
]);
const VALID_THEMES = new Set<string>(ThemePreferenceSchema.options);
/** Where the theme picker lands when the persisted preference cannot be honoured. */
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
@@ -59,6 +69,8 @@ export const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings {
theme: ThemePreference;
/** Which contributed theme `theme: "plugin"` selects. */
pluginThemeId: string | null;
language: AppLanguage;
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
@@ -103,6 +115,7 @@ const SidebarRowItemsSchema = z.strictObject({
const StoredAppSettingsSchema = z.strictObject({
theme: ThemePreferenceSchema.optional(),
pluginThemeId: z.string().nullish(),
language: z
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
.optional(),
@@ -137,7 +150,8 @@ const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
theme: DEFAULT_THEME_PREFERENCE,
pluginThemeId: null,
language: "system",
sendBehavior: "steer",
serviceUrlBehavior: "ask",
@@ -369,6 +383,9 @@ function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
Object.assign(result, pickEnumAppSettings(stored));
if (typeof stored.pluginThemeId === "string") {
result.pluginThemeId = stored.pluginThemeId;
}
if (stored.sidebarRowItems !== undefined) {
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
}