diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index fafe24e439734d270aaf5f8a8eb1a8f94cf3abc5..d168c6db4e5fa4dd9b2a1e169f6e45a33cf2127c 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -6165,7 +6165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
if (isInsideRestingComposerControlScope(event.target)) return;
composerMentionDragHandlers.onDrop(event);
}}
- className="mx-auto w-full min-w-0 max-w-3xl"
+ className="mx-auto w-full min-w-0 max-w-(--chat-max-width)"
data-chat-composer-form="true"
>
{composerControlsInStrip && restingControlsHost
diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx
index bcb994085eb0d064e36efe35721e663eb3273b1e..83e94895eddca294eb09e6cf624eb85121cd9abd 100644
--- a/apps/web/src/components/chat/ComposerSurface.tsx
+++ b/apps/web/src/components/chat/ComposerSurface.tsx
@@ -13,7 +13,7 @@ function Shell({
data-slot="composer-shell"
data-with-context={contextStrip || undefined}
className={cn(
- "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-3xl",
+ "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-(--chat-max-width)",
"[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
"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)]",
"[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)]",
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 57ed45a89d1d361afdb6d0bf51c28f8ddaf80a63..21508a9663aef03c06e3fb64245867aed30513e7 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -42,7 +42,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
const TIMELINE_MINIMAP_ITEM_SPACING = 8;
export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
-const TIMELINE_CONTENT_MAX_WIDTH = 768;
const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
function singleToolCallLabel(entry: WorkLogEntry): string {
@@ -233,14 +232,25 @@ export function resolveTimelineMinimapCurrentIndex(input: {
return precedingIndex;
}
-export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {
- if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
- return false;
+/**
+ * Side gutter between the viewport edge and the centered content column.
+ * `contentWidth` is the rendered column width, which follows the Chat width
+ * setting, so callers measure it rather than assume a fixed maximum.
+ */
+function resolveTimelineSideGutter(viewportWidth: number, contentWidth: number): number {
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(contentWidth)) {
+ return 0;
}
+ return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2);
+}
- const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
- const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
- return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;
+export function resolveTimelineMinimapHasPersistentGutter(
+ viewportWidth: number,
+ contentWidth: number,
+): boolean {
+ return (
+ resolveTimelineSideGutter(viewportWidth, contentWidth) >= TIMELINE_MINIMAP_PERSISTENT_GUTTER
+ );
}
const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;
@@ -249,18 +259,16 @@ const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";
/**
* The minimap overlays the viewport's left edge while the content column is
- * centered, so the side gutter between them shrinks under browser zoom or a
- * narrow pane. A fixed-width hover strip would then sit on top of the message
+ * centered, so the side gutter between them shrinks under browser zoom, a
+ * narrow pane, or a wider Chat width setting. A fixed-width hover strip would then sit on top of the message
* text and swallow its pointer events. Cap the strip's width so it never
* extends past the gutter into the content column; 0 disables the strip.
*/
-export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number {
- if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
- return 0;
- }
-
- const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
- const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
+export function resolveTimelineMinimapHitStripWidth(
+ viewportWidth: number,
+ contentWidth: number,
+): number {
+ const sideGutter = resolveTimelineSideGutter(viewportWidth, contentWidth);
return Math.max(
0,
Math.min(
@@ -270,6 +278,19 @@ export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): numb
);
}
+// The prev/next buttons are centered 4px into the strip and 20px wide, so
+// their hitbox reaches 14px past the strip's left edge.
+const TIMELINE_MINIMAP_NAVIGATION_REACH = 14;
+
+/**
+ * The prev/next buttons hang outside the strip's height, so the strip's own
+ * width cap does not cover them. Keep them inert to the pointer unless the
+ * gutter can hold them; keyboard focus still reaches them.
+ */
+export function resolveTimelineMinimapNavigationInteractive(collapsedWidth: number): boolean {
+ return collapsedWidth >= TIMELINE_MINIMAP_NAVIGATION_REACH;
+}
+
/**
* Once the preview is open, keep the full preview and the space leading to it
* interactive. The collapsed strip remains gutter-capped so it cannot block
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index 016e621be1dd822ab306d2078678663fe9607e5d..a87dc2c2a444b7945f29778b081b6453d0c6748a 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -571,6 +571,7 @@ describe("MessagesTimeline", () => {
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
resolveTimelineMinimapInteractiveWidth,
+ resolveTimelineMinimapNavigationInteractive,
resolveTimelineMinimapTopPercent,
} = await import("./MessagesTimeline.logic");
@@ -655,22 +656,39 @@ describe("MessagesTimeline", () => {
itemBounds: [{ top: 80, height: 20 }],
}),
).toBeNull();
- expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
- expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
- expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
+ // Comfortable width: the column is capped at 768px.
+ expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false);
+ expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false);
+ expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true);
+ // Wider Chat width settings consume the gutter the minimap relies on.
+ expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true);
+ expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false);
+ expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false);
// No usable gutter (zoomed in / narrow pane): the strip must go inert
// instead of overlaying the centered content column.
- expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0);
- expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0);
+ expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0);
+ expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0);
// Partial gutter: strip shrinks to what fits between the viewport edge
// and the content column.
- expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14);
+ expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14);
// Full gutter: unchanged 40px-wide strip.
- expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40);
- expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40);
- expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0);
- expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0);
+ expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40);
+ expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40);
+ // Full Chat width: the column spans the viewport, so the strip is inert
+ // however wide the window gets.
+ expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0);
+ // Wide Chat width on a window just wider than the column: partial strip.
+ expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14);
+ expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0);
+ expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0);
+
+ // Prev/next buttons reach 14px past the strip's left edge; a narrower
+ // strip means they would sit on the content column.
+ expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true);
+ expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true);
+ expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false);
+ expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false);
// The collapsed target stays narrow, but an open preview keeps its full
// 20rem width plus the 2rem offset from the minimap rail interactive.
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 755657e794ab43ba1d79d4c731a2c5dae61f3409..f812f67f2bd4371a95df5c292772e0825accb4ba 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -189,6 +189,7 @@ import {
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
resolveTimelineMinimapInteractiveWidth,
+ resolveTimelineMinimapNavigationInteractive,
resolveTimelineMinimapTopPercent,
resolveWorkGroupScrollIndex,
shouldFollowWorkGroupAppend,
@@ -239,6 +240,7 @@ import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
import { createContextPresentationRegistry } from "../contextPresentationRegistry";
import { useOpenPrLink } from "~/lib/openPullRequestLink";
+import { useClientSettings } from "~/hooks/useSettings";
import type { ChatMarkdownContextReference } from "../ChatMarkdown";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { cn } from "~/lib/utils";
@@ -346,7 +348,7 @@ function TimelineLoadEarlierHeader({
}) {
return (