authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 21:38:30-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 21:38:30-03:00
log9686cd9afe20d13fcb850199f9b9849a8276737a
tree4f3456a86437538e147659082d9921d64bc65c73
parente106fe95cc814797cfb3dad6d78c062e96d0060f
signature Signed by PGP key B5690EEEBB952194

feat(web): choose themes from chat with color previews (#12143)


7 files changed, 211 insertions(+), 7 deletions(-)

apps/web/src/components/CommandPalette.logic.ts+4-1
......@@ -61,7 +61,7 @@ export function browseInputEndPaddingClass(input: {
6161export type SearchOverlayMode = "command" | "files" | "content";
6262
6363export type CommandPaletteOpenIntent =
64 | { readonly kind: "add-project" | "new-thread-in" }
64 | { readonly kind: "add-project" | "new-thread-in" | "change-theme" }
6565 | {
6666 readonly kind: "search";
6767 readonly query: string;
......@@ -84,6 +84,7 @@ export type CommandPaletteUiAction =
8484 }
8585 | { readonly _tag: "OpenAddProject" }
8686 | { readonly _tag: "OpenNewThreadIn" }
87 | { readonly _tag: "OpenChangeTheme" }
8788 | { readonly _tag: "ClearOpenIntent" };
8889
8990export function reduceCommandPaletteUiState(
......@@ -113,6 +114,8 @@ export function reduceCommandPaletteUiState(
113114 return { open: true, mode: "command", openIntent: { kind: "add-project" } };
114115 case "OpenNewThreadIn":
115116 return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };
117 case "OpenChangeTheme":
118 return { open: true, mode: "command", openIntent: { kind: "change-theme" } };
116119 case "ClearOpenIntent":
117120 return state.openIntent ? { ...state, openIntent: null } : state;
118121 }
apps/web/src/components/CommandPalette.tsx+188-4
......@@ -48,9 +48,12 @@ import {
4848 FolderPlusIcon,
4949 LinkIcon,
5050 MessageSquareIcon,
51 MonitorIcon,
52 MoonIcon,
5153 PaletteIcon,
5254 SettingsIcon,
5355 SquarePenIcon,
56 SunIcon,
5457 TextSearchIcon,
5558} from "lucide-react";
5659import {
......@@ -74,6 +77,15 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"
7477import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
7578import { useClientSettings } from "../hooks/useSettings";
7679import { useTheme } from "../hooks/useTheme";
80import { useCustomThemes } from "../hooks/useCustomThemes";
81import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme";
82import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
83import { getThemeDefinition } from "../themePalette";
84import {
85 STANDARD_THEME_CARDS,
86 getThemeCardDefinition,
87 ThemePreviewCircle,
88} from "./settings/ThemePreviewCircles";
7789import { readLocalApi } from "../localApi";
7890import { desktopLocalBackendId } from "../connection/desktopLocal";
7991import { filesystemEnvironment } from "../state/filesystem";
......@@ -184,6 +196,22 @@ import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
184196
185197const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
186198
199const APPEARANCE_OPTIONS = [
200 { mode: "system", label: "System", icon: MonitorIcon },
201 { mode: "light", label: "Light", icon: SunIcon },
202 { mode: "dark", label: "Dark", icon: MoonIcon },
203] as const;
204
205function notifyThemeSaveFailure(): void {
206 toastManager.add(
207 stackedThreadToast({
208 type: "error",
209 title: "Couldn't save theme selection",
210 description: "Try again.",
211 }),
212 );
213}
214
187215function projectFavicon(project: Project) {
188216 return <ProjectFavicon project={project} className="size-4" />;
189217}
......@@ -453,7 +481,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
453481 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
454482 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
455483 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
456 const { theme, themeHalves, resolvedTheme } = useTheme();
484 const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme();
457485 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
458486 const routeTarget = useParams({
459487 strict: false,
......@@ -494,8 +522,33 @@ export function CommandPalette({ children }: { children: ReactNode }) {
494522 terminalOpen,
495523 previewFocus: isPreviewFocused(),
496524 previewOpen,
525 modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false,
497526 },
498527 });
528 if (command === "appearance.cycle") {
529 event.preventDefault();
530 event.stopPropagation();
531 if (event.repeat) return;
532 const nextMode =
533 appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system";
534 if (!setAppearanceMode(nextMode)) {
535 notifyThemeSaveFailure();
536 } else {
537 toastManager.add({
538 id: "appearance-cycle",
539 title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`,
540 timeout: 1500,
541 });
542 }
543 return;
544 }
545 if (command === "theme.select") {
546 event.preventDefault();
547 event.stopPropagation();
548 if (event.repeat) return;
549 dispatch({ _tag: "OpenChangeTheme" });
550 return;
551 }
499552 if (command === "themeEditor.toggle") {
500553 event.preventDefault();
501554 event.stopPropagation();
......@@ -516,7 +569,17 @@ export function CommandPalette({ children }: { children: ReactNode }) {
516569 };
517570 window.addEventListener("keydown", onKeyDown);
518571 return () => window.removeEventListener("keydown", onKeyDown);
519 }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]);
572 }, [
573 appearanceMode,
574 keybindings,
575 previewOpen,
576 resolvedTheme,
577 setAppearanceMode,
578 terminalOpen,
579 theme,
580 themeHalves,
581 toggleMode,
582 ]);
520583
521584 useEffect(
522585 () =>
......@@ -702,7 +765,30 @@ function OpenCommandPaletteDialog(props: {
702765 const projectOrder = useUiStateStore((store) => store.projectOrder);
703766 const threads = useThreadShells();
704767 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
705 const { theme, themeHalves, resolvedTheme } = useTheme();
768 const {
769 theme,
770 themeHalves,
771 resolvedTheme,
772 appearanceMode,
773 setAppearanceMode,
774 setTheme,
775 setThemeHalf,
776 } = useTheme();
777 const customThemes = useCustomThemes();
778 const environmentThemes = useEnvironmentThemeDefinitions();
779 const themeCards = useMemo(() => {
780 const seen = new Set<string>();
781 return [
782 ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })),
783 ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes]
784 .filter((definition) => {
785 if (seen.has(definition.id)) return false;
786 seen.add(definition.id);
787 return true;
788 })
789 .map(getThemeCardDefinition),
790 ];
791 }, [customThemes, environmentThemes]);
706792 const providers = useAtomValue(primaryServerProvidersAtom);
707793 const providerEntryByEnvironmentAndInstanceId = useMemo(() => {
708794 const map = new Map<string, ProviderInstanceEntry>();
......@@ -1803,6 +1889,100 @@ function OpenCommandPaletteDialog(props: {
18031889 });
18041890 }
18051891
1892 const changeThemeItem: CommandPaletteSubmenuItem = {
1893 kind: "submenu",
1894 value: "action:change-theme",
1895 searchTerms: ["change theme", "appearance", "colors", "palette"],
1896 title: "Change theme",
1897 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
1898 addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />,
1899 shortcutCommand: "theme.select",
1900 groups: [
1901 {
1902 value: "themes",
1903 label: "Change theme",
1904 items: themeCards.map(({ id, label, previews }) => ({
1905 kind: "action",
1906 value: id === null ? "theme:standard" : `theme:palette:${id}`,
1907 title: label,
1908 description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined,
1909 searchTerms: [label, "theme", "appearance"],
1910 icon: <PaletteIcon className={ITEM_ICON_CLASS} />,
1911 titleTrailingContent: (
1912 <span className="flex shrink-0 items-center gap-2">
1913 {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? (
1914 <span className="text-xs text-muted-foreground/70">Current</span>
1915 ) : null}
1916 <span className="flex items-center gap-1" aria-hidden>
1917 {previews.map((preview) => (
1918 <ThemePreviewCircle
1919 key={preview.mode}
1920 colors={preview.colors}
1921 mode={preview.mode}
1922 className="size-3 border-0"
1923 />
1924 ))}
1925 </span>
1926 </span>
1927 ),
1928 run: async () => {
1929 const saved =
1930 previews.length === 1 && id !== null
1931 ? setThemeHalf(previews[0]!.mode, id)
1932 : setTheme(id ?? appearanceMode);
1933 if (!saved) notifyThemeSaveFailure();
1934 },
1935 })),
1936 },
1937 ],
1938 };
1939 actionItems.push(changeThemeItem);
1940
1941 const changeAppearanceItem: CommandPaletteSubmenuItem = {
1942 kind: "submenu",
1943 value: "action:change-appearance",
1944 searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"],
1945 title: "Change appearance",
1946 icon: <MonitorIcon className={ITEM_ICON_CLASS} />,
1947 addonIcon: <MonitorIcon className={ADDON_ICON_CLASS} />,
1948 shortcutCommand: "appearance.cycle",
1949 groups: [
1950 {
1951 value: "appearance",
1952 label: "Change appearance",
1953 items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({
1954 kind: "action",
1955 value: `appearance:${mode}`,
1956 title: label,
1957 searchTerms: [label, "appearance", "mode"],
1958 icon: <Icon className={ITEM_ICON_CLASS} />,
1959 titleTrailingContent:
1960 appearanceMode === mode ? (
1961 <span className="text-xs text-muted-foreground/70">Current</span>
1962 ) : undefined,
1963 run: async () => {
1964 if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
1965 },
1966 })),
1967 },
1968 ],
1969 };
1970 actionItems.push(changeAppearanceItem);
1971
1972 useLayoutEffect(() => {
1973 if (openIntent?.kind !== "change-theme") return;
1974 clearOpenIntent();
1975 browseNavigation.invalidate();
1976 cloneLookupGeneration.current += 1;
1977 setIsRemoteProjectLookingUp(false);
1978 setAddProjectCloneFlow(null);
1979 setViewStack([]);
1980 pushPaletteView({
1981 addonIcon: <PaletteIcon className={ADDON_ICON_CLASS} />,
1982 groups: [{ value: "themes", label: "Change theme", items: [] }],
1983 });
1984 }, [browseNavigation, clearOpenIntent, openIntent, pushPaletteView]);
1985
18061986 actionItems.push({
18071987 kind: "action",
18081988 value: "action:theme-editor",
......@@ -1898,7 +2078,11 @@ function OpenCommandPaletteDialog(props: {
18982078 addProjectEnvironmentId,
18992079 buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data),
19002080 )
1901 : (currentView?.groups ?? rootGroups);
2081 : currentView?.groups[0]?.value === "themes"
2082 ? changeThemeItem.groups
2083 : currentView?.groups[0]?.value === "appearance"
2084 ? changeAppearanceItem.groups
2085 : (currentView?.groups ?? rootGroups);
19022086
19032087 const filteredGroups = filterCommandPaletteGroups({
19042088 activeGroups,
apps/web/src/components/settings/ThemePreviewCircles.tsx+6-1
......@@ -131,14 +131,19 @@ function themePreviewEdgeShadow(mode: ThemeAppearance): string {
131131export function ThemePreviewCircle({
132132 colors,
133133 mode,
134 className,
134135}: {
135136 colors: ThemeCardPreviewColors;
136137 mode: ThemeAppearance;
138 className?: string;
137139}) {
138140 return (
139141 <span
140142 aria-hidden
141 className="relative block size-14 shrink-0 overflow-hidden rounded-full border-2 border-background"
143 className={cn(
144 "relative block size-14 shrink-0 overflow-hidden rounded-full border-2 border-background",
145 className,
146 )}
142147 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
143148 >
144149 <span
apps/web/src/themePalette.ts+3-1
......@@ -238,9 +238,11 @@ export function invalidateCustomThemes() {
238238 notifyCustomThemeListeners();
239239}
240240
241const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = [];
242
241243export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
242244 const snapshot = getCustomThemeLibrarySnapshot();
243 return snapshot.status === "ready" ? snapshot.themes : [];
245 return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;
244246}
245247
246248export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> {
docs/user/appearance.md+6
......@@ -4,6 +4,12 @@ Open **Settings → Appearance** to choose a theme and follow the system appeara
44or dark mode. To use different themes for light and dark mode, select the corresponding preview
55within each theme. Appearance preferences are saved separately on each device or browser.
66
7On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat.
8Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly.
9Use **Change appearance** in the command palette to choose System, Light, or Dark independently of
10the theme. **Cmd+Option+Shift+A** on macOS or **Ctrl+Alt+Shift+A** on Windows/Linux cycles through
11those modes. Customize these shortcuts under **Settings → Keybindings**.
12
713Mobile has its own themes and text, code, and terminal preferences. It does not follow environment
814themes or defaults.
915
packages/contracts/src/keybindings.ts+2
......@@ -75,6 +75,8 @@ export const STATIC_KEYBINDING_COMMANDS = [
7575 "commandPalette.toggle",
7676 "filePicker.toggle",
7777 "projectSearch.toggle",
78 "theme.select",
79 "appearance.cycle",
7880 "themeEditor.toggle",
7981 "composer.stash",
8082 "composer.host",
packages/shared/src/keybindings.ts+2
......@@ -38,6 +38,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
3838 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
3939 { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
4040 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
41 { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" },
42 { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" },
4143 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },
4244 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
4345 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },