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: {...@@ -61,7 +61,7 @@ export function browseInputEndPaddingClass(input: {
61export type SearchOverlayMode = "command" | "files" | "content";61export type SearchOverlayMode = "command" | "files" | "content";
6262
63export type CommandPaletteOpenIntent =63export type CommandPaletteOpenIntent =
64 | { readonly kind: "add-project" | "new-thread-in" }64 | { readonly kind: "add-project" | "new-thread-in" | "change-theme" }
65 | {65 | {
66 readonly kind: "search";66 readonly kind: "search";
67 readonly query: string;67 readonly query: string;
...@@ -84,6 +84,7 @@ export type CommandPaletteUiAction =...@@ -84,6 +84,7 @@ export type CommandPaletteUiAction =
84 }84 }
85 | { readonly _tag: "OpenAddProject" }85 | { readonly _tag: "OpenAddProject" }
86 | { readonly _tag: "OpenNewThreadIn" }86 | { readonly _tag: "OpenNewThreadIn" }
87 | { readonly _tag: "OpenChangeTheme" }
87 | { readonly _tag: "ClearOpenIntent" };88 | { readonly _tag: "ClearOpenIntent" };
8889
89export function reduceCommandPaletteUiState(90export function reduceCommandPaletteUiState(
...@@ -113,6 +114,8 @@ export function reduceCommandPaletteUiState(...@@ -113,6 +114,8 @@ export function reduceCommandPaletteUiState(
113 return { open: true, mode: "command", openIntent: { kind: "add-project" } };114 return { open: true, mode: "command", openIntent: { kind: "add-project" } };
114 case "OpenNewThreadIn":115 case "OpenNewThreadIn":
115 return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };116 return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };
117 case "OpenChangeTheme":
118 return { open: true, mode: "command", openIntent: { kind: "change-theme" } };
116 case "ClearOpenIntent":119 case "ClearOpenIntent":
117 return state.openIntent ? { ...state, openIntent: null } : state;120 return state.openIntent ? { ...state, openIntent: null } : state;
118 }121 }
apps/web/src/components/CommandPalette.tsx+188-4
...@@ -48,9 +48,12 @@ import {...@@ -48,9 +48,12 @@ import {
48 FolderPlusIcon,48 FolderPlusIcon,
49 LinkIcon,49 LinkIcon,
50 MessageSquareIcon,50 MessageSquareIcon,
51 MonitorIcon,
52 MoonIcon,
51 PaletteIcon,53 PaletteIcon,
52 SettingsIcon,54 SettingsIcon,
53 SquarePenIcon,55 SquarePenIcon,
56 SunIcon,
54 TextSearchIcon,57 TextSearchIcon,
55} from "lucide-react";58} from "lucide-react";
56import {59import {
...@@ -74,6 +77,15 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"...@@ -74,6 +77,15 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"
74import { writeTextToClipboard } from "../hooks/useCopyToClipboard";77import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
75import { useClientSettings } from "../hooks/useSettings";78import { useClientSettings } from "../hooks/useSettings";
76import { useTheme } from "../hooks/useTheme";79import { 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";
77import { readLocalApi } from "../localApi";89import { readLocalApi } from "../localApi";
78import { desktopLocalBackendId } from "../connection/desktopLocal";90import { desktopLocalBackendId } from "../connection/desktopLocal";
79import { filesystemEnvironment } from "../state/filesystem";91import { filesystemEnvironment } from "../state/filesystem";
...@@ -184,6 +196,22 @@ import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";...@@ -184,6 +196,22 @@ import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
184196
185const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];197const 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
187function projectFavicon(project: Project) {215function projectFavicon(project: Project) {
188 return <ProjectFavicon project={project} className="size-4" />;216 return <ProjectFavicon project={project} className="size-4" />;
189}217}
...@@ -453,7 +481,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -453,7 +481,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
453 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);481 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
454 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);482 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
455 const keybindings = useAtomValue(primaryServerKeybindingsAtom);483 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
456 const { theme, themeHalves, resolvedTheme } = useTheme();484 const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme();
457 const composerHandleRef = useRef<ChatComposerHandle | null>(null);485 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
458 const routeTarget = useParams({486 const routeTarget = useParams({
459 strict: false,487 strict: false,
...@@ -494,8 +522,33 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -494,8 +522,33 @@ export function CommandPalette({ children }: { children: ReactNode }) {
494 terminalOpen,522 terminalOpen,
495 previewFocus: isPreviewFocused(),523 previewFocus: isPreviewFocused(),
496 previewOpen,524 previewOpen,
525 modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false,
497 },526 },
498 });527 });
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 }
499 if (command === "themeEditor.toggle") {552 if (command === "themeEditor.toggle") {
500 event.preventDefault();553 event.preventDefault();
501 event.stopPropagation();554 event.stopPropagation();
...@@ -516,7 +569,17 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -516,7 +569,17 @@ export function CommandPalette({ children }: { children: ReactNode }) {
516 };569 };
517 window.addEventListener("keydown", onKeyDown);570 window.addEventListener("keydown", onKeyDown);
518 return () => window.removeEventListener("keydown", onKeyDown);571 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
521 useEffect(584 useEffect(
522 () =>585 () =>
...@@ -702,7 +765,30 @@ function OpenCommandPaletteDialog(props: {...@@ -702,7 +765,30 @@ function OpenCommandPaletteDialog(props: {
702 const projectOrder = useUiStateStore((store) => store.projectOrder);765 const projectOrder = useUiStateStore((store) => store.projectOrder);
703 const threads = useThreadShells();766 const threads = useThreadShells();
704 const keybindings = useAtomValue(primaryServerKeybindingsAtom);767 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]);
706 const providers = useAtomValue(primaryServerProvidersAtom);792 const providers = useAtomValue(primaryServerProvidersAtom);
707 const providerEntryByEnvironmentAndInstanceId = useMemo(() => {793 const providerEntryByEnvironmentAndInstanceId = useMemo(() => {
708 const map = new Map<string, ProviderInstanceEntry>();794 const map = new Map<string, ProviderInstanceEntry>();
...@@ -1803,6 +1889,100 @@ function OpenCommandPaletteDialog(props: {...@@ -1803,6 +1889,100 @@ function OpenCommandPaletteDialog(props: {
1803 });1889 });
1804 }1890 }
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
1806 actionItems.push({1986 actionItems.push({
1807 kind: "action",1987 kind: "action",
1808 value: "action:theme-editor",1988 value: "action:theme-editor",
...@@ -1898,7 +2078,11 @@ function OpenCommandPaletteDialog(props: {...@@ -1898,7 +2078,11 @@ function OpenCommandPaletteDialog(props: {
1898 addProjectEnvironmentId,2078 addProjectEnvironmentId,
1899 buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data),2079 buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data),
1900 )2080 )
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
1903 const filteredGroups = filterCommandPaletteGroups({2087 const filteredGroups = filterCommandPaletteGroups({
1904 activeGroups,2088 activeGroups,
apps/web/src/components/settings/ThemePreviewCircles.tsx+6-1
...@@ -131,14 +131,19 @@ function themePreviewEdgeShadow(mode: ThemeAppearance): string {...@@ -131,14 +131,19 @@ function themePreviewEdgeShadow(mode: ThemeAppearance): string {
131export function ThemePreviewCircle({131export function ThemePreviewCircle({
132 colors,132 colors,
133 mode,133 mode,
134 className,
134}: {135}: {
135 colors: ThemeCardPreviewColors;136 colors: ThemeCardPreviewColors;
136 mode: ThemeAppearance;137 mode: ThemeAppearance;
138 className?: string;
137}) {139}) {
138 return (140 return (
139 <span141 <span
140 aria-hidden142 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 )}
142 style={{ boxShadow: themePreviewEdgeShadow(mode) }}147 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
143 >148 >
144 <span149 <span
apps/web/src/themePalette.ts+3-1
...@@ -238,9 +238,11 @@ export function invalidateCustomThemes() {...@@ -238,9 +238,11 @@ export function invalidateCustomThemes() {
238 notifyCustomThemeListeners();238 notifyCustomThemeListeners();
239}239}
240240
241const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = [];
242
241export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {243export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
242 const snapshot = getCustomThemeLibrarySnapshot();244 const snapshot = getCustomThemeLibrarySnapshot();
243 return snapshot.status === "ready" ? snapshot.themes : [];245 return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;
244}246}
245247
246export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> {248export 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...@@ -4,6 +4,12 @@ Open **Settings → Appearance** to choose a theme and follow the system appeara
4or dark mode. To use different themes for light and dark mode, select the corresponding preview4or dark mode. To use different themes for light and dark mode, select the corresponding preview
5within each theme. Appearance preferences are saved separately on each device or browser.5within 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
7Mobile has its own themes and text, code, and terminal preferences. It does not follow environment13Mobile has its own themes and text, code, and terminal preferences. It does not follow environment
8themes or defaults.14themes or defaults.
915
packages/contracts/src/keybindings.ts+2
...@@ -75,6 +75,8 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -75,6 +75,8 @@ export const STATIC_KEYBINDING_COMMANDS = [
75 "commandPalette.toggle",75 "commandPalette.toggle",
76 "filePicker.toggle",76 "filePicker.toggle",
77 "projectSearch.toggle",77 "projectSearch.toggle",
78 "theme.select",
79 "appearance.cycle",
78 "themeEditor.toggle",80 "themeEditor.toggle",
79 "composer.stash",81 "composer.stash",
80 "composer.host",82 "composer.host",
packages/shared/src/keybindings.ts+2
...@@ -38,6 +38,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -38,6 +38,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
38 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },38 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
39 { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },39 { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
40 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },40 { 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" },
41 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },43 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },
42 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },44 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
43 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },45 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },