Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ describe("MessagesTimeline", () => {
resolveTimelineIsAtEnd,
resolveTimelineMinimapHasPersistentGutter,
resolveTimelineMinimapHeightStyle,
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
resolveTimelineMinimapTopPercent,
} = await import("./MessagesTimeline.logic");
Expand Down Expand Up @@ -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 () => {
Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
resolveTimelineIsAtEnd,
resolveTimelineMinimapHasPersistentGutter,
resolveTimelineMinimapHeightStyle,
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
resolveTimelineMinimapTopPercent,
type StableMessagesTimelineRowsState,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -393,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
setMinimapHasPersistentGutter((current) =>
current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
);
setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
};

const frame = requestAnimationFrame(measure);
Expand Down Expand Up @@ -506,6 +509,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
items={minimapItems}
bottomInset={contentInsetEndAdjustment}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
stripMap={minimapStripMap}
onSelect={(item) => {
onManualNavigation();
Expand Down Expand Up @@ -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<TimelineMinimapItem>;
stripMap: Map<string, HTMLSpanElement>;
onSelect: (item: TimelineMinimapItem) => void;
Expand Down Expand Up @@ -671,7 +677,7 @@ function TimelineMinimap({
return (
<div
className={cn(
"group/minimap pointer-events-auto absolute top-0 left-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
"group/minimap pointer-events-none absolute top-0 left-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
hasPersistentGutter
? "opacity-100"
: "opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
Expand All @@ -683,7 +689,12 @@ function TimelineMinimap({
<div className="relative h-full w-full select-none">
<button
aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}
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"
className={cn(
"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",
// The strip is width-capped to the side gutter so it never overlays
// the centered content column; with no usable gutter it goes inert.
hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none",
)}
onBlur={() => setActiveIndex(null)}
onClick={(event) => {
const nextIndex = resolveActiveIndexFromPointer(event);
Expand Down Expand Up @@ -719,7 +730,10 @@ function TimelineMinimap({
onMouseDown={(event) => {
event.preventDefault();
}}
style={{ height: resolveTimelineMinimapHeightStyle(items.length) }}
style={{
height: resolveTimelineMinimapHeightStyle(items.length),
width: hitStripWidth,
}}
type="button"
>
<div className="absolute top-0 left-3 h-full w-px bg-border/15" />
Expand Down
Loading