authorgravatar for xxanshxx9@gmail.comxxashxx-svg <xxanshxx9@gmail.com> 2026-07-20 16:46:47+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 11:16:47+00:00
log946b867666be94482e9e41015a304f55fe1e4754
tree38807aa93fcfef4551b87b1d12442c989f793c29
parent0ca3240691bf1773802b3ed70330515d68b0a6b8
signature Signed by PGP key B5690EEEBB952194

fix(web): scope timeline minimap hover target to the side gutter (#3869)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 110 insertions(+), 18 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.ts+39
......@@ -64,6 +64,45 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number)
6464 return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;
6565}
6666
67export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;
68export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40;
69export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";
70
71/**
72 * The minimap overlays the viewport's left edge while the content column is
73 * centered, so the side gutter between them shrinks under browser zoom or a
74 * narrow pane. A fixed-width hover strip would then sit on top of the message
75 * text and swallow its pointer events. Cap the strip's width so it never
76 * extends past the gutter into the content column; 0 disables the strip.
77 */
78export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number {
79 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
80 return 0;
81 }
82
83 const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
84 const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
85 return Math.max(
86 0,
87 Math.min(
88 TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH,
89 Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT,
90 ),
91 );
92}
93
94/**
95 * Once the preview is open, keep the full preview and the space leading to it
96 * interactive. The collapsed strip remains gutter-capped so it cannot block
97 * selecting message text.
98 */
99export function resolveTimelineMinimapInteractiveWidth(
100 collapsedWidth: number,
101 expanded: boolean,
102): number | string {
103 return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth;
104}
105
67106function computeElapsedMs(startIso: string, endIso: string): number | null {
68107 const start = Date.parse(startIso);
69108 const end = Date.parse(endIso);
apps/web/src/components/chat/MessagesTimeline.test.tsx+24
......@@ -224,7 +224,9 @@ describe("MessagesTimeline", () => {
224224 resolveTimelineIsAtEnd,
225225 resolveTimelineMinimapHasPersistentGutter,
226226 resolveTimelineMinimapHeightStyle,
227 resolveTimelineMinimapHitStripWidth,
227228 resolveTimelineMinimapIndexFromPointer,
229 resolveTimelineMinimapInteractiveWidth,
228230 resolveTimelineMinimapTopPercent,
229231 } = await import("./MessagesTimeline.logic");
230232
......@@ -254,6 +256,28 @@ describe("MessagesTimeline", () => {
254256 expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
255257 expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
256258 expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
259
260 // No usable gutter (zoomed in / narrow pane): the strip must go inert
261 // instead of overlaying the centered content column.
262 expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0);
263 expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0);
264 // Partial gutter: strip shrinks to what fits between the viewport edge
265 // and the content column.
266 expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14);
267 // Full gutter: unchanged 40px-wide strip.
268 expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40);
269 expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40);
270 expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0);
271 expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0);
272
273 // The collapsed target stays narrow, but an open preview keeps its full
274 // 20rem width plus the 2rem offset from the minimap rail interactive.
275 expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0);
276 expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14);
277 expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40);
278 expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem");
279 expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem");
280 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
257281 });
258282
259283 it("anchors a sent attachment message using its measured height", async () => {
apps/web/src/components/chat/MessagesTimeline.tsx+47-18
......@@ -73,7 +73,9 @@ import {
7373 resolveTimelineIsAtEnd,
7474 resolveTimelineMinimapHasPersistentGutter,
7575 resolveTimelineMinimapHeightStyle,
76 resolveTimelineMinimapHitStripWidth,
7677 resolveTimelineMinimapIndexFromPointer,
78 resolveTimelineMinimapInteractiveWidth,
7779 resolveTimelineMinimapTopPercent,
7880 type StableMessagesTimelineRowsState,
7981 type MessagesTimelineRow,
......@@ -324,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
324326 null,
325327 );
326328 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
329 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
327330 const handleAnchorReady = useCallback(
328331 (info: { anchorIndex: number | undefined }) => {
329332 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
......@@ -395,6 +398,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
395398 setMinimapHasPersistentGutter((current) =>
396399 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
397400 );
401 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
398402 };
399403
400404 const frame = requestAnimationFrame(measure);
......@@ -511,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
511515 items={minimapItems}
512516 bottomInset={contentInsetEndAdjustment}
513517 hasPersistentGutter={minimapHasPersistentGutter}
518 hitStripWidth={minimapHitStripWidth}
514519 stripMap={minimapStripMap}
515520 onSelect={(item) => {
516521 onManualNavigation();
......@@ -605,15 +610,21 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number
605610 return typeof height === "number" && Number.isFinite(height) ? height : null;
606611}
607612
613function timelineMinimapEventTargetsPreview(target: EventTarget): boolean {
614 return target instanceof Element && target.closest("[data-minimap-preview]") !== null;
615}
616
608617function TimelineMinimap({
609618 bottomInset,
610619 hasPersistentGutter,
620 hitStripWidth,
611621 items,
612622 stripMap,
613623 onSelect,
614624}: {
615625 bottomInset: number;
616626 hasPersistentGutter: boolean;
627 hitStripWidth: number;
617628 items: ReadonlyArray<TimelineMinimapItem>;
618629 stripMap: Map<string, HTMLSpanElement>;
619630 onSelect: (item: TimelineMinimapItem) => void;
......@@ -676,7 +687,7 @@ function TimelineMinimap({
676687 return (
677688 <div
678689 className={cn(
679 "group/minimap pointer-events-auto absolute top-0 left-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
690 "group/minimap pointer-events-none absolute top-0 left-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
680691 hasPersistentGutter
681692 ? "opacity-100"
682693 : "opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
......@@ -688,9 +699,17 @@ function TimelineMinimap({
688699 <div className="relative h-full w-full select-none">
689700 <button
690701 aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}
691 className="pointer-events-auto absolute top-1/2 left-3 w-10 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70"
702 className={cn(
703 "absolute top-1/2 left-3 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70",
704 // The strip is width-capped to the side gutter so it never overlays
705 // the centered content column; with no usable gutter it goes inert.
706 hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none",
707 )}
692708 onBlur={() => setActiveIndex(null)}
693709 onClick={(event) => {
710 if (timelineMinimapEventTargetsPreview(event.target)) {
711 return;
712 }
694713 const nextIndex = resolveActiveIndexFromPointer(event);
695714 const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null);
696715 if (nextItem) {
......@@ -722,9 +741,15 @@ function TimelineMinimap({
722741 onMouseLeave={() => setActiveIndex(null)}
723742 onMouseMove={updateActiveIndexFromPointer}
724743 onMouseDown={(event) => {
744 if (timelineMinimapEventTargetsPreview(event.target)) {
745 return;
746 }
725747 event.preventDefault();
726748 }}
727 style={{ height: resolveTimelineMinimapHeightStyle(items.length) }}
749 style={{
750 height: resolveTimelineMinimapHeightStyle(items.length),
751 width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null),
752 }}
728753 type="button"
729754 >
730755 <div className="absolute top-0 left-3 h-full w-px bg-border/15" />
......@@ -761,27 +786,31 @@ function TimelineMinimap({
761786 })}
762787 {activeItem ? (
763788 <span
764 className="pointer-events-none absolute left-8 w-80 rounded-xl border border-border/70 bg-popover/95 p-3 text-left text-popover-foreground shadow-xl shadow-black/25 backdrop-blur"
789 className="pointer-events-auto absolute left-8 w-80 cursor-text select-text"
790 data-minimap-preview
791 onMouseMove={(event) => event.stopPropagation()}
765792 style={{
766793 top: `${activeTopPercent}%`,
767794 transform: `translateY(${activeTooltipTranslate})`,
768795 }}
769796 >
770 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
771 {activeItem.userText ?? "User message"}
772 </span>
773 {activeItem.assistantText ? (
774 <span
775 className="mt-1 max-h-[3.75rem] overflow-hidden text-muted-foreground text-sm leading-5"
776 style={{
777 display: "-webkit-box",
778 WebkitBoxOrient: "vertical",
779 WebkitLineClamp: 3,
780 }}
781 >
782 {activeItem.assistantText}
797 <span className="block rounded-xl border border-border/70 bg-popover/95 p-3 text-left text-popover-foreground shadow-xl shadow-black/25 backdrop-blur">
798 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
799 {activeItem.userText ?? "User message"}
783800 </span>
784 ) : null}
801 {activeItem.assistantText ? (
802 <span
803 className="mt-1 max-h-[3.75rem] overflow-hidden text-muted-foreground text-sm leading-5"
804 style={{
805 display: "-webkit-box",
806 WebkitBoxOrient: "vertical",
807 WebkitLineClamp: 3,
808 }}
809 >
810 {activeItem.assistantText}
811 </span>
812 ) : null}
813 </span>
785814 </span>
786815 ) : null}
787816 </button>