authorgravatar for me@maxkatz.meMax Katz <me@maxkatz.me> 2026-07-28 13:28:29+04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 11:28:29+02:00
log4f584da0f84666bda0cd399721115b0001cc0308
treec9eeac23b5b3cd9d7de9d71b6bf2f8cce16098df
parent362f12792254335bfe2a015787d7cbbaa214654e
signature Signed by PGP key B5690EEEBB952194

feat(web): add appearance settings category (#4715)


4 files changed, 107 insertions(+), 61 deletions(-)

apps/web/src/components/settings/SettingsPanels.tsx+72-61
...@@ -524,58 +524,23 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -524,58 +524,23 @@ export function useSettingsRestore(onRestored?: () => void) {
524 };524 };
525}525}
526526
527export function GeneralSettingsPanel() {527export function AppearanceSettingsPanel() {
528 const { theme, setTheme } = useTheme();528 const { theme, setTheme } = useTheme();
529 const settings = usePrimarySettings();529 const settings = usePrimarySettings();
530 const updateSettings = useUpdatePrimarySettings();530 const updateSettings = useUpdatePrimarySettings();
531 const environmentStageLabel = useEnvironmentStageLabel();531 const environmentStageLabel = useEnvironmentStageLabel();
532 const showEnvironmentIdentification =532 const showEnvironmentIdentification =
533 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;533 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
534 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
535 readLastEnabledProjectGroupingMode(),
536 );
537 const observability = useAtomValue(primaryServerObservabilityAtom);
538 const serverProviders = useAtomValue(primaryServerProvidersAtom);
539 const glassOpacityRatio =534 const glassOpacityRatio =
540 (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY);535 (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY);
541 const glassOpacitySliderStyle = {536 const glassOpacitySliderStyle = {
542 "--glass-slider-progress": `${glassOpacityRatio * 100}%`,537 "--glass-slider-progress": `${glassOpacityRatio * 100}%`,
543 "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,538 "--glass-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,
544 } as CSSProperties;539 } as CSSProperties;
545 const diagnosticsDescription = formatDiagnosticsDescription({
546 localTracingEnabled: observability?.localTracingEnabled ?? false,
547 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
548 otlpTracesUrl: observability?.otlpTracesUrl,
549 otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false,
550 otlpMetricsUrl: observability?.otlpMetricsUrl,
551 });
552
553 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
554 const textGenInstanceId = textGenerationModelSelection.instanceId;
555 const textGenModel = textGenerationModelSelection.model;
556 const textGenModelOptions = textGenerationModelSelection.options;
557 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
558 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
559 );
560 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
561 (entry) => entry.instanceId === textGenInstanceId,
562 );
563 const textGenProvider: ProviderDriverKind =
564 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
565 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
566 settings,
567 serverProviders,
568 textGenInstanceId,
569 textGenModel,
570 );
571 const isTextGenerationModelDirty = !Equal.equals(
572 settings.textGenerationModelSelection ?? null,
573 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
574 );
575540
576 return (541 return (
577 <SettingsPageContainer>542 <SettingsPageContainer>
578 <SettingsSection title="General">543 <SettingsSection title="Appearance">
579 <SettingsRow544 <SettingsRow
580 title="Theme"545 title="Theme"
581 description="Choose how T3 Code looks across the app."546 description="Choose how T3 Code looks across the app."
...@@ -697,6 +662,76 @@ export function GeneralSettingsPanel() {...@@ -697,6 +662,76 @@ export function GeneralSettingsPanel() {
697 />662 />
698 ) : null}663 ) : null}
699664
665 <SettingsRow
666 title="Word wrap"
667 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."
668 resetAction={
669 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
670 <SettingResetButton
671 label="word wrapping"
672 onClick={() =>
673 updateSettings({
674 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
675 })
676 }
677 />
678 ) : null
679 }
680 control={
681 <Switch
682 checked={settings.wordWrap}
683 onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
684 aria-label="Wrap code, tables, diffs, and file previews by default"
685 />
686 }
687 />
688 </SettingsSection>
689 </SettingsPageContainer>
690 );
691}
692
693export function GeneralSettingsPanel() {
694 const settings = usePrimarySettings();
695 const updateSettings = useUpdatePrimarySettings();
696 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
697 readLastEnabledProjectGroupingMode(),
698 );
699 const observability = useAtomValue(primaryServerObservabilityAtom);
700 const serverProviders = useAtomValue(primaryServerProvidersAtom);
701 const diagnosticsDescription = formatDiagnosticsDescription({
702 localTracingEnabled: observability?.localTracingEnabled ?? false,
703 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
704 otlpTracesUrl: observability?.otlpTracesUrl,
705 otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false,
706 otlpMetricsUrl: observability?.otlpMetricsUrl,
707 });
708
709 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
710 const textGenInstanceId = textGenerationModelSelection.instanceId;
711 const textGenModel = textGenerationModelSelection.model;
712 const textGenModelOptions = textGenerationModelSelection.options;
713 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
714 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
715 );
716 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
717 (entry) => entry.instanceId === textGenInstanceId,
718 );
719 const textGenProvider: ProviderDriverKind =
720 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
721 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
722 settings,
723 serverProviders,
724 textGenInstanceId,
725 textGenModel,
726 );
727 const isTextGenerationModelDirty = !Equal.equals(
728 settings.textGenerationModelSelection ?? null,
729 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
730 );
731
732 return (
733 <SettingsPageContainer>
734 <SettingsSection title="General">
700 <SettingsRow735 <SettingsRow
701 title="Project Grouping"736 title="Project Grouping"
702 description="Combine matching repositories across environments."737 description="Combine matching repositories across environments."
...@@ -775,30 +810,6 @@ export function GeneralSettingsPanel() {...@@ -775,30 +810,6 @@ export function GeneralSettingsPanel() {
775 }810 }
776 />811 />
777812
778 <SettingsRow
779 title="Word wrap"
780 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."
781 resetAction={
782 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
783 <SettingResetButton
784 label="word wrapping"
785 onClick={() =>
786 updateSettings({
787 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
788 })
789 }
790 />
791 ) : null
792 }
793 control={
794 <Switch
795 checked={settings.wordWrap}
796 onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
797 aria-label="Wrap code, tables, diffs, and file previews by default"
798 />
799 }
800 />
801
802 <SettingsRow813 <SettingsRow
803 title="Hide whitespace changes"814 title="Hide whitespace changes"
804 description="Set whether the diff panel ignores whitespace-only edits by default."815 description="Set whether the diff panel ignores whitespace-only edits by default."
apps/web/src/components/settings/SettingsSidebarNav.tsx+3
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 GitBranchIcon,7 GitBranchIcon,
8 KeyboardIcon,8 KeyboardIcon,
9 Link2Icon,9 Link2Icon,
10 PaletteIcon,
10 Settings2Icon,11 Settings2Icon,
11} from "lucide-react";12} from "lucide-react";
12import { useCanGoBack, useNavigate } from "@tanstack/react-router";13import { useCanGoBack, useNavigate } from "@tanstack/react-router";
...@@ -24,6 +25,7 @@ import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3Conne...@@ -24,6 +25,7 @@ import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3Conne
2425
25export type SettingsSectionPath =26export type SettingsSectionPath =
26 | "/settings/general"27 | "/settings/general"
28 | "/settings/appearance"
27 | "/settings/keybindings"29 | "/settings/keybindings"
28 | "/settings/providers"30 | "/settings/providers"
29 | "/settings/source-control"31 | "/settings/source-control"
...@@ -37,6 +39,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{...@@ -37,6 +39,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
37 icon: ComponentType<{ className?: string }>;39 icon: ComponentType<{ className?: string }>;
38}> = [40}> = [
39 { label: "General", to: "/settings/general", icon: Settings2Icon },41 { label: "General", to: "/settings/general", icon: Settings2Icon },
42 { label: "Appearance", to: "/settings/appearance", icon: PaletteIcon },
40 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },43 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
41 { label: "Providers", to: "/settings/providers", icon: BotIcon },44 { label: "Providers", to: "/settings/providers", icon: BotIcon },
42 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },45 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
apps/web/src/routeTree.gen.ts+21
...@@ -22,6 +22,7 @@ import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagn...@@ -22,6 +22,7 @@ import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagn
22import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'22import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
23import { Route as SettingsBetaRouteImport } from './routes/settings.beta'23import { Route as SettingsBetaRouteImport } from './routes/settings.beta'
24import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'24import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
25import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
25import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'26import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
26import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'27import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
27import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'28import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
...@@ -90,6 +91,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({...@@ -90,6 +91,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
90 path: '/archived',91 path: '/archived',
91 getParentRoute: () => SettingsRoute,92 getParentRoute: () => SettingsRoute,
92} as any)93} as any)
94const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({
95 id: '/appearance',
96 path: '/appearance',
97 getParentRoute: () => SettingsRoute,
98} as any)
93const ConnectCallbackRoute = ConnectCallbackRouteImport.update({99const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
94 id: '/connect_/callback',100 id: '/connect_/callback',
95 path: '/connect/callback',101 path: '/connect/callback',
...@@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {...@@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {
113 '/pair': typeof PairRoute119 '/pair': typeof PairRoute
114 '/settings': typeof SettingsRouteWithChildren120 '/settings': typeof SettingsRouteWithChildren
115 '/connect/callback': typeof ConnectCallbackRoute121 '/connect/callback': typeof ConnectCallbackRoute
122 '/settings/appearance': typeof SettingsAppearanceRoute
116 '/settings/archived': typeof SettingsArchivedRoute123 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/beta': typeof SettingsBetaRoute124 '/settings/beta': typeof SettingsBetaRoute
118 '/settings/connections': typeof SettingsConnectionsRoute125 '/settings/connections': typeof SettingsConnectionsRoute
...@@ -129,6 +136,7 @@ export interface FileRoutesByTo {...@@ -129,6 +136,7 @@ export interface FileRoutesByTo {
129 '/pair': typeof PairRoute136 '/pair': typeof PairRoute
130 '/settings': typeof SettingsRouteWithChildren137 '/settings': typeof SettingsRouteWithChildren
131 '/connect/callback': typeof ConnectCallbackRoute138 '/connect/callback': typeof ConnectCallbackRoute
139 '/settings/appearance': typeof SettingsAppearanceRoute
132 '/settings/archived': typeof SettingsArchivedRoute140 '/settings/archived': typeof SettingsArchivedRoute
133 '/settings/beta': typeof SettingsBetaRoute141 '/settings/beta': typeof SettingsBetaRoute
134 '/settings/connections': typeof SettingsConnectionsRoute142 '/settings/connections': typeof SettingsConnectionsRoute
...@@ -148,6 +156,7 @@ export interface FileRoutesById {...@@ -148,6 +156,7 @@ export interface FileRoutesById {
148 '/pair': typeof PairRoute156 '/pair': typeof PairRoute
149 '/settings': typeof SettingsRouteWithChildren157 '/settings': typeof SettingsRouteWithChildren
150 '/connect_/callback': typeof ConnectCallbackRoute158 '/connect_/callback': typeof ConnectCallbackRoute
159 '/settings/appearance': typeof SettingsAppearanceRoute
151 '/settings/archived': typeof SettingsArchivedRoute160 '/settings/archived': typeof SettingsArchivedRoute
152 '/settings/beta': typeof SettingsBetaRoute161 '/settings/beta': typeof SettingsBetaRoute
153 '/settings/connections': typeof SettingsConnectionsRoute162 '/settings/connections': typeof SettingsConnectionsRoute
...@@ -168,6 +177,7 @@ export interface FileRouteTypes {...@@ -168,6 +177,7 @@ export interface FileRouteTypes {
168 | '/pair'177 | '/pair'
169 | '/settings'178 | '/settings'
170 | '/connect/callback'179 | '/connect/callback'
180 | '/settings/appearance'
171 | '/settings/archived'181 | '/settings/archived'
172 | '/settings/beta'182 | '/settings/beta'
173 | '/settings/connections'183 | '/settings/connections'
...@@ -184,6 +194,7 @@ export interface FileRouteTypes {...@@ -184,6 +194,7 @@ export interface FileRouteTypes {
184 | '/pair'194 | '/pair'
185 | '/settings'195 | '/settings'
186 | '/connect/callback'196 | '/connect/callback'
197 | '/settings/appearance'
187 | '/settings/archived'198 | '/settings/archived'
188 | '/settings/beta'199 | '/settings/beta'
189 | '/settings/connections'200 | '/settings/connections'
...@@ -202,6 +213,7 @@ export interface FileRouteTypes {...@@ -202,6 +213,7 @@ export interface FileRouteTypes {
202 | '/pair'213 | '/pair'
203 | '/settings'214 | '/settings'
204 | '/connect_/callback'215 | '/connect_/callback'
216 | '/settings/appearance'
205 | '/settings/archived'217 | '/settings/archived'
206 | '/settings/beta'218 | '/settings/beta'
207 | '/settings/connections'219 | '/settings/connections'
...@@ -316,6 +328,13 @@ declare module '@tanstack/react-router' {...@@ -316,6 +328,13 @@ declare module '@tanstack/react-router' {
316 preLoaderRoute: typeof SettingsArchivedRouteImport328 preLoaderRoute: typeof SettingsArchivedRouteImport
317 parentRoute: typeof SettingsRoute329 parentRoute: typeof SettingsRoute
318 }330 }
331 '/settings/appearance': {
332 id: '/settings/appearance'
333 path: '/appearance'
334 fullPath: '/settings/appearance'
335 preLoaderRoute: typeof SettingsAppearanceRouteImport
336 parentRoute: typeof SettingsRoute
337 }
319 '/connect_/callback': {338 '/connect_/callback': {
320 id: '/connect_/callback'339 id: '/connect_/callback'
321 path: '/connect/callback'340 path: '/connect/callback'
...@@ -355,6 +374,7 @@ const ChatRouteChildren: ChatRouteChildren = {...@@ -355,6 +374,7 @@ const ChatRouteChildren: ChatRouteChildren = {
355const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)374const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
356375
357interface SettingsRouteChildren {376interface SettingsRouteChildren {
377 SettingsAppearanceRoute: typeof SettingsAppearanceRoute
358 SettingsArchivedRoute: typeof SettingsArchivedRoute378 SettingsArchivedRoute: typeof SettingsArchivedRoute
359 SettingsBetaRoute: typeof SettingsBetaRoute379 SettingsBetaRoute: typeof SettingsBetaRoute
360 SettingsConnectionsRoute: typeof SettingsConnectionsRoute380 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
...@@ -366,6 +386,7 @@ interface SettingsRouteChildren {...@@ -366,6 +386,7 @@ interface SettingsRouteChildren {
366}386}
367387
368const SettingsRouteChildren: SettingsRouteChildren = {388const SettingsRouteChildren: SettingsRouteChildren = {
389 SettingsAppearanceRoute: SettingsAppearanceRoute,
369 SettingsArchivedRoute: SettingsArchivedRoute,390 SettingsArchivedRoute: SettingsArchivedRoute,
370 SettingsBetaRoute: SettingsBetaRoute,391 SettingsBetaRoute: SettingsBetaRoute,
371 SettingsConnectionsRoute: SettingsConnectionsRoute,392 SettingsConnectionsRoute: SettingsConnectionsRoute,
apps/web/src/routes/settings.appearance.tsx created+11
...@@ -0,0 +1,11 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { AppearanceSettingsPanel } from "../components/settings/SettingsPanels";
4
5function SettingsAppearanceRoute() {
6 return <AppearanceSettingsPanel />;
7}
8
9export const Route = createFileRoute("/settings/appearance")({
10 component: SettingsAppearanceRoute,
11});