From 5038d24617b2bc99e7b5183d7724a3e96f5c6c7a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:35:44 +1000 Subject: [PATCH 1/2] fix(web): size the timeline minimap gutter from the rendered chat column The minimap gutter and hover-strip helpers assumed the chat column is capped at 768px. With the Wide or Full chat width, the real gutter is narrower or gone, so the 40px hover strip sat on top of the message text and swallowed its pointer events. Measure the rendered column instead and re-measure when the Chat width setting changes. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/MessagesTimeline.logic.ts | 40 +++++++++++-------- .../components/chat/MessagesTimeline.test.tsx | 30 +++++++++----- .../src/components/chat/MessagesTimeline.tsx | 17 ++++++-- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8dcb539fb024..f9c1ac4931a8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -34,7 +34,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 { @@ -225,14 +224,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; @@ -241,18 +251,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( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6991f4432594..74fb6d5362cb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -644,22 +644,32 @@ 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); // 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 fffa3f490b2b..81c724322b66 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -226,6 +226,7 @@ import { } from "../composerInlineChip"; import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { useClientSettings } from "~/hooks/useSettings"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; @@ -691,6 +692,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); + // Re-measure the minimap gutter when the chat column changes width without a viewport resize. + const chatWidth = useClientSettings((settings) => settings.chatWidth); const { target: readyCitationRequest, positioning: citationPositioning, @@ -830,11 +833,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const measure = () => { const viewportWidth = timelineViewportElement.getBoundingClientRect().width; - const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); + // Without a mounted row, treat the column as full width so the strip stays inert. + const contentWidth = + timelineViewportElement + .querySelector("[data-timeline-root]") + ?.getBoundingClientRect().width ?? viewportWidth; + const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( + viewportWidth, + contentWidth, + ); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); - setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); reportContentOverflow(); }; @@ -847,7 +858,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length, reportContentOverflow]); + }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); const sharedState = useMemo( () => ({ From f3ff396d25d43cbb26899365c2c6b359e204773b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:43:58 +1000 Subject: [PATCH 2/2] fix(web): keep minimap turn buttons off the chat column in narrow gutters The previous and next turn buttons hang above and below the minimap strip and stayed clickable regardless of the strip's width cap. With Full chat width and overlay scrollbars they covered the first few pixels of message text. Make them inert to the pointer unless the gutter can hold them; keyboard focus still reaches them. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.logic.ts | 13 +++++++++++++ .../src/components/chat/MessagesTimeline.test.tsx | 8 ++++++++ apps/web/src/components/chat/MessagesTimeline.tsx | 9 ++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f9c1ac4931a8..252f63d47abc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -270,6 +270,19 @@ export function resolveTimelineMinimapHitStripWidth( ); } +// 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 74fb6d5362cb..b7a30637db8f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -560,6 +560,7 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, + resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); @@ -671,6 +672,13 @@ describe("MessagesTimeline", () => { 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. expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 81c724322b66..5af56eca388f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -173,6 +173,7 @@ import { resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, + resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, @@ -1095,6 +1096,7 @@ function TimelineMinimap({ ), [items, resolvedActiveIndex], ); + const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth); const activeTopPercent = resolvedActiveIndex === null ? 0 @@ -1175,6 +1177,7 @@ function TimelineMinimap({ { if (previousItem) onSelect(previousItem); }} @@ -1290,6 +1293,7 @@ function TimelineMinimap({ { if (nextItem) onSelect(nextItem); }} @@ -1303,10 +1307,12 @@ function TimelineMinimap({ function TimelineMinimapNavigationButton({ direction, disabled, + interactive, onClick, }: { direction: "previous" | "next"; disabled: boolean; + interactive: boolean; onClick: () => void; }) { const previous = direction === "previous"; @@ -1319,7 +1325,8 @@ function TimelineMinimapNavigationButton({ render={