From f1264c17348c3fba0da0e007b6700b041bf37fdd Mon Sep 17 00:00:00 2001 From: xxashxx-svg Date: Sat, 11 Jul 2026 01:54:36 +0530 Subject: [PATCH] fix(web): scope timeline minimap hover target to the side gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimap overlay is anchored to the timeline viewport's left edge with a fixed-width hover strip (left-3 w-10) and an always-interactive w-18 wrapper, while the message column is centered. Under browser zoom or a narrow pane the side gutter between them collapses, so the fixed strip lands on top of the message text: hovering body text pops the minimap preview, the copy button next to the timestamp never reveals (the row's group-hover can't fire while the overlay swallows the pointer), and text selection fights the overlay. Cap the strip's width to what actually fits between the viewport edge and the content column, measured from the already-observed viewport width; with no usable gutter the strip goes inert. The wrapper is now pointer-events-none so only the width-capped strip captures the pointer (hover reveal still works — hovering the strip puts its ancestors in :hover). Wide layouts keep the exact 40px strip they have today. Fixes #3702 Co-Authored-By: Claude Fable 5 --- .../components/chat/MessagesTimeline.logic.ts | 26 +++++++++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 14 ++++++++++ .../src/components/chat/MessagesTimeline.tsx | 20 +++++++++++--- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c6e277cce08..ac8448e660a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -64,6 +64,32 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } +export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; + +/** + * 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 + * 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); + return Math.max( + 0, + Math.min( + TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH, + Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT, + ), + ); +} + function computeElapsedMs(startIso: string, endIso: string): number | null { const start = Date.parse(startIso); const end = Date.parse(endIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..7303249cf04 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -224,6 +224,7 @@ describe("MessagesTimeline", () => { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); @@ -254,6 +255,19 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + + // 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); + // Partial gutter: strip shrinks to what fits between the viewport edge + // and the content column. + expect(resolveTimelineMinimapHitStripWidth(820)).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); }); it("anchors a sent attachment message using its measured height", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..3860e656f2f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -73,6 +73,7 @@ import { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, type StableMessagesTimelineRowsState, @@ -322,6 +323,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -393,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); }; const frame = requestAnimationFrame(measure); @@ -506,6 +509,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ items={minimapItems} bottomInset={contentInsetEndAdjustment} hasPersistentGutter={minimapHasPersistentGutter} + hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} onSelect={(item) => { onManualNavigation(); @@ -603,12 +607,14 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number function TimelineMinimap({ bottomInset, hasPersistentGutter, + hitStripWidth, items, stripMap, onSelect, }: { bottomInset: number; hasPersistentGutter: boolean; + hitStripWidth: number; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; @@ -671,7 +677,7 @@ function TimelineMinimap({ return (