diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index b9312858e334b..3b852508b97a2 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -65,8 +65,16 @@ function startClientAppearance(): void { if (previous?.diffColorScheme !== settings.diffColorScheme) { root.dataset.diffColorScheme = settings.diffColorScheme; } - if (previous?.chatWidth !== settings.chatWidth) { + if ( + previous?.chatWidth !== settings.chatWidth || + previous.chatCustomWidthRem !== settings.chatCustomWidthRem + ) { root.dataset.chatWidth = settings.chatWidth; + if (settings.chatWidth === "custom") { + root.style.setProperty("--chat-content-max-width", `${settings.chatCustomWidthRem}rem`); + } else { + root.style.removeProperty("--chat-content-max-width"); + } } if (previous?.appearanceContrast !== settings.appearanceContrast) { applyAppearanceContrast(root, settings.appearanceContrast); diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index 1c5812a2bda0d..f16efd9c7c791 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -7,9 +7,11 @@ import { type EffortDisplay, type EnvironmentIdentificationMode, MAX_APPEARANCE_CONTRAST, + MAX_CHAT_CUSTOM_WIDTH_REM, MAX_GLASS_OPACITY, MAX_PANEL_ANIMATION_DURATION_MS, MIN_APPEARANCE_CONTRAST, + MIN_CHAT_CUSTOM_WIDTH_REM, MIN_GLASS_OPACITY, MIN_PANEL_ANIMATION_DURATION_MS, } from "@t3tools/contracts/settings"; @@ -76,6 +78,7 @@ const CHAT_WIDTH_LABELS: Record = { comfortable: "Comfortable", wide: "Wide", full: "Full", + custom: "Custom", }; const EFFORT_DISPLAY_LABELS: Record = { @@ -119,6 +122,14 @@ export function AppearanceSettingsPanel() { "--settings-slider-progress": `${appearanceContrastRatio * 100}%`, "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`, } as CSSProperties; + const chatCustomWidthRatio = + (settings.chatCustomWidthRem - MIN_CHAT_CUSTOM_WIDTH_REM) / + (MAX_CHAT_CUSTOM_WIDTH_REM - MIN_CHAT_CUSTOM_WIDTH_REM); + const chatCustomWidthLabel = `${settings.chatCustomWidthRem * settings.fontSizeInterface} px`; + const chatCustomWidthSliderStyle = { + "--settings-slider-progress": `${chatCustomWidthRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - chatCustomWidthRatio}rem`, + } as CSSProperties; const panelAnimationDurationRatio = (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) / (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS); @@ -246,10 +257,16 @@ export function AppearanceSettingsPanel() { {...searchableSetting("chat-width")} description="Set how wide messages and the composer can grow on large screens." resetAction={ - settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ( + settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth || + settings.chatCustomWidthRem !== DEFAULT_UNIFIED_SETTINGS.chatCustomWidthRem ? ( updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })} + onClick={() => + updateSettings({ + chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + chatCustomWidthRem: DEFAULT_UNIFIED_SETTINGS.chatCustomWidthRem, + }) + } /> ) : null } @@ -258,7 +275,12 @@ export function AppearanceSettingsPanel() { } - /> + > + {settings.chatWidth === "custom" ? ( +
+
+ + {chatCustomWidthLabel} + + { + const chatCustomWidthRem = Number(event.currentTarget.value); + if ( + Number.isInteger(chatCustomWidthRem) && + chatCustomWidthRem >= MIN_CHAT_CUSTOM_WIDTH_REM && + chatCustomWidthRem <= MAX_CHAT_CUSTOM_WIDTH_REM + ) { + updateSettings({ chatCustomWidthRem }); + } + }} + step={2} + style={chatCustomWidthSliderStyle} + type="range" + value={settings.chatCustomWidthRem} + /> +
+
+ ) : null} + void) { ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme ? ["Diff colors"] : []), - ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []), + ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth || + settings.chatCustomWidthRem !== DEFAULT_UNIFIED_SETTINGS.chatCustomWidthRem + ? ["Chat width"] + : []), ...(settings.effortDisplay !== DEFAULT_UNIFIED_SETTINGS.effortDisplay ? ["Effort display"] : []), @@ -609,6 +612,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + chatCustomWidthRem: DEFAULT_UNIFIED_SETTINGS.chatCustomWidthRem, effortDisplay: DEFAULT_UNIFIED_SETTINGS.effortDisplay, mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aabfd0300eda5..d6aa5774c8c5a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -323,7 +323,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "chat-width", title: "Chat width", to: "/settings/appearance", - searchTerms: ["wide full width column layout messages composer monitor"], + searchTerms: ["wide full custom width column layout messages composer monitor"], }, { id: "thread-details-items", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8acc312321b8f..52a48f86e3249 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2254,7 +2254,8 @@ code { } /* Chat width setting. Comfortable keeps the default --chat-content-max-width, - which sizes the timeline lane, the composer, and ChatCanvas's width probe. */ + which sizes the timeline lane, the composer, and ChatCanvas's width probe. + Custom sets it inline from appInit.tsx. */ :root[data-chat-width="wide"] { --chat-content-max-width: 72rem; } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 859644a54c72f..1e50c5ad1b41a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -291,6 +291,15 @@ describe("ClientSettings notifications", () => { }); }); +describe("ClientSettings chat width", () => { + it("keeps a saved preset when existing settings omit the custom width", () => { + expect(decodeClientSettings({ chatWidth: "wide" })).toMatchObject({ + chatWidth: "wide", + chatCustomWidthRem: 60, + }); + }); +}); + describe("ClientSettings quit confirmation", () => { it.each([ [true, "hold"], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 643d27296f34b..bfbc317c61459 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -324,9 +324,17 @@ export const LoadBalancingWeights = Schema.Record( export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); -export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); +export const ChatWidth = Schema.Literals(["comfortable", "wide", "full", "custom"]); export type ChatWidth = typeof ChatWidth.Type; +/** In rem so the cap scales with the interface font size. */ +export const MIN_CHAT_CUSTOM_WIDTH_REM = 46; +export const MAX_CHAT_CUSTOM_WIDTH_REM = 96; +export const ChatCustomWidthRem = Schema.Int.check( + Schema.isBetween({ minimum: MIN_CHAT_CUSTOM_WIDTH_REM, maximum: MAX_CHAT_CUSTOM_WIDTH_REM }), +); +const DEFAULT_CHAT_CUSTOM_WIDTH_REM: typeof ChatCustomWidthRem.Type = 60; + /** How the composer shows reasoning effort: the level's name, or rising bars. */ export const EffortDisplay = Schema.Literals(["label", "bars"]); export type EffortDisplay = typeof EffortDisplay.Type; @@ -357,6 +365,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))), + chatCustomWidthRem: ChatCustomWidthRem.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CHAT_CUSTOM_WIDTH_REM)), + ), effortDisplay: EffortDisplay.pipe(Schema.withDecodingDefault(Effect.succeed("label" as const))), loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -1721,6 +1732,7 @@ export const ClientSettingsPatch = Schema.Struct({ inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffColorScheme: Schema.optionalKey(DiffColorScheme), chatWidth: Schema.optionalKey(ChatWidth), + chatCustomWidthRem: Schema.optionalKey(ChatCustomWidthRem), effortDisplay: Schema.optionalKey(EffortDisplay), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),