| author | |
| committer | |
| log | 04c15f34b756aadbff4c93ff07a217fa2398e5d5 |
| tree | d88bef76b7614a42c4613ddc38cb4d5baf7a8822 |
| parent | 94c42162b443072ca59a0733c02ee54229a61fef |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | <div | 10061 | <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 && restingControlsHost | 6171 | {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"; |
| 42 | const TIMELINE_MINIMAP_ITEM_SPACING = 8; | 42 | const TIMELINE_MINIMAP_ITEM_SPACING = 8; |
| 43 | export const TIMELINE_MINIMAP_MIN_ITEMS = 2; | 43 | export const TIMELINE_MINIMAP_MIN_ITEMS = 2; |
| 44 | const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; | 44 | const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; |
| 45 | const TIMELINE_CONTENT_MAX_WIDTH = 768; | ||
| 46 | const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; | 45 | const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; |
| 47 | 46 | ||
| 48 | function singleToolCallLabel(entry: WorkLogEntry): string { | 47 | function 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 | } |
| 235 | 234 | ||
| 236 | export 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 | */ | ||
| 240 | function 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 | } | ||
| 240 | 246 | ||
| 241 | const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); | 247 | export 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 | } |
| 245 | 255 | ||
| 246 | const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; | 256 | const 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"; |
| 249 | 259 | ||
| 250 | /** | 260 | /** |
| 251 | * The minimap overlays the viewport's left edge while the content column is | 261 | * 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 | 262 | * 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 message | 263 | * 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 never | 264 | * 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 | */ |
| 257 | export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number { | 267 | export function resolveTimelineMinimapHitStripWidth( |
| 258 | if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { | 268 | viewportWidth: number, |
| 259 | return 0; | 269 | contentWidth: number, |
| 260 | } | 270 | ): number { |
| 261 | 271 | 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 | } |
| 272 | 280 | ||
| 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. | ||
| 283 | const 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 | */ | ||
| 290 | export 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 it | 295 | * 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 block | 296 | * 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"); |
| 576 | 577 | ||
| ... | @@ -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); | ||
| 661 | 667 | ||
| 662 | // No usable gutter (zoomed in / narrow pane): the strip must go inert | 668 | // 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 edge | 672 | // 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); | ||
| 674 | 692 | ||
| 675 | // The collapsed target stays narrow, but an open preview keeps its full | 693 | // 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"; |
| 239 | import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip"; | 240 | import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip"; |
| 240 | import { createContextPresentationRegistry } from "../contextPresentationRegistry"; | 241 | import { createContextPresentationRegistry } from "../contextPresentationRegistry"; |
| 241 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; | 242 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; |
| 243 | import { useClientSettings } from "~/hooks/useSettings"; | ||
| 242 | import type { ChatMarkdownContextReference } from "../ChatMarkdown"; | 244 | import type { ChatMarkdownContextReference } from "../ChatMarkdown"; |
| 243 | import { useMediaQuery } from "~/hooks/useMediaQuery"; | 245 | import { useMediaQuery } from "~/hooks/useMediaQuery"; |
| 244 | import { cn } from "~/lib/utils"; | 246 | import { 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 | <button | 352 | <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({ |
| 1107 | 1111 | ||
| 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 | }; |
| 1117 | 1129 | ||
| ... | @@ -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]); |
| 1128 | 1140 | ||
| 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 === null | 1425 | resolvedActiveIndex === null |
| 1410 | ? 0 | 1426 | ? 0 |
| ... | @@ -1485,6 +1501,7 @@ function TimelineMinimap({ | ... | @@ -1485,6 +1501,7 @@ function TimelineMinimap({ |
| 1485 | <TimelineMinimapNavigationButton | 1501 | <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 | <TimelineMinimapNavigationButton | 1617 | <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({ |
| 1613 | function TimelineMinimapNavigationButton({ | 1631 | function 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 | <span | 1650 | <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 | <div | 1883 | <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 { |
| 22 | import { | 22 | import { |
| 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; |
| 199 | 200 | ||
| 201 | const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = { | ||
| 202 | comfortable: "Comfortable", | ||
| 203 | wide: "Wide", | ||
| 204 | full: "Full", | ||
| 205 | }; | ||
| 206 | |||
| 200 | const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = { | 207 | const 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.diffColorScheme | 539 | ...(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.panelAnimationDurationMs | 543 | ...(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> |
| 1387 | 1429 | ||
| 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 | } |
| 1996 | 1996 | ||
| 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]); |
| 266 | 266 | ||
| 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 | }); |
| 303 | 303 | ||
| 304 | describe("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 | |||
| 304 | describe("ClientSettings load balancing", () => { | 321 | describe("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( |
| 291 | 291 | ||
| 292 | export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); | 292 | export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); |
| 293 | 293 | ||
| 294 | /** Maximum width of the chat timeline and composer on wide screens. */ | ||
| 295 | export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); | ||
| 296 | export type ChatWidth = typeof ChatWidth.Type; | ||
| 297 | |||
| 294 | export const ClientSettingsSchema = Schema.Struct({ | 298 | export 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), |