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) {...@@ -10060,7 +10060,7 @@ export default function ChatView(props: ChatViewProps) {
10060 >10060 >
10061 <div10061 <div
10062 data-chat-composer-stack="true"10062 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)"
10064 >10064 >
10065 {isDraftHeroState ? (10065 {isDraftHeroState ? (
10066 <div className="absolute inset-x-0 bottom-full z-0">10066 <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)...@@ -6165,7 +6165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6165 if (isInsideRestingComposerControlScope(event.target)) return;6165 if (isInsideRestingComposerControlScope(event.target)) return;
6166 composerMentionDragHandlers.onDrop(event);6166 composerMentionDragHandlers.onDrop(event);
6167 }}6167 }}
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)"
6169 data-chat-composer-form="true"6169 data-chat-composer-form="true"
6170 >6170 >
6171 {composerControlsInStrip && restingControlsHost6171 {composerControlsInStrip && restingControlsHost
apps/web/src/components/chat/ComposerSurface.tsx+1-1
...@@ -13,7 +13,7 @@ function Shell({...@@ -13,7 +13,7 @@ function Shell({
13 data-slot="composer-shell"13 data-slot="composer-shell"
14 data-with-context={contextStrip || undefined}14 data-with-context={contextStrip || undefined}
15 className={cn(15 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)",
17 "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",17 "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
18 "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)]",18 "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)]",
19 "[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)]",19 "[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";...@@ -42,7 +42,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
42const TIMELINE_MINIMAP_ITEM_SPACING = 8;42const TIMELINE_MINIMAP_ITEM_SPACING = 8;
43export const TIMELINE_MINIMAP_MIN_ITEMS = 2;43export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
44const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";44const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
45const TIMELINE_CONTENT_MAX_WIDTH = 768;
46const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;45const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
4746
48function singleToolCallLabel(entry: WorkLogEntry): string {47function singleToolCallLabel(entry: WorkLogEntry): string {
...@@ -233,14 +232,25 @@ export function resolveTimelineMinimapCurrentIndex(input: {...@@ -233,14 +232,25 @@ export function resolveTimelineMinimapCurrentIndex(input: {
233 return precedingIndex;232 return precedingIndex;
234}233}
235234
236export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {235/**
237 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {236 * Side gutter between the viewport edge and the centered content column.
238 return false;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;
239 }243 }
244 return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2);
245}
240246
241 const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);247export function resolveTimelineMinimapHasPersistentGutter(
242 const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);248 viewportWidth: number,
243 return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;249 contentWidth: number,
250): boolean {
251 return (
252 resolveTimelineSideGutter(viewportWidth, contentWidth) >= TIMELINE_MINIMAP_PERSISTENT_GUTTER
253 );
244}254}
245255
246const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;256const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;
...@@ -249,18 +259,16 @@ const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";...@@ -249,18 +259,16 @@ const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";
249259
250/**260/**
251 * The minimap overlays the viewport's left edge while the content column is261 * 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 a262 * centered, so the side gutter between them shrinks under browser zoom, a
253 * narrow pane. A fixed-width hover strip would then sit on top of the message263 * narrow pane, or a wider Chat width setting. A fixed-width hover strip would then sit on top of the message
254 * text and swallow its pointer events. Cap the strip's width so it never264 * text and swallow its pointer events. Cap the strip's width so it never
255 * extends past the gutter into the content column; 0 disables the strip.265 * extends past the gutter into the content column; 0 disables the strip.
256 */266 */
257export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number {267export function resolveTimelineMinimapHitStripWidth(
258 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {268 viewportWidth: number,
259 return 0;269 contentWidth: number,
260 }270): number {
261271 const sideGutter = resolveTimelineSideGutter(viewportWidth, contentWidth);
262 const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
263 const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
264 return Math.max(272 return Math.max(
265 0,273 0,
266 Math.min(274 Math.min(
...@@ -270,6 +278,19 @@ export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): numb...@@ -270,6 +278,19 @@ export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): numb
270 );278 );
271}279}
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
273/**294/**
274 * Once the preview is open, keep the full preview and the space leading to it295 * Once the preview is open, keep the full preview and the space leading to it
275 * interactive. The collapsed strip remains gutter-capped so it cannot block296 * 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", () => {...@@ -571,6 +571,7 @@ describe("MessagesTimeline", () => {
571 resolveTimelineMinimapHitStripWidth,571 resolveTimelineMinimapHitStripWidth,
572 resolveTimelineMinimapIndexFromPointer,572 resolveTimelineMinimapIndexFromPointer,
573 resolveTimelineMinimapInteractiveWidth,573 resolveTimelineMinimapInteractiveWidth,
574 resolveTimelineMinimapNavigationInteractive,
574 resolveTimelineMinimapTopPercent,575 resolveTimelineMinimapTopPercent,
575 } = await import("./MessagesTimeline.logic");576 } = await import("./MessagesTimeline.logic");
576577
...@@ -655,22 +656,39 @@ describe("MessagesTimeline", () => {...@@ -655,22 +656,39 @@ describe("MessagesTimeline", () => {
655 itemBounds: [{ top: 80, height: 20 }],656 itemBounds: [{ top: 80, height: 20 }],
656 }),657 }),
657 ).toBeNull();658 ).toBeNull();
658 expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);659 // Comfortable width: the column is capped at 768px.
659 expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);660 expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false);
660 expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);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
662 // No usable gutter (zoomed in / narrow pane): the strip must go inert668 // No usable gutter (zoomed in / narrow pane): the strip must go inert
663 // instead of overlaying the centered content column.669 // instead of overlaying the centered content column.
664 expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0);670 expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0);
665 expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0);671 expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0);
666 // Partial gutter: strip shrinks to what fits between the viewport edge672 // Partial gutter: strip shrinks to what fits between the viewport edge
667 // and the content column.673 // and the content column.
668 expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14);674 expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14);
669 // Full gutter: unchanged 40px-wide strip.675 // Full gutter: unchanged 40px-wide strip.
670 expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40);676 expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40);
671 expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40);677 expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40);
672 expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0);678 // Full Chat width: the column spans the viewport, so the strip is inert
673 expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0);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
675 // The collapsed target stays narrow, but an open preview keeps its full693 // The collapsed target stays narrow, but an open preview keeps its full
676 // 20rem width plus the 2rem offset from the minimap rail interactive.694 // 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 {...@@ -189,6 +189,7 @@ import {
189 resolveTimelineMinimapHitStripWidth,189 resolveTimelineMinimapHitStripWidth,
190 resolveTimelineMinimapIndexFromPointer,190 resolveTimelineMinimapIndexFromPointer,
191 resolveTimelineMinimapInteractiveWidth,191 resolveTimelineMinimapInteractiveWidth,
192 resolveTimelineMinimapNavigationInteractive,
192 resolveTimelineMinimapTopPercent,193 resolveTimelineMinimapTopPercent,
193 resolveWorkGroupScrollIndex,194 resolveWorkGroupScrollIndex,
194 shouldFollowWorkGroupAppend,195 shouldFollowWorkGroupAppend,
...@@ -239,6 +240,7 @@ import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";...@@ -239,6 +240,7 @@ import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
239import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";240import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
240import { createContextPresentationRegistry } from "../contextPresentationRegistry";241import { createContextPresentationRegistry } from "../contextPresentationRegistry";
241import { useOpenPrLink } from "~/lib/openPullRequestLink";242import { useOpenPrLink } from "~/lib/openPullRequestLink";
243import { useClientSettings } from "~/hooks/useSettings";
242import type { ChatMarkdownContextReference } from "../ChatMarkdown";244import type { ChatMarkdownContextReference } from "../ChatMarkdown";
243import { useMediaQuery } from "~/hooks/useMediaQuery";245import { useMediaQuery } from "~/hooks/useMediaQuery";
244import { cn } from "~/lib/utils";246import { cn } from "~/lib/utils";
...@@ -346,7 +348,7 @@ function TimelineLoadEarlierHeader({...@@ -346,7 +348,7 @@ function TimelineLoadEarlierHeader({
346}) {348}) {
347 return (349 return (
348 <div className={fade ? "pt-(--workspace-titlebar-scroll-fade-height)" : "pt-3 sm:pt-4"}>350 <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">
350 <button352 <button
351 type="button"353 type="button"
352 onClick={onLoadEarlier}354 onClick={onLoadEarlier}
...@@ -936,6 +938,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -936,6 +938,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
936 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(938 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
937 null,939 null,
938 );940 );
941 // Re-measure the minimap gutter when the chat column changes width without a viewport resize.
942 const chatWidth = useClientSettings((settings) => settings.chatWidth);
939 const {943 const {
940 target: readyCitationRequest,944 target: readyCitationRequest,
941 positioning: citationPositioning,945 positioning: citationPositioning,
...@@ -1107,11 +1111,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1107,11 +1111,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11071111
1108 const measure = () => {1112 const measure = () => {
1109 const viewportWidth = timelineViewportElement.getBoundingClientRect().width;1113 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 );
1111 setMinimapHasPersistentGutter((current) =>1123 setMinimapHasPersistentGutter((current) =>
1112 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,1124 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
1113 );1125 );
1114 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));1126 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth));
1115 reportContentOverflow();1127 reportContentOverflow();
1116 };1128 };
11171129
...@@ -1124,7 +1136,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1124,7 +1136,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1124 cancelAnimationFrame(frame);1136 cancelAnimationFrame(frame);
1125 observer.disconnect();1137 observer.disconnect();
1126 };1138 };
1127 }, [timelineViewportElement, rows.length, reportContentOverflow]);1139 }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]);
11281140
1129 const sharedState = useMemo<TimelineRowSharedState>(1141 const sharedState = useMemo<TimelineRowSharedState>(
1130 () => ({1142 () => ({
...@@ -1239,7 +1251,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1239,7 +1251,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1239 // from TimelineRowCtx, which propagates through LegendList's memo.1251 // from TimelineRowCtx, which propagates through LegendList's memo.
1240 const renderItem = useCallback(1252 const renderItem = useCallback(
1241 ({ item }: { item: MessagesTimelineRow }) => (1253 ({ 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 >
1243 <TimelineRowContent row={item} />1258 <TimelineRowContent row={item} />
1244 </div>1259 </div>
1245 ),1260 ),
...@@ -1405,6 +1420,7 @@ function TimelineMinimap({...@@ -1405,6 +1420,7 @@ function TimelineMinimap({
1405 ),1420 ),
1406 [items, resolvedActiveIndex],1421 [items, resolvedActiveIndex],
1407 );1422 );
1423 const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth);
1408 const activeTopPercent =1424 const activeTopPercent =
1409 resolvedActiveIndex === null1425 resolvedActiveIndex === null
1410 ? 01426 ? 0
...@@ -1485,6 +1501,7 @@ function TimelineMinimap({...@@ -1485,6 +1501,7 @@ function TimelineMinimap({
1485 <TimelineMinimapNavigationButton1501 <TimelineMinimapNavigationButton
1486 direction="previous"1502 direction="previous"
1487 disabled={previousItem === null}1503 disabled={previousItem === null}
1504 interactive={navigationInteractive}
1488 onClick={() => {1505 onClick={() => {
1489 if (previousItem) onSelect(previousItem);1506 if (previousItem) onSelect(previousItem);
1490 }}1507 }}
...@@ -1600,6 +1617,7 @@ function TimelineMinimap({...@@ -1600,6 +1617,7 @@ function TimelineMinimap({
1600 <TimelineMinimapNavigationButton1617 <TimelineMinimapNavigationButton
1601 direction="next"1618 direction="next"
1602 disabled={nextItem === null}1619 disabled={nextItem === null}
1620 interactive={navigationInteractive}
1603 onClick={() => {1621 onClick={() => {
1604 if (nextItem) onSelect(nextItem);1622 if (nextItem) onSelect(nextItem);
1605 }}1623 }}
...@@ -1613,10 +1631,12 @@ function TimelineMinimap({...@@ -1613,10 +1631,12 @@ function TimelineMinimap({
1613function TimelineMinimapNavigationButton({1631function TimelineMinimapNavigationButton({
1614 direction,1632 direction,
1615 disabled,1633 disabled,
1634 interactive,
1616 onClick,1635 onClick,
1617}: {1636}: {
1618 direction: "previous" | "next";1637 direction: "previous" | "next";
1619 disabled: boolean;1638 disabled: boolean;
1639 interactive: boolean;
1620 onClick: () => void;1640 onClick: () => void;
1621}) {1641}) {
1622 const previous = direction === "previous";1642 const previous = direction === "previous";
...@@ -1629,7 +1649,8 @@ function TimelineMinimapNavigationButton({...@@ -1629,7 +1649,8 @@ function TimelineMinimapNavigationButton({
1629 render={1649 render={
1630 <span1650 <span
1631 className={cn(1651 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",
1633 previous ? "bottom-[calc(100%+2px)]" : "top-[calc(100%+2px)]",1654 previous ? "bottom-[calc(100%+2px)]" : "top-[calc(100%+2px)]",
1634 )}1655 )}
1635 />1656 />
...@@ -1862,7 +1883,7 @@ function ContextCompactionTimelineRow({...@@ -1862,7 +1883,7 @@ function ContextCompactionTimelineRow({
1862 <div1883 <div
1863 role="separator"1884 role="separator"
1864 aria-label={row.label}1885 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"
1866 >1887 >
1867 <span className="h-px flex-1 bg-border/70" />1888 <span className="h-px flex-1 bg-border/70" />
1868 <span className="flex shrink-0 items-center gap-1.5">1889 <span className="flex shrink-0 items-center gap-1.5">
apps/web/src/components/settings/SettingsPanels.tsx+42
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22import {22import {
23 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,23 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
24 DEFAULT_UNIFIED_SETTINGS,24 DEFAULT_UNIFIED_SETTINGS,
25 type ChatWidth,
25 type DiffLayout,26 type DiffLayout,
26 type EnvironmentIdentificationMode,27 type EnvironmentIdentificationMode,
27 MAX_APPEARANCE_CONTRAST,28 MAX_APPEARANCE_CONTRAST,
...@@ -197,6 +198,12 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -197,6 +198,12 @@ const TIMESTAMP_FORMAT_LABELS = {
197 "24-hour": "24-hour",198 "24-hour": "24-hour",
198} as const;199} as const;
199200
201const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
202 comfortable: "Comfortable",
203 wide: "Wide",
204 full: "Full",
205};
206
200const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {207const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
201 stacked: "Stacked",208 stacked: "Stacked",
202 split: "Split",209 split: "Split",
...@@ -532,6 +539,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -532,6 +539,7 @@ export function useSettingsRestore(onRestored?: () => void) {
532 ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme539 ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
533 ? ["Diff colors"]540 ? ["Diff colors"]
534 : []),541 : []),
542 ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []),
535 ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs543 ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
536 ? ["Panel animations"]544 ? ["Panel animations"]
537 : []),545 : []),
...@@ -641,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -641,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) {
641 settings.browserAutoShowFloatingPreview,649 settings.browserAutoShowFloatingPreview,
642 settings.appearanceContrast,650 settings.appearanceContrast,
643 settings.diffColorScheme,651 settings.diffColorScheme,
652 settings.chatWidth,
644 settings.enableAgentBrowserAccess,653 settings.enableAgentBrowserAccess,
645 settings.confirmQuit,654 settings.confirmQuit,
646 settings.confirmThreadArchive,655 settings.confirmThreadArchive,
...@@ -752,6 +761,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -752,6 +761,7 @@ export function useSettingsRestore(onRestored?: () => void) {
752 updateSettings({761 updateSettings({
753 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,762 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
754 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,763 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
764 chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
755 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,765 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
756 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,766 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
757 inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,767 inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
...@@ -1383,6 +1393,38 @@ export function AppearanceSettingsPanel() {...@@ -1383,6 +1393,38 @@ export function AppearanceSettingsPanel() {
1383 </div>1393 </div>
1384 }1394 }
1385 />1395 />
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 />
1386 </SettingsSection>1428 </SettingsSection>
13871429
1388 <SettingsSection id="motion" title="Motion">1430 <SettingsSection id="motion" title="Motion">
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -211,6 +211,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -211,6 +211,12 @@ export const SETTINGS_SEARCH_ITEMS = [
211 to: "/settings/appearance",211 to: "/settings/appearance",
212 searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"],212 searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"],
213 },213 },
214 {
215 id: "chat-width",
216 title: "Chat width",
217 to: "/settings/appearance",
218 searchTerms: ["wide full width column layout messages composer monitor"],
219 },
214 {220 {
215 id: "panel-animations",221 id: "panel-animations",
216 title: "Panel animations",222 title: "Panel animations",
apps/web/src/index.css+13
...@@ -1994,6 +1994,19 @@ code {...@@ -1994,6 +1994,19 @@ code {
1994 --diff-deletion-foreground: light-dark(var(--color-red-600), var(--color-red-400));1994 --diff-deletion-foreground: light-dark(var(--color-red-600), var(--color-red-400));
1995}1995}
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
1997:root[data-diff-color-scheme="blue-orange"] {2010:root[data-diff-color-scheme="blue-orange"] {
1998 --diff-addition: light-dark(var(--color-blue-600), var(--color-blue-400));2011 --diff-addition: light-dark(var(--color-blue-600), var(--color-blue-400));
1999 --diff-deletion: light-dark(var(--color-orange-600), var(--color-orange-400));2012 --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() {...@@ -264,6 +264,11 @@ function ContrastAppearanceSync() {
264 document.documentElement.dataset.diffColorScheme = diffColorScheme;264 document.documentElement.dataset.diffColorScheme = diffColorScheme;
265 }, [diffColorScheme]);265 }, [diffColorScheme]);
266266
267 const chatWidth = useClientSettings((settings) => settings.chatWidth);
268 useEffect(() => {
269 document.documentElement.dataset.chatWidth = chatWidth;
270 }, [chatWidth]);
271
267 useEffect(() => {272 useEffect(() => {
268 applyAppearanceContrast(document.documentElement, appearanceContrast);273 applyAppearanceContrast(document.documentElement, appearanceContrast);
269 }, [appearanceContrast]);274 }, [appearanceContrast]);
packages/contracts/src/settings.test.ts+17
...@@ -301,6 +301,23 @@ describe("ClientSettings diff colors", () => {...@@ -301,6 +301,23 @@ describe("ClientSettings diff colors", () => {
301 });301 });
302});302});
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
304describe("ClientSettings load balancing", () => {321describe("ClientSettings load balancing", () => {
305 it("requires opt-in when settings are new or omit load balancing", () => {322 it("requires opt-in when settings are new or omit load balancing", () => {
306 expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);323 expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);
packages/contracts/src/settings.ts+6
...@@ -291,6 +291,10 @@ export const LoadBalancingWeights = Schema.Record(...@@ -291,6 +291,10 @@ export const LoadBalancingWeights = Schema.Record(
291291
292export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);292export 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
294export const ClientSettingsSchema = Schema.Struct({298export const ClientSettingsSchema = Schema.Struct({
295 notificationMode: NotificationMode.pipe(299 notificationMode: NotificationMode.pipe(
296 Schema.withDecodingDefault(Effect.succeed("off" as const)),300 Schema.withDecodingDefault(Effect.succeed("off" as const)),
...@@ -299,6 +303,7 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -299,6 +303,7 @@ export const ClientSettingsSchema = Schema.Struct({
299 diffColorScheme: DiffColorScheme.pipe(303 diffColorScheme: DiffColorScheme.pipe(
300 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),304 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
301 ),305 ),
306 chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))),
302 loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),307 loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
303 loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),308 loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
304 appearanceContrast: AppearanceContrast.pipe(309 appearanceContrast: AppearanceContrast.pipe(
...@@ -1566,6 +1571,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1566,6 +1571,7 @@ export const ClientSettingsPatch = Schema.Struct({
1566 notificationMode: Schema.optionalKey(NotificationMode),1571 notificationMode: Schema.optionalKey(NotificationMode),
1567 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),1572 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
1568 diffColorScheme: Schema.optionalKey(DiffColorScheme),1573 diffColorScheme: Schema.optionalKey(DiffColorScheme),
1574 chatWidth: Schema.optionalKey(ChatWidth),
1569 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),1575 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
1570 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),1576 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
1571 appearanceContrast: Schema.optionalKey(AppearanceContrast),1577 appearanceContrast: Schema.optionalKey(AppearanceContrast),