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 (