diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8dcb539fb024..252f63d47abc 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( @@ -262,6 +270,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 6991f4432594..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"); @@ -644,22 +645,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 fffa3f490b2b..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, @@ -226,6 +227,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 +693,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 +834,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 +859,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length, reportContentOverflow]); + }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); const sharedState = useMemo( () => ({ @@ -1084,6 +1096,7 @@ function TimelineMinimap({ ), [items, resolvedActiveIndex], ); + const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth); const activeTopPercent = resolvedActiveIndex === null ? 0 @@ -1164,6 +1177,7 @@ function TimelineMinimap({ { if (previousItem) onSelect(previousItem); }} @@ -1279,6 +1293,7 @@ function TimelineMinimap({ { if (nextItem) onSelect(nextItem); }} @@ -1292,10 +1307,12 @@ function TimelineMinimap({ function TimelineMinimapNavigationButton({ direction, disabled, + interactive, onClick, }: { direction: "previous" | "next"; disabled: boolean; + interactive: boolean; onClick: () => void; }) { const previous = direction === "previous"; @@ -1308,7 +1325,8 @@ function TimelineMinimapNavigationButton({ render={