Open History to see the last commit
1import type { CSSProperties } from "react";
2import { useState } from "react";
3import {
4 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
5 DEFAULT_UNIFIED_SETTINGS,
6 type ChatWidth,
7 type EffortDisplay,
8 type EnvironmentIdentificationMode,
9 MAX_APPEARANCE_CONTRAST,
10 MAX_GLASS_OPACITY,
11 MAX_PANEL_ANIMATION_DURATION_MS,
12 MIN_APPEARANCE_CONTRAST,
13 MIN_GLASS_OPACITY,
14 MIN_PANEL_ANIMATION_DURATION_MS,
15} from "@t3tools/contracts/settings";
16import {
17 resolveSidebarStageBackdropVariant,
18 type SidebarStageBackdropVariant,
19 useEnvironmentStageLabel,
20} from "../SidebarStageBackdrop";
21import { useCustomThemes } from "../../hooks/useCustomThemes";
22import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
23import { useTheme } from "../../hooks/useTheme";
24import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
25import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
26import { Switch } from "../ui/switch";
27import { ThemeLibrary } from "./ThemeSettings";
28import {
29 SettingResetButton,
30 SettingsPageContainer,
31 SettingsRow,
32 SettingsSection,
33} from "./settingsLayout";
34import { searchableSetting } from "./settingsSearch";
35import { AdvancedSettings } from "./AdvancedSettings";
36import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
37import { TypographySection } from "./TypographySettings";
38import { ThreadDetailsItemsRow } from "./ThreadDetailsItemsRow";
39
40const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
41 artwork: "Artwork",
42 "nightly-artwork": "Nightly artwork",
43 "dev-artwork": "Dev artwork",
44 pill: "Version pill",
45 none: "None",
46};
47
48// The default "artwork" follows the running channel, so it is labeled as that channel's art,
49// or as "None" on Latest. The other channel's art is offered as a fixed choice.
50function environmentIdentificationOptions(
51 stageVariant: SidebarStageBackdropVariant | null,
52): ReadonlyArray<readonly [EnvironmentIdentificationMode, string]> {
53 if (stageVariant === "dev") {
54 return [
55 ["artwork", "Dev artwork"],
56 ["nightly-artwork", "Nightly artwork"],
57 ["pill", "Version pill"],
58 ["none", "None"],
59 ];
60 }
61 if (stageVariant === "nightly") {
62 return [
63 ["artwork", "Nightly artwork"],
64 ["dev-artwork", "Dev artwork"],
65 ["pill", "Version pill"],
66 ["none", "None"],
67 ];
68 }
69 return [
70 ["artwork", "None"],
71 ["nightly-artwork", "Nightly artwork"],
72 ["dev-artwork", "Dev artwork"],
73 ];
74}
75
76const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
77 comfortable: "Comfortable",
78 wide: "Wide",
79 full: "Full",
80};
81
82const EFFORT_DISPLAY_LABELS: Record<EffortDisplay, string> = {
83 label: "Word",
84 bars: "Bars",
85};
86
87export function AppearanceSettingsPanel() {
88 const {
89 appearanceMode,
90 refreshTheme,
91 resolvedTheme,
92 setAppearanceMode,
93 setTheme,
94 setThemeHalf,
95 theme,
96 themeHalves,
97 } = useTheme();
98 const customThemes = useCustomThemes();
99 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
100 const settings = useScopedSettings();
101 const updateSettings = useUpdateScopedSettings();
102 const environmentStageLabel = useEnvironmentStageLabel();
103 const environmentStageVariant = resolveSidebarStageBackdropVariant(environmentStageLabel);
104 const environmentIdentificationOptionList =
105 environmentIdentificationOptions(environmentStageVariant);
106 const environmentIdentificationLabel =
107 environmentIdentificationOptionList.find(
108 ([value]) => value === settings.environmentIdentificationMode,
109 )?.[1] ?? ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode];
110 const effectiveEnvironmentIdentificationMode = useEnvironmentIdentificationMode();
111 const themeHidesEnvironmentArtwork =
112 effectiveEnvironmentIdentificationMode === "pill" &&
113 resolveSidebarStageBackdropVariant(
114 environmentStageLabel,
115 settings.environmentIdentificationMode,
116 ) !== null;
117 const environmentIdentificationDescription = themeHidesEnvironmentArtwork
118 ? environmentStageVariant
119 ? "Only built-in themes show artwork, so this theme shows the version pill."
120 : "Only built-in themes show artwork."
121 : environmentStageVariant
122 ? "Choose how Dev and Nightly environments are identified."
123 : "Show Dev or Nightly artwork in the sidebar header.";
124 const glassOpacityRatio =
125 (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY);
126 const glassOpacitySliderStyle = {
127 "--settings-slider-progress": `${glassOpacityRatio * 100}%`,
128 "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,
129 } as CSSProperties;
130 const appearanceContrastRatio =
131 (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) /
132 (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST);
133 const appearanceContrastSliderStyle = {
134 "--settings-slider-progress": `${appearanceContrastRatio * 100}%`,
135 "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`,
136 } as CSSProperties;
137 const panelAnimationDurationRatio =
138 (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) /
139 (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS);
140 const panelAnimationDurationSliderStyle = {
141 "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`,
142 "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`,
143 } as CSSProperties;
144
145 return (
146 <SettingsPageContainer>
147 <SettingsSection id="appearance" title="Colors & themes" variant="plain" hideTitle>
148 <div id={searchableSetting("theme").id}>
149 <ThemeLibrary
150 appearanceMode={appearanceMode}
151 customThemes={customThemes}
152 initialAppearance={resolvedTheme}
153 refreshTheme={refreshTheme}
154 isImportOpen={isImportThemeOpen}
155 setAppearanceMode={setAppearanceMode}
156 setTheme={setTheme}
157 setThemeHalf={setThemeHalf}
158 theme={theme}
159 themeHalves={themeHalves}
160 onImportOpenChange={setIsImportThemeOpen}
161 />
162 </div>
163 </SettingsSection>
164
165 <SettingsSection id="appearance-interface" title="Interface">
166 <SettingsRow
167 {...searchableSetting("setting-appearance-contrast")}
168 description="Adjust the contrast of colors and borders across the interface."
169 resetAction={
170 settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? (
171 <SettingResetButton
172 label="contrast"
173 onClick={() =>
174 updateSettings({
175 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
176 })
177 }
178 />
179 ) : null
180 }
181 control={
182 <div className="flex w-full items-center gap-3 sm:w-52">
183 <output
184 className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
185 htmlFor="appearance-contrast"
186 >
187 {settings.appearanceContrast}%
188 </output>
189 <input
190 aria-label="Contrast"
191 className="settings-slider min-w-0 flex-1"
192 id="appearance-contrast"
193 max={MAX_APPEARANCE_CONTRAST}
194 min={MIN_APPEARANCE_CONTRAST}
195 onChange={(event) => {
196 const appearanceContrast = Number(event.currentTarget.value);
197 if (
198 Number.isInteger(appearanceContrast) &&
199 appearanceContrast >= MIN_APPEARANCE_CONTRAST &&
200 appearanceContrast <= MAX_APPEARANCE_CONTRAST
201 ) {
202 updateSettings({ appearanceContrast });
203 }
204 }}
205 step={5}
206 style={appearanceContrastSliderStyle}
207 type="range"
208 value={settings.appearanceContrast}
209 />
210 </div>
211 }
212 />
213
214 <SettingsRow
215 {...searchableSetting("setting-glass-opacity")}
216 description="Higher values make menus, dialogs, and the composer more solid."
217 resetAction={
218 settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? (
219 <SettingResetButton
220 label="glass opacity"
221 onClick={() =>
222 updateSettings({ glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity })
223 }
224 />
225 ) : null
226 }
227 control={
228 <div className="flex w-full items-center gap-3 sm:w-52">
229 <output
230 className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
231 htmlFor="glass-opacity"
232 >
233 {settings.glassOpacity}%
234 </output>
235 <input
236 aria-label="Glass opacity"
237 className="settings-slider min-w-0 flex-1"
238 id="glass-opacity"
239 max={MAX_GLASS_OPACITY}
240 min={MIN_GLASS_OPACITY}
241 onChange={(event) => {
242 const glassOpacity = Number(event.currentTarget.value);
243 if (
244 Number.isInteger(glassOpacity) &&
245 glassOpacity >= MIN_GLASS_OPACITY &&
246 glassOpacity <= MAX_GLASS_OPACITY
247 ) {
248 updateSettings({ glassOpacity });
249 }
250 }}
251 step={5}
252 style={glassOpacitySliderStyle}
253 type="range"
254 value={settings.glassOpacity}
255 />
256 </div>
257 }
258 />
259
260 <SettingsRow
261 {...searchableSetting("chat-width")}
262 description="Set how wide messages and the composer can grow on large screens."
263 resetAction={
264 settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? (
265 <SettingResetButton
266 label="chat width"
267 onClick={() => updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })}
268 />
269 ) : null
270 }
271 control={
272 <div className="w-full sm:w-40">
273 <Select
274 value={settings.chatWidth}
275 onValueChange={(value) => {
276 if (value === "comfortable" || value === "wide" || value === "full")
277 updateSettings({ chatWidth: value });
278 }}
279 >
280 <SelectTrigger size="sm" className="w-full min-w-0" aria-label="Chat width">
281 <SelectValue>{CHAT_WIDTH_LABELS[settings.chatWidth]}</SelectValue>
282 </SelectTrigger>
283 <SelectPopup align="end" alignItemWithTrigger={false}>
284 <SelectItem value="comfortable">Comfortable (default)</SelectItem>
285 <SelectItem value="wide">Wide</SelectItem>
286 <SelectItem value="full">Full</SelectItem>
287 </SelectPopup>
288 </Select>
289 </div>
290 }
291 />
292 <ThreadDetailsItemsRow />
293 <SettingsRow
294 {...searchableSetting("effort-display")}
295 description="Show reasoning effort in the composer as its name, such as High, or as bars."
296 resetAction={
297 settings.effortDisplay !== DEFAULT_UNIFIED_SETTINGS.effortDisplay ? (
298 <SettingResetButton
299 label="effort display"
300 onClick={() =>
301 updateSettings({ effortDisplay: DEFAULT_UNIFIED_SETTINGS.effortDisplay })
302 }
303 />
304 ) : null
305 }
306 control={
307 <div className="w-full sm:w-40">
308 <Select
309 value={settings.effortDisplay}
310 onValueChange={(value) => {
311 if (value === "bars" || value === "label")
312 updateSettings({ effortDisplay: value });
313 }}
314 >
315 <SelectTrigger size="sm" className="w-full min-w-0" aria-label="Effort display">
316 <SelectValue>{EFFORT_DISPLAY_LABELS[settings.effortDisplay]}</SelectValue>
317 </SelectTrigger>
318 <SelectPopup align="end" alignItemWithTrigger={false}>
319 <SelectItem value="label">Word (default)</SelectItem>
320 <SelectItem value="bars">Bars</SelectItem>
321 </SelectPopup>
322 </Select>
323 </div>
324 }
325 />
326 <SettingsRow
327 {...searchableSetting("math-rendering")}
328 description="Typeset LaTeX math, such as $x^2$ or \[x^2\], in messages and Markdown previews. The composer keeps TeX source editable."
329 resetAction={
330 settings.mathRenderingEnabled !== DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled ? (
331 <SettingResetButton
332 label="math rendering"
333 onClick={() =>
334 updateSettings({
335 mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled,
336 })
337 }
338 />
339 ) : null
340 }
341 control={
342 <Switch
343 checked={settings.mathRenderingEnabled}
344 onCheckedChange={(checked) =>
345 updateSettings({ mathRenderingEnabled: Boolean(checked) })
346 }
347 aria-label="Render math"
348 />
349 }
350 />
351 <AdvancedSettings
352 targetIds={[
353 searchableSetting("panel-animations").id,
354 searchableSetting("environment-identification").id,
355 // Environment identification's search result lands on this section.
356 "appearance-interface",
357 searchableSetting("composer-context").id,
358 ]}
359 >
360 <SettingsRow
361 {...searchableSetting("panel-animations")}
362 description="Set how fast panels open and close."
363 control={
364 <div className="grid w-full grid-cols-[5rem_minmax(0,1fr)] items-center gap-3 sm:w-auto sm:grid-cols-[7rem_13rem] sm:gap-4">
365 <PanelAnimationsPreview durationMs={settings.panelAnimationDurationMs} />
366 <div className="flex w-full items-center gap-3">
367 <output
368 className="min-w-16 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
369 htmlFor="panel-animation-duration"
370 >
371 {settings.panelAnimationDurationMs} ms
372 </output>
373 <input
374 aria-label="Panel animation duration"
375 className="settings-slider min-w-0 flex-1"
376 id="panel-animation-duration"
377 max={MAX_PANEL_ANIMATION_DURATION_MS}
378 min={MIN_PANEL_ANIMATION_DURATION_MS}
379 onChange={(event) => {
380 const panelAnimationDurationMs = Number(event.currentTarget.value);
381 if (
382 Number.isInteger(panelAnimationDurationMs) &&
383 panelAnimationDurationMs >= MIN_PANEL_ANIMATION_DURATION_MS &&
384 panelAnimationDurationMs <= MAX_PANEL_ANIMATION_DURATION_MS
385 ) {
386 updateSettings({ panelAnimationDurationMs });
387 }
388 }}
389 step={25}
390 style={panelAnimationDurationSliderStyle}
391 type="range"
392 value={settings.panelAnimationDurationMs}
393 />
394 </div>
395 </div>
396 }
397 resetAction={
398 settings.panelAnimationDurationMs !==
399 DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? (
400 <SettingResetButton
401 label="panel animations"
402 onClick={() =>
403 updateSettings({
404 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
405 })
406 }
407 />
408 ) : null
409 }
410 />
411 <SettingsRow
412 {...searchableSetting("environment-identification")}
413 description={environmentIdentificationDescription}
414 resetAction={
415 settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? (
416 <SettingResetButton
417 label="environment identification"
418 onClick={() =>
419 updateSettings({
420 environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
421 })
422 }
423 />
424 ) : null
425 }
426 control={
427 <Select
428 value={settings.environmentIdentificationMode}
429 onValueChange={(value) => {
430 const option = environmentIdentificationOptionList.find(
431 ([optionValue]) => optionValue === value,
432 );
433 if (option) updateSettings({ environmentIdentificationMode: option[0] });
434 }}
435 >
436 <SelectTrigger
437 size="sm"
438 className="w-full sm:w-40"
439 aria-label="Environment identification"
440 >
441 <SelectValue>{environmentIdentificationLabel}</SelectValue>
442 </SelectTrigger>
443 <SelectPopup align="end" alignItemWithTrigger={false}>
444 {environmentIdentificationOptionList.map(([value, label]) => (
445 <SelectItem hideIndicator key={value} value={value}>
446 {label}
447 </SelectItem>
448 ))}
449 </SelectPopup>
450 </Select>
451 }
452 />
453 <SettingsRow
454 {...searchableSetting("composer-context")}
455 description="Keep branch and worktree controls below the composer after a thread starts."
456 resetAction={
457 settings.persistComposerContextStrip !==
458 DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip ? (
459 <SettingResetButton
460 label="composer context"
461 onClick={() =>
462 updateSettings({
463 persistComposerContextStrip:
464 DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip,
465 })
466 }
467 />
468 ) : null
469 }
470 control={
471 <Switch
472 checked={settings.persistComposerContextStrip}
473 onCheckedChange={(checked) =>
474 updateSettings({ persistComposerContextStrip: Boolean(checked) })
475 }
476 aria-label="Keep composer context visible in active threads"
477 />
478 }
479 />
480 </AdvancedSettings>
481 </SettingsSection>
482
483 <TypographySection />
484 </SettingsPageContainer>
485 );
486}