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)...@@ -64,6 +64,45 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number)
64 return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;64 return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;
65}65}
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
67function computeElapsedMs(startIso: string, endIso: string): number | null {106function computeElapsedMs(startIso: string, endIso: string): number | null {
68 const start = Date.parse(startIso);107 const start = Date.parse(startIso);
69 const end = Date.parse(endIso);108 const end = Date.parse(endIso);
apps/web/src/components/chat/MessagesTimeline.test.tsx+24
...@@ -224,7 +224,9 @@ describe("MessagesTimeline", () => {...@@ -224,7 +224,9 @@ describe("MessagesTimeline", () => {
224 resolveTimelineIsAtEnd,224 resolveTimelineIsAtEnd,
225 resolveTimelineMinimapHasPersistentGutter,225 resolveTimelineMinimapHasPersistentGutter,
226 resolveTimelineMinimapHeightStyle,226 resolveTimelineMinimapHeightStyle,
227 resolveTimelineMinimapHitStripWidth,
227 resolveTimelineMinimapIndexFromPointer,228 resolveTimelineMinimapIndexFromPointer,
229 resolveTimelineMinimapInteractiveWidth,
228 resolveTimelineMinimapTopPercent,230 resolveTimelineMinimapTopPercent,
229 } = await import("./MessagesTimeline.logic");231 } = await import("./MessagesTimeline.logic");
230232
...@@ -254,6 +256,28 @@ describe("MessagesTimeline", () => {...@@ -254,6 +256,28 @@ describe("MessagesTimeline", () => {
254 expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);256 expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
255 expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);257 expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
256 expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);258 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");
257 });281 });
258282
259 it("anchors a sent attachment message using its measured height", async () => {283 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 {...@@ -73,7 +73,9 @@ import {
73 resolveTimelineIsAtEnd,73 resolveTimelineIsAtEnd,
74 resolveTimelineMinimapHasPersistentGutter,74 resolveTimelineMinimapHasPersistentGutter,
75 resolveTimelineMinimapHeightStyle,75 resolveTimelineMinimapHeightStyle,
76 resolveTimelineMinimapHitStripWidth,
76 resolveTimelineMinimapIndexFromPointer,77 resolveTimelineMinimapIndexFromPointer,
78 resolveTimelineMinimapInteractiveWidth,
77 resolveTimelineMinimapTopPercent,79 resolveTimelineMinimapTopPercent,
78 type StableMessagesTimelineRowsState,80 type StableMessagesTimelineRowsState,
79 type MessagesTimelineRow,81 type MessagesTimelineRow,
...@@ -324,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -324,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
324 null,326 null,
325 );327 );
326 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);328 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
329 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
327 const handleAnchorReady = useCallback(330 const handleAnchorReady = useCallback(
328 (info: { anchorIndex: number | undefined }) => {331 (info: { anchorIndex: number | undefined }) => {
329 if (anchorMessageId !== null && info.anchorIndex !== undefined) {332 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
...@@ -395,6 +398,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -395,6 +398,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
395 setMinimapHasPersistentGutter((current) =>398 setMinimapHasPersistentGutter((current) =>
396 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,399 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
397 );400 );
401 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
398 };402 };
399403
400 const frame = requestAnimationFrame(measure);404 const frame = requestAnimationFrame(measure);
...@@ -511,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -511,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
511 items={minimapItems}515 items={minimapItems}
512 bottomInset={contentInsetEndAdjustment}516 bottomInset={contentInsetEndAdjustment}
513 hasPersistentGutter={minimapHasPersistentGutter}517 hasPersistentGutter={minimapHasPersistentGutter}
518 hitStripWidth={minimapHitStripWidth}
514 stripMap={minimapStripMap}519 stripMap={minimapStripMap}
515 onSelect={(item) => {520 onSelect={(item) => {
516 onManualNavigation();521 onManualNavigation();
...@@ -605,15 +610,21 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number...@@ -605,15 +610,21 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number
605 return typeof height === "number" && Number.isFinite(height) ? height : null;610 return typeof height === "number" && Number.isFinite(height) ? height : null;
606}611}
607612
613function timelineMinimapEventTargetsPreview(target: EventTarget): boolean {
614 return target instanceof Element && target.closest("[data-minimap-preview]") !== null;
615}
616
608function TimelineMinimap({617function TimelineMinimap({
609 bottomInset,618 bottomInset,
610 hasPersistentGutter,619 hasPersistentGutter,
620 hitStripWidth,
611 items,621 items,
612 stripMap,622 stripMap,
613 onSelect,623 onSelect,
614}: {624}: {
615 bottomInset: number;625 bottomInset: number;
616 hasPersistentGutter: boolean;626 hasPersistentGutter: boolean;
627 hitStripWidth: number;
617 items: ReadonlyArray<TimelineMinimapItem>;628 items: ReadonlyArray<TimelineMinimapItem>;
618 stripMap: Map<string, HTMLSpanElement>;629 stripMap: Map<string, HTMLSpanElement>;
619 onSelect: (item: TimelineMinimapItem) => void;630 onSelect: (item: TimelineMinimapItem) => void;
...@@ -676,7 +687,7 @@ function TimelineMinimap({...@@ -676,7 +687,7 @@ function TimelineMinimap({
676 return (687 return (
677 <div688 <div
678 className={cn(689 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",
680 hasPersistentGutter691 hasPersistentGutter
681 ? "opacity-100"692 ? "opacity-100"
682 : "opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",693 : "opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
...@@ -688,9 +699,17 @@ function TimelineMinimap({...@@ -688,9 +699,17 @@ function TimelineMinimap({
688 <div className="relative h-full w-full select-none">699 <div className="relative h-full w-full select-none">
689 <button700 <button
690 aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}701 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 )}
692 onBlur={() => setActiveIndex(null)}708 onBlur={() => setActiveIndex(null)}
693 onClick={(event) => {709 onClick={(event) => {
710 if (timelineMinimapEventTargetsPreview(event.target)) {
711 return;
712 }
694 const nextIndex = resolveActiveIndexFromPointer(event);713 const nextIndex = resolveActiveIndexFromPointer(event);
695 const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null);714 const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null);
696 if (nextItem) {715 if (nextItem) {
...@@ -722,9 +741,15 @@ function TimelineMinimap({...@@ -722,9 +741,15 @@ function TimelineMinimap({
722 onMouseLeave={() => setActiveIndex(null)}741 onMouseLeave={() => setActiveIndex(null)}
723 onMouseMove={updateActiveIndexFromPointer}742 onMouseMove={updateActiveIndexFromPointer}
724 onMouseDown={(event) => {743 onMouseDown={(event) => {
744 if (timelineMinimapEventTargetsPreview(event.target)) {
745 return;
746 }
725 event.preventDefault();747 event.preventDefault();
726 }}748 }}
727 style={{ height: resolveTimelineMinimapHeightStyle(items.length) }}749 style={{
750 height: resolveTimelineMinimapHeightStyle(items.length),
751 width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null),
752 }}
728 type="button"753 type="button"
729 >754 >
730 <div className="absolute top-0 left-3 h-full w-px bg-border/15" />755 <div className="absolute top-0 left-3 h-full w-px bg-border/15" />
...@@ -761,27 +786,31 @@ function TimelineMinimap({...@@ -761,27 +786,31 @@ function TimelineMinimap({
761 })}786 })}
762 {activeItem ? (787 {activeItem ? (
763 <span788 <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()}
765 style={{792 style={{
766 top: `${activeTopPercent}%`,793 top: `${activeTopPercent}%`,
767 transform: `translateY(${activeTooltipTranslate})`,794 transform: `translateY(${activeTooltipTranslate})`,
768 }}795 }}
769 >796 >
770 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">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">
771 {activeItem.userText ?? "User message"}798 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
772 </span>799 {activeItem.userText ?? "User message"}
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}
783 </span>800 </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>
785 </span>814 </span>
786 ) : null}815 ) : null}
787 </button>816 </button>