From 4f849b9e5c25268cfc9e358a67012f5bea84b5b2 Mon Sep 17 00:00:00 2001 From: Kotti Date: Fri, 30 Oct 2026 12:21:18 +0100 Subject: [PATCH 1/2] feat(web): add a custom chat width setting Comfortable (46rem) and Wide (72rem) leave a large gap with no option in between. Chat width now offers Custom, which shows a slider from 46rem to 96rem in 2rem steps. The value is stored in rem so it follows the interface font size, and the slider shows the resulting width in px. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/appInit.tsx | 10 ++- .../settings/AppearanceSettingsPanel.tsx | 61 ++++++++++++++++++- .../components/settings/SettingsPanels.tsx | 1 + .../src/components/settings/settingsSearch.ts | 2 +- apps/web/src/index.css | 3 +- packages/contracts/src/settings.test.ts | 9 +++ packages/contracts/src/settings.ts | 14 ++++- 7 files changed, 93 insertions(+), 7 deletions(-) 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..6b58c24fb0d09 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,13 @@ 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 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); @@ -249,7 +259,12 @@ export function AppearanceSettingsPanel() { settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ( updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })} + onClick={() => + updateSettings({ + chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + chatCustomWidthRem: DEFAULT_UNIFIED_SETTINGS.chatCustomWidthRem, + }) + } /> ) : null } @@ -258,7 +273,12 @@ export function AppearanceSettingsPanel() { } - /> + > + {settings.chatWidth === "custom" ? ( +
+
+ + {settings.chatCustomWidthRem * settings.fontSizeInterface} px + + { + 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) { 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), From 5ece4170a0cc44e155b26c665869d960322d5628 Mon Sep 17 00:00:00 2001 From: Kotti Date: Fri, 30 Oct 2026 15:12:38 +0100 Subject: [PATCH 2/2] fix(web): reset a hidden custom chat width and announce its px value A non-default custom width stayed stored after switching back to a preset, but neither the row reset nor Restore defaults offered to clear it. Both now check the custom width too. The slider also exposes the displayed px width through aria-valuetext. Co-Authored-By: Claude Opus 5.5 --- .../src/components/settings/AppearanceSettingsPanel.tsx | 7 +++++-- apps/web/src/components/settings/SettingsPanels.tsx | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index 6b58c24fb0d09..f16efd9c7c791 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -125,6 +125,7 @@ export function AppearanceSettingsPanel() { 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`, @@ -256,7 +257,8 @@ 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 ? ( @@ -302,10 +304,11 @@ export function AppearanceSettingsPanel() { className="min-w-18 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground" htmlFor="chat-custom-width" > - {settings.chatCustomWidthRem * settings.fontSizeInterface} px + {chatCustomWidthLabel} 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"] : []),