authorgravatar for otavio@ossystems.com.brOtavio Salvador <otavio@ossystems.com.br> 2026-09-26 00:47:20-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 22:47:20-05:00
log04c15f34b756aadbff4c93ff07a217fa2398e5d5
treed88bef76b7614a42c4613ddc38cb4d5baf7a8822
parent94c42162b443072ca59a0733c02ee54229a61fef
signature Signed by PGP key B5690EEEBB952194

feat(web): add chat width setting for wide screens (#11594)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

12 files changed, 185 insertions(+), 36 deletions(-)

apps/web/src/components/ChatView.tsx+1-1
......@@ -10060,7 +10060,7 @@ export default function ChatView(props: ChatViewProps) {
1006010060 >
1006110061 <div
1006210062 data-chat-composer-stack="true"
10063 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl"
10063 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-max-width)"
1006410064 >
1006510065 {isDraftHeroState ? (
1006610066 <div className="absolute inset-x-0 bottom-full z-0">
apps/web/src/components/chat/ChatComposer.tsx+1-1
......@@ -6165,7 +6165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
61656165 if (isInsideRestingComposerControlScope(event.target)) return;
61666166 composerMentionDragHandlers.onDrop(event);
61676167 }}
6168 className="mx-auto w-full min-w-0 max-w-3xl"
6168 className="mx-auto w-full min-w-0 max-w-(--chat-max-width)"
61696169 data-chat-composer-form="true"
61706170 >
61716171 {composerControlsInStrip && restingControlsHost
apps/web/src/components/chat/ComposerSurface.tsx+1-1
......@@ -13,7 +13,7 @@ function Shell({
1313 data-slot="composer-shell"
1414 data-with-context={contextStrip || undefined}
1515 className={cn(
16 "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-3xl",
16 "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-(--chat-max-width)",
1717 "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
1818 "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]",
1919 "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]",
apps/web/src/components/chat/MessagesTimeline.logic.ts+37-16
......@@ -42,7 +42,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
4242const TIMELINE_MINIMAP_ITEM_SPACING = 8;
4343export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
4444const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
45const TIMELINE_CONTENT_MAX_WIDTH = 768;
4645const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
4746
4847function singleToolCallLabel(entry: WorkLogEntry): string {
......@@ -233,14 +232,25 @@ export function resolveTimelineMinimapCurrentIndex(input: {
233232 return precedingIndex;
234233}
235234
236export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {
237 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
238 return false;
235/**
236 * Side gutter between the viewport edge and the centered content column.
237 * `contentWidth` is the rendered column width, which follows the Chat width
238 * setting, so callers measure it rather than assume a fixed maximum.
239 */
240function resolveTimelineSideGutter(viewportWidth: number, contentWidth: number): number {
241 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(contentWidth)) {
242 return 0;
239243 }
244 return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2);
245}
240246
241 const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
242 const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
243 return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;
247export function resolveTimelineMinimapHasPersistentGutter(
248 viewportWidth: number,
249 contentWidth: number,
250): boolean {
251 return (
252 resolveTimelineSideGutter(viewportWidth, contentWidth) >= TIMELINE_MINIMAP_PERSISTENT_GUTTER
253 );
244254}
245255
246256const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;
......@@ -249,18 +259,16 @@ const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";
249259
250260/**
251261 * The minimap overlays the viewport's left edge while the content column is
252 * centered, so the side gutter between them shrinks under browser zoom or a
253 * narrow pane. A fixed-width hover strip would then sit on top of the message
262 * centered, so the side gutter between them shrinks under browser zoom, a
263 * narrow pane, or a wider Chat width setting. A fixed-width hover strip would then sit on top of the message
254264 * text and swallow its pointer events. Cap the strip's width so it never
255265 * extends past the gutter into the content column; 0 disables the strip.
256266 */
257export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number {
258 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
259 return 0;
260 }
261
262 const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
263 const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
267export function resolveTimelineMinimapHitStripWidth(
268 viewportWidth: number,
269 contentWidth: number,
270): number {
271 const sideGutter = resolveTimelineSideGutter(viewportWidth, contentWidth);
264272 return Math.max(
265273 0,
266274 Math.min(
......@@ -270,6 +278,19 @@ export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): numb
270278 );
271279}
272280
281// The prev/next buttons are centered 4px into the strip and 20px wide, so
282// their hitbox reaches 14px past the strip's left edge.
283const TIMELINE_MINIMAP_NAVIGATION_REACH = 14;
284
285/**
286 * The prev/next buttons hang outside the strip's height, so the strip's own
287 * width cap does not cover them. Keep them inert to the pointer unless the
288 * gutter can hold them; keyboard focus still reaches them.
289 */
290export function resolveTimelineMinimapNavigationInteractive(collapsedWidth: number): boolean {
291 return collapsedWidth >= TIMELINE_MINIMAP_NAVIGATION_REACH;
292}
293
273294/**
274295 * Once the preview is open, keep the full preview and the space leading to it
275296 * interactive. The collapsed strip remains gutter-capped so it cannot block
apps/web/src/components/chat/MessagesTimeline.test.tsx+28-10
......@@ -571,6 +571,7 @@ describe("MessagesTimeline", () => {
571571 resolveTimelineMinimapHitStripWidth,
572572 resolveTimelineMinimapIndexFromPointer,
573573 resolveTimelineMinimapInteractiveWidth,
574 resolveTimelineMinimapNavigationInteractive,
574575 resolveTimelineMinimapTopPercent,
575576 } = await import("./MessagesTimeline.logic");
576577
......@@ -655,22 +656,39 @@ describe("MessagesTimeline", () => {
655656 itemBounds: [{ top: 80, height: 20 }],
656657 }),
657658 ).toBeNull();
658 expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
659 expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
660 expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
659 // Comfortable width: the column is capped at 768px.
660 expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false);
661 expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false);
662 expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true);
663 // Wider Chat width settings consume the gutter the minimap relies on.
664 expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true);
665 expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false);
666 expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false);
661667
662668 // No usable gutter (zoomed in / narrow pane): the strip must go inert
663669 // instead of overlaying the centered content column.
664 expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0);
665 expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0);
670 expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0);
671 expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0);
666672 // Partial gutter: strip shrinks to what fits between the viewport edge
667673 // and the content column.
668 expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14);
674 expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14);
669675 // Full gutter: unchanged 40px-wide strip.
670 expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40);
671 expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40);
672 expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0);
673 expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0);
676 expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40);
677 expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40);
678 // Full Chat width: the column spans the viewport, so the strip is inert
679 // however wide the window gets.
680 expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0);
681 // Wide Chat width on a window just wider than the column: partial strip.
682 expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14);
683 expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0);
684 expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0);
685
686 // Prev/next buttons reach 14px past the strip's left edge; a narrower
687 // strip means they would sit on the content column.
688 expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true);
689 expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true);
690 expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false);
691 expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false);
674692
675693 // The collapsed target stays narrow, but an open preview keeps its full
676694 // 20rem width plus the 2rem offset from the minimap rail interactive.
apps/web/src/components/chat/MessagesTimeline.tsx+28-7
......@@ -189,6 +189,7 @@ import {
189189 resolveTimelineMinimapHitStripWidth,
190190 resolveTimelineMinimapIndexFromPointer,
191191 resolveTimelineMinimapInteractiveWidth,
192 resolveTimelineMinimapNavigationInteractive,
192193 resolveTimelineMinimapTopPercent,
193194 resolveWorkGroupScrollIndex,
194195 shouldFollowWorkGroupAppend,
......@@ -239,6 +240,7 @@ import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
239240import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
240241import { createContextPresentationRegistry } from "../contextPresentationRegistry";
241242import { useOpenPrLink } from "~/lib/openPullRequestLink";
243import { useClientSettings } from "~/hooks/useSettings";
242244import type { ChatMarkdownContextReference } from "../ChatMarkdown";
243245import { useMediaQuery } from "~/hooks/useMediaQuery";
244246import { cn } from "~/lib/utils";
......@@ -346,7 +348,7 @@ function TimelineLoadEarlierHeader({
346348}) {
347349 return (
348350 <div className={fade ? "pt-(--workspace-titlebar-scroll-fade-height)" : "pt-3 sm:pt-4"}>
349 <div className="mx-auto w-full max-w-3xl pb-2">
351 <div className="mx-auto w-full max-w-(--chat-max-width) pb-2">
350352 <button
351353 type="button"
352354 onClick={onLoadEarlier}
......@@ -936,6 +938,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
936938 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
937939 null,
938940 );
941 // Re-measure the minimap gutter when the chat column changes width without a viewport resize.
942 const chatWidth = useClientSettings((settings) => settings.chatWidth);
939943 const {
940944 target: readyCitationRequest,
941945 positioning: citationPositioning,
......@@ -1107,11 +1111,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11071111
11081112 const measure = () => {
11091113 const viewportWidth = timelineViewportElement.getBoundingClientRect().width;
1110 const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth);
1114 // Without a mounted row, treat the column as full width so the strip stays inert.
1115 const contentWidth =
1116 timelineViewportElement
1117 .querySelector<HTMLElement>("[data-timeline-root]")
1118 ?.getBoundingClientRect().width ?? viewportWidth;
1119 const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(
1120 viewportWidth,
1121 contentWidth,
1122 );
11111123 setMinimapHasPersistentGutter((current) =>
11121124 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
11131125 );
1114 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
1126 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth));
11151127 reportContentOverflow();
11161128 };
11171129
......@@ -1124,7 +1136,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11241136 cancelAnimationFrame(frame);
11251137 observer.disconnect();
11261138 };
1127 }, [timelineViewportElement, rows.length, reportContentOverflow]);
1139 }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]);
11281140
11291141 const sharedState = useMemo<TimelineRowSharedState>(
11301142 () => ({
......@@ -1239,7 +1251,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
12391251 // from TimelineRowCtx, which propagates through LegendList's memo.
12401252 const renderItem = useCallback(
12411253 ({ item }: { item: MessagesTimelineRow }) => (
1242 <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-clip" data-timeline-root="true">
1254 <div
1255 className="mx-auto w-full min-w-0 max-w-(--chat-max-width) overflow-x-clip"
1256 data-timeline-root="true"
1257 >
12431258 <TimelineRowContent row={item} />
12441259 </div>
12451260 ),
......@@ -1405,6 +1420,7 @@ function TimelineMinimap({
14051420 ),
14061421 [items, resolvedActiveIndex],
14071422 );
1423 const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth);
14081424 const activeTopPercent =
14091425 resolvedActiveIndex === null
14101426 ? 0
......@@ -1485,6 +1501,7 @@ function TimelineMinimap({
14851501 <TimelineMinimapNavigationButton
14861502 direction="previous"
14871503 disabled={previousItem === null}
1504 interactive={navigationInteractive}
14881505 onClick={() => {
14891506 if (previousItem) onSelect(previousItem);
14901507 }}
......@@ -1600,6 +1617,7 @@ function TimelineMinimap({
16001617 <TimelineMinimapNavigationButton
16011618 direction="next"
16021619 disabled={nextItem === null}
1620 interactive={navigationInteractive}
16031621 onClick={() => {
16041622 if (nextItem) onSelect(nextItem);
16051623 }}
......@@ -1613,10 +1631,12 @@ function TimelineMinimap({
16131631function TimelineMinimapNavigationButton({
16141632 direction,
16151633 disabled,
1634 interactive,
16161635 onClick,
16171636}: {
16181637 direction: "previous" | "next";
16191638 disabled: boolean;
1639 interactive: boolean;
16201640 onClick: () => void;
16211641}) {
16221642 const previous = direction === "previous";
......@@ -1629,7 +1649,8 @@ function TimelineMinimapNavigationButton({
16291649 render={
16301650 <span
16311651 className={cn(
1632 "absolute left-1 z-10 inline-flex -translate-x-1/2 opacity-0 pointer-events-auto transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
1652 "absolute left-1 z-10 inline-flex -translate-x-1/2 opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
1653 interactive ? "pointer-events-auto" : "pointer-events-none",
16331654 previous ? "bottom-[calc(100%+2px)]" : "top-[calc(100%+2px)]",
16341655 )}
16351656 />
......@@ -1862,7 +1883,7 @@ function ContextCompactionTimelineRow({
18621883 <div
18631884 role="separator"
18641885 aria-label={row.label}
1865 className="mx-auto flex w-full max-w-3xl items-center gap-3 py-1 text-muted-foreground text-xs"
1886 className="mx-auto flex w-full max-w-(--chat-max-width) items-center gap-3 py-1 text-muted-foreground text-xs"
18661887 >
18671888 <span className="h-px flex-1 bg-border/70" />
18681889 <span className="flex shrink-0 items-center gap-1.5">
apps/web/src/components/settings/SettingsPanels.tsx+42
......@@ -22,6 +22,7 @@ import {
2222import {
2323 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
2424 DEFAULT_UNIFIED_SETTINGS,
25 type ChatWidth,
2526 type DiffLayout,
2627 type EnvironmentIdentificationMode,
2728 MAX_APPEARANCE_CONTRAST,
......@@ -197,6 +198,12 @@ const TIMESTAMP_FORMAT_LABELS = {
197198 "24-hour": "24-hour",
198199} as const;
199200
201const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
202 comfortable: "Comfortable",
203 wide: "Wide",
204 full: "Full",
205};
206
200207const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
201208 stacked: "Stacked",
202209 split: "Split",
......@@ -532,6 +539,7 @@ export function useSettingsRestore(onRestored?: () => void) {
532539 ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
533540 ? ["Diff colors"]
534541 : []),
542 ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []),
535543 ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
536544 ? ["Panel animations"]
537545 : []),
......@@ -641,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) {
641649 settings.browserAutoShowFloatingPreview,
642650 settings.appearanceContrast,
643651 settings.diffColorScheme,
652 settings.chatWidth,
644653 settings.enableAgentBrowserAccess,
645654 settings.confirmQuit,
646655 settings.confirmThreadArchive,
......@@ -752,6 +761,7 @@ export function useSettingsRestore(onRestored?: () => void) {
752761 updateSettings({
753762 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
754763 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
764 chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
755765 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
756766 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
757767 inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
......@@ -1383,6 +1393,38 @@ export function AppearanceSettingsPanel() {
13831393 </div>
13841394 }
13851395 />
1396 <SettingsRow
1397 {...searchableSetting("chat-width")}
1398 description="Set how wide messages and the composer can grow on large screens."
1399 resetAction={
1400 settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? (
1401 <SettingResetButton
1402 label="chat width"
1403 onClick={() => updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })}
1404 />
1405 ) : null
1406 }
1407 control={
1408 <div className="w-full sm:w-40">
1409 <Select
1410 value={settings.chatWidth}
1411 onValueChange={(value) => {
1412 if (value === "comfortable" || value === "wide" || value === "full")
1413 updateSettings({ chatWidth: value });
1414 }}
1415 >
1416 <SelectTrigger size="sm" className="w-full min-w-0" aria-label="Chat width">
1417 <SelectValue>{CHAT_WIDTH_LABELS[settings.chatWidth]}</SelectValue>
1418 </SelectTrigger>
1419 <SelectPopup align="end" alignItemWithTrigger={false}>
1420 <SelectItem value="comfortable">Comfortable (default)</SelectItem>
1421 <SelectItem value="wide">Wide</SelectItem>
1422 <SelectItem value="full">Full</SelectItem>
1423 </SelectPopup>
1424 </Select>
1425 </div>
1426 }
1427 />
13861428 </SettingsSection>
13871429
13881430 <SettingsSection id="motion" title="Motion">
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -211,6 +211,12 @@ export const SETTINGS_SEARCH_ITEMS = [
211211 to: "/settings/appearance",
212212 searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"],
213213 },
214 {
215 id: "chat-width",
216 title: "Chat width",
217 to: "/settings/appearance",
218 searchTerms: ["wide full width column layout messages composer monitor"],
219 },
214220 {
215221 id: "panel-animations",
216222 title: "Panel animations",
apps/web/src/index.css+13
......@@ -1994,6 +1994,19 @@ code {
19941994 --diff-deletion-foreground: light-dark(var(--color-red-600), var(--color-red-400));
19951995}
19961996
1997/* Chat timeline and composer column width, set from the Chat width setting. */
1998:root {
1999 --chat-max-width: 48rem;
2000}
2001
2002:root[data-chat-width="wide"] {
2003 --chat-max-width: 72rem;
2004}
2005
2006:root[data-chat-width="full"] {
2007 --chat-max-width: 100%;
2008}
2009
19972010:root[data-diff-color-scheme="blue-orange"] {
19982011 --diff-addition: light-dark(var(--color-blue-600), var(--color-blue-400));
19992012 --diff-deletion: light-dark(var(--color-orange-600), var(--color-orange-400));
apps/web/src/routes/__root.tsx+5
......@@ -264,6 +264,11 @@ function ContrastAppearanceSync() {
264264 document.documentElement.dataset.diffColorScheme = diffColorScheme;
265265 }, [diffColorScheme]);
266266
267 const chatWidth = useClientSettings((settings) => settings.chatWidth);
268 useEffect(() => {
269 document.documentElement.dataset.chatWidth = chatWidth;
270 }, [chatWidth]);
271
267272 useEffect(() => {
268273 applyAppearanceContrast(document.documentElement, appearanceContrast);
269274 }, [appearanceContrast]);
packages/contracts/src/settings.test.ts+17
......@@ -301,6 +301,23 @@ describe("ClientSettings diff colors", () => {
301301 });
302302});
303303
304describe("ClientSettings chat width", () => {
305 it("keeps the comfortable width for existing settings without a saved width", () => {
306 expect(decodeClientSettings({}).chatWidth).toBe("comfortable");
307 });
308
309 it.each(["comfortable", "wide", "full"])("round-trips the %s width", (chatWidth) => {
310 const settings = decodeClientSettings({ chatWidth });
311 expect(encodeClientSettings(settings).chatWidth).toBe(chatWidth);
312 expect(decodeClientSettingsPatch({ chatWidth }).chatWidth).toBe(chatWidth);
313 });
314
315 it("rejects unsupported widths", () => {
316 expect(() => decodeClientSettings({ chatWidth: "huge" })).toThrow();
317 expect(() => decodeClientSettingsPatch({ chatWidth: "huge" })).toThrow();
318 });
319});
320
304321describe("ClientSettings load balancing", () => {
305322 it("requires opt-in when settings are new or omit load balancing", () => {
306323 expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);
packages/contracts/src/settings.ts+6
......@@ -291,6 +291,10 @@ export const LoadBalancingWeights = Schema.Record(
291291
292292export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
293293
294/** Maximum width of the chat timeline and composer on wide screens. */
295export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);
296export type ChatWidth = typeof ChatWidth.Type;
297
294298export const ClientSettingsSchema = Schema.Struct({
295299 notificationMode: NotificationMode.pipe(
296300 Schema.withDecodingDefault(Effect.succeed("off" as const)),
......@@ -299,6 +303,7 @@ export const ClientSettingsSchema = Schema.Struct({
299303 diffColorScheme: DiffColorScheme.pipe(
300304 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
301305 ),
306 chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))),
302307 loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
303308 loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
304309 appearanceContrast: AppearanceContrast.pipe(
......@@ -1566,6 +1571,7 @@ export const ClientSettingsPatch = Schema.Struct({
15661571 notificationMode: Schema.optionalKey(NotificationMode),
15671572 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
15681573 diffColorScheme: Schema.optionalKey(DiffColorScheme),
1574 chatWidth: Schema.optionalKey(ChatWidth),
15691575 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
15701576 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
15711577 appearanceContrast: Schema.optionalKey(AppearanceContrast),