feat(issues): thread quick-jump minimap on issue detail (MUL-4389) (#5234)

* feat(issues): add thread quick-jump minimap to issue detail (MUL-4389)

A Linear-style rail of tick marks overlaid on the left edge of the issue
detail scroll area, one tick per comment thread (folded resolved bars
included). Ticks whose thread intersects the viewport render darker, so
the rail doubles as a scroll minimap. Hovering a tick grows it and opens
a preview card (bold first line + muted body excerpt, both clamped);
clicking jumps the timeline to the thread and flashes it like an inbox
deep-link landing.

Jumps go through Virtuoso's scrollToIndex in virtualized mode (the
target row may be unmounted) and direct container scrollTop math in the
flat deep-link/find modes, never native scrollIntoView (#3929).
Viewport tracking reads DOM rects on scroll/resize instead of an
IntersectionObserver because Virtuoso mounts/unmounts rows while
scrolling. Hidden on mobile: no hover, and the gutter is too tight.

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): Dock-style hover wave on the thread minimap (MUL-4389)

Hovering the rail now magnifies ticks with a cosine falloff of their
distance to the cursor — the hovered tick peaks at 1.7x and neighbours
taper off across ~4 tick pitches, following the pointer continuously.

Driven per-pointermove with direct style writes on the native `scale`
property (compositor-friendly, no React re-render), batched
read-then-write inside one rAF; a 100ms ease-out transition smooths
between pointer samples and settles the collapse on leave. Clearing the
inline value hands control back to the CSS floor states (popup-open,
focus-visible), and prefers-reduced-motion swaps the wave for a plain
hover grow. Only the hovered tick darkens — neighbours grow but keep
their color.

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): single glide-follow preview card on the thread minimap (MUL-4389)

Scanning the rail continuously re-paid the 150ms open delay plus the
close/open animation on every tick crossed, because each tick owned an
independent PreviewCard popover — hover felt laggy while gliding.

Replace the per-tick popovers with ONE card owned by the rail, driven
by the same rAF rect pass as the hover wave: the intent delay is paid
once when the pointer enters the rail; after that, gliding retargets
the card instantly (~1 frame) and slides it to the hovered tick with a
150ms transform transition. Leaving starts a grace timer long enough to
travel onto the card (which keeps it open for text selection); keyboard
focus anchors the card immediately. The anchor is clamped so the card
never sticks out of the column at the rail's extremes, and previews are
cached per thread content so unrelated timeline updates don't
re-flatten every comment.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-07-11 00:51:33 +08:00
committed by GitHub
parent 932bbf2bb5
commit 835b1d5e4f
7 changed files with 664 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from "react";
import { Virtuoso } from "react-virtuoso";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
@@ -58,6 +58,7 @@ import { LocalDirectoryHint } from "../../projects/components/local-directory-hi
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { ResolvedThreadBar } from "./resolved-thread-bar";
import { ThreadMinimap, type ThreadMinimapThread } from "./thread-minimap";
import { collectThreadReplies, deriveThreadResolution } from "./thread-utils";
import { IssueAgentHeaderChip } from "./issue-agent-header-chip";
import { ExecutionLogSection } from "./execution-log-section";
@@ -1079,6 +1080,59 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
return items.findIndex((it) => it.id === rootId);
}, [items, highlightCommentId, replyToRoot]);
// Quick-jump minimap rail: one tick per comment thread (folded resolved
// bars included), activity groups skipped. Derived from the same flat
// `items` array Virtuoso renders so tick order always matches the page.
const minimapThreads = useMemo<ThreadMinimapThread[]>(
() =>
items.flatMap((it) =>
it.kind === "comment" || it.kind === "resolved-bar"
? [{ id: it.id, entry: it.entry }]
: [],
),
[items],
);
// When the timeline renders flat (deep-link or in-page find), there is no
// Virtuoso instance — minimap jumps drive the scroll container directly.
const isFlatTimeline = !!highlightCommentId || find.open;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const jumpFlashTimerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (jumpFlashTimerRef.current !== null) window.clearTimeout(jumpFlashTimerRef.current);
},
[],
);
const jumpToThread = useCallback(
(threadId: string) => {
if (isFlatTimeline) {
// Flat mode mounts every row, so the anchor is always in the DOM.
// Drive the container's scrollTop directly — never native
// scrollIntoView, which also scrolls the desktop shell (#3929).
const el = document.getElementById(`comment-${threadId}`);
const container = scrollContainerEl;
if (!el || !container) return;
const c = container.getBoundingClientRect();
const e = el.getBoundingClientRect();
container.scrollTop = Math.max(0, container.scrollTop + (e.top - c.top) - 16);
} else {
// Virtualized mode: the target row may not be mounted, so scroll by
// index and let Virtuoso mount it. Offset leaves a small top gap.
const index = items.findIndex((it) => it.id === threadId);
if (index < 0) return;
virtuosoRef.current?.scrollToIndex({ index, align: "start", offset: -16 });
}
// Flash the landed thread the same way inbox deep-links do, so the eye
// has an anchor after the instant jump. (Folded resolved bars don't
// take the highlight prop — the scroll itself is the feedback there.)
setHighlightedId(threadId);
if (jumpFlashTimerRef.current !== null) window.clearTimeout(jumpFlashTimerRef.current);
jumpFlashTimerRef.current = window.setTimeout(() => setHighlightedId(null), 2000);
},
[isFlatTimeline, items, scrollContainerEl],
);
const {
reactions: issueReactions,
toggleReaction: handleToggleIssueReaction,
@@ -2196,6 +2250,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<div className="mt-4">
<Virtuoso
key={`${wsId}:${id}`}
ref={virtuosoRef}
customScrollParent={scrollContainerEl}
data={items}
increaseViewportBy={{ top: 800, bottom: 800 }}
@@ -2232,6 +2287,19 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
</div>
</div>
{/* Thread quick-jump rail — overlays the scroll container's left
gutter (inside the px-8 content padding, so it never covers
text). Hover previews a thread, click jumps to it. Hidden on
mobile: no hover, and the gutter is too tight. */}
{!isMobile && (
<ThreadMinimap
threads={minimapThreads}
scrollContainerEl={scrollContainerEl}
onJump={jumpToThread}
className="absolute bottom-0 left-2 top-12"
/>
)}
</div>
);

View File

@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, screen } from "@testing-library/react";
import type { TimelineEntry } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
import { ThreadMinimap, commentPreview, waveScale } from "./thread-minimap";
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (type: string, id: string) => `${type}:${id}`,
}),
}));
function comment(id: string, content: string): TimelineEntry {
return {
type: "comment",
id,
actor_type: "member",
actor_id: `author-${id}`,
created_at: "2026-07-10T10:00:00Z",
content,
};
}
describe("commentPreview", () => {
it("splits the first line into the title and joins the rest into the body", () => {
const { title, body } = commentPreview(
"## Rollout plan\n\nShip the flag first.\nThen watch the dashboards.",
);
expect(title).toBe("Rollout plan");
expect(body).toBe("Ship the flag first. Then watch the dashboards.");
});
it("flattens markdown decorations to plain text", () => {
const { title, body } = commentPreview(
"**Bold** start with [a link](https://example.com) and [@Walt](mention://agent/a-1)\n" +
"- first item\n" +
"1. numbered ![diagram](https://example.com/x.png)\n" +
"```ts\nconst hidden = true;\n```\n" +
"> quoted tail",
);
expect(title).toBe("Bold start with a link and @Walt");
expect(body).toBe("first item numbered diagram quoted tail");
});
it("returns empty strings for content that flattens to nothing", () => {
expect(commentPreview("![](https://example.com/only-image.png)")).toEqual({
title: "",
body: "",
});
});
it("caps runaway titles and bodies", () => {
const { title, body } = commentPreview(`${"t".repeat(500)}\n${"b".repeat(900)}`);
expect(title).toHaveLength(200);
expect(body).toHaveLength(300);
});
});
describe("waveScale", () => {
it("peaks under the cursor and settles to 1 at the radius", () => {
expect(waveScale(0)).toBeCloseTo(1.7, 5);
expect(waveScale(56)).toBe(1);
expect(waveScale(200)).toBe(1);
});
it("tapers monotonically and symmetrically", () => {
const profile = [0, 14, 28, 42, 56].map(waveScale);
for (let i = 1; i < profile.length; i++) {
expect(profile[i]!).toBeLessThan(profile[i - 1]!);
}
expect(waveScale(-14)).toBeCloseTo(waveScale(14), 10);
});
});
describe("ThreadMinimap", () => {
const threads = [
{ id: "c1", entry: comment("c1", "First thread opener\nwith details") },
{ id: "c2", entry: comment("c2", "Second thread opener") },
{ id: "c3", entry: comment("c3", "") },
];
it("renders nothing below the thread threshold", () => {
const { container } = renderWithI18n(
<ThreadMinimap threads={threads.slice(0, 1)} scrollContainerEl={null} onJump={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders one labelled tick per thread, falling back to the author for empty content", () => {
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={vi.fn()} />,
);
const nav = screen.getByRole("navigation", { name: "Jump to comment thread" });
expect(nav).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(3);
expect(screen.getByRole("button", { name: "First thread opener" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Second thread opener" })).toBeInTheDocument();
// Attachment-only comment: accessible name falls back to the actor.
expect(screen.getByRole("button", { name: "member:author-c3" })).toBeInTheDocument();
});
it("opens the shared preview card after the intent delay and closes after the leave grace", () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"],
});
try {
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={vi.fn()} />,
);
const nav = screen.getByRole("navigation", { name: "Jump to comment thread" });
// jsdom rects are all zero → the nearest tick resolves to index 0.
fireEvent.pointerMove(nav, { clientY: 0 });
act(() => vi.advanceTimersByTime(30)); // rAF flush — arms the intent timer
expect(screen.queryByText("with details")).not.toBeInTheDocument();
act(() => vi.advanceTimersByTime(150)); // intent delay elapses → card opens
expect(screen.getByText("with details")).toBeInTheDocument();
fireEvent.pointerLeave(nav);
act(() => vi.advanceTimersByTime(30)); // wave-clear frame
expect(screen.getByText("with details")).toBeInTheDocument(); // grace keeps it up
act(() => vi.advanceTimersByTime(150)); // grace elapses → card closes
expect(screen.queryByText("with details")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it("jumps to the clicked thread", () => {
const onJump = vi.fn();
renderWithI18n(
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={onJump} />,
);
fireEvent.click(screen.getByRole("button", { name: "Second thread opener" }));
expect(onJump).toHaveBeenCalledTimes(1);
expect(onJump).toHaveBeenCalledWith("c2");
});
});

View File

@@ -0,0 +1,450 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TimelineEntry } from "@multica/core/types";
import { useActorName } from "@multica/core/workspace/hooks";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
// ---------------------------------------------------------------------------
// ThreadMinimap — Linear-style quick-jump rail for comment threads
// ---------------------------------------------------------------------------
//
// A vertical column of tick marks overlaid on the left edge of the issue
// detail scroll area, one tick per top-level comment thread (folded resolved
// bars included — they are jump targets too). Ticks whose thread is currently
// inside the scroll viewport render darker, so the rail doubles as a "you are
// here" minimap. Hovering magnifies ticks in a Dock-style wave around the
// cursor (the hovered tick peaks, neighbours taper off) and shows a preview
// card (bold first line + muted body excerpt); clicking jumps the timeline
// to that thread.
//
// The preview is ONE card owned by the rail, not a popover per tick: the
// open-intent delay is paid once when the pointer enters the rail, and while
// the pointer glides across ticks the card swaps content instantly and
// slides to the hovered tick. Per-tick popovers re-paid the open delay and
// exit/enter animations on every tick crossed, which read as lag when
// scanning the rail continuously.
//
// The rail deliberately skips activity groups: they are timeline noise, not
// navigation destinations.
/** Minimum number of threads before the rail is worth its pixels. */
const MIN_THREADS = 2;
/** Intent delay before the card first appears; gliding afterwards is instant. */
const PREVIEW_OPEN_DELAY_MS = 150;
/** Grace period on leave — long enough to travel from rail onto the card. */
const PREVIEW_CLOSE_DELAY_MS = 150;
// ---------------------------------------------------------------------------
// Hover wave — Dock-style proximity magnification
// ---------------------------------------------------------------------------
//
// While the pointer travels along the rail, every tick scales with a cosine
// falloff of its distance to the cursor, so the hovered tick peaks and its
// neighbours taper off like a wave. Driven per-pointermove with direct style
// writes (no React re-render), batched read-then-write inside one rAF, on the
// compositor-friendly native `scale` property; the 100ms ease-out transition
// on the tick smooths between pointer samples and settles the collapse on
// leave. Only the hovered tick darkens — neighbours grow but keep their color.
/** Distance (px) at which a tick stops feeling the wave — ~4 tick pitches. */
const WAVE_RADIUS_PX = 56;
/** Peak horizontal scale of the hovered tick (12px base → ~20px). */
const WAVE_MAX_SCALE = 1.7;
/**
* Horizontal scale for a tick whose center is `distancePx` from the pointer.
* Cosine-squared bell: smooth at the peak and at the radius edge (no kinks).
*/
export function waveScale(distancePx: number): number {
const d = Math.abs(distancePx);
if (d >= WAVE_RADIUS_PX) return 1;
const t = Math.cos(((d / WAVE_RADIUS_PX) * Math.PI) / 2);
return 1 + (WAVE_MAX_SCALE - 1) * t * t;
}
/**
* Caps applied by `commentPreview`. The preview card clamps visually
* (`truncate` / `line-clamp-3`), but agent comments can be tens of KB of
* markdown — capping here keeps the flattened strings (and the aria-labels
* derived from them) small instead of shipping the whole comment into the DOM.
*/
const PREVIEW_TITLE_MAX = 200;
const PREVIEW_BODY_MAX = 300;
/**
* Flatten comment markdown into a plain-text preview: `title` is the first
* non-empty line (bold in the card), `body` is the remaining lines joined
* into one muted excerpt. Mirrors the chat list's `toPreview` flattening
* (fences dropped, md tokens stripped) but keeps the first-line/body split
* the minimap card renders.
*/
export function commentPreview(markdown: string): { title: string; body: string } {
const lines = markdown
.replace(/```[\s\S]*?```/g, " ")
.split(/\r?\n/)
.map((line) =>
line
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s*(?:[-+*]|\d+[.)])\s+/, "")
.replace(/[#*`>~]/g, "")
.replace(/\s+/g, " ")
.trim(),
)
.filter(Boolean);
return {
title: (lines[0] ?? "").slice(0, PREVIEW_TITLE_MAX),
body: lines.slice(1).join(" ").slice(0, PREVIEW_BODY_MAX),
};
}
export interface ThreadMinimapThread {
/** Root comment id — also the `comment-${id}` DOM anchor of the rendered row. */
id: string;
/** The thread's root comment entry (preview text + author fallback). */
entry: TimelineEntry;
}
interface ThreadMinimapProps {
threads: ThreadMinimapThread[];
/** The issue detail scroll container; null until its callback ref populates. */
scrollContainerEl: HTMLElement | null;
onJump: (threadId: string) => void;
/** Positioning within the page (e.g. `absolute left-2 top-12 bottom-0`) — owned by the caller, like FindBar. */
className?: string;
}
function sameIdSet(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
/**
* Which threads currently intersect the scroll viewport. Computed from DOM
* rects on scroll/resize instead of an IntersectionObserver because Virtuoso
* mounts/unmounts rows while scrolling — an observer would lose its targets.
* Unmounted rows are by definition outside the (overscanned) viewport, so
* "no element" correctly counts as not visible.
*/
function useVisibleThreadIds(
threads: ThreadMinimapThread[],
scrollContainerEl: HTMLElement | null,
): Set<string> {
const [visibleIds, setVisibleIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
const container = scrollContainerEl;
if (!container) return;
let raf = 0;
const compute = () => {
raf = 0;
const rect = container.getBoundingClientRect();
const next = new Set<string>();
for (const t of threads) {
const el = document.getElementById(`comment-${t.id}`);
if (!el) continue;
const r = el.getBoundingClientRect();
if (r.bottom > rect.top && r.top < rect.bottom) next.add(t.id);
}
setVisibleIds((prev) => (sameIdSet(prev, next) ? prev : next));
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(compute);
};
compute();
container.addEventListener("scroll", schedule, { passive: true });
// Content height changes without scroll events: Virtuoso mounting rows
// after first paint, streamed agent replies growing, window resizes.
const ro = new ResizeObserver(schedule);
ro.observe(container);
if (container.firstElementChild) ro.observe(container.firstElementChild);
return () => {
container.removeEventListener("scroll", schedule);
ro.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [threads, scrollContainerEl]);
return visibleIds;
}
/** The rail-owned preview card's target: which tick, anchored at which shim-relative Y. */
interface PreviewAnchor {
index: number;
y: number;
}
function MinimapTick({
label,
inViewport,
isPreviewOpen,
onClick,
}: {
label: string;
inViewport: boolean;
/** This tick's preview is the open card — hold the grown state even when the pointer is on the card. */
isPreviewOpen: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="group/tick flex min-h-[5px] w-6 flex-[0_1_0.875rem] cursor-pointer items-center focus-visible:outline-none"
>
<span
className={cn(
// Enlargement is a left-anchored `scale` (compositor-friendly, and
// what the JS wave writes inline). The 100ms ease-out doubles as
// smoothing between pointer samples and as the settle on leave.
"h-0.5 w-3 origin-left rounded-full transition-[scale,background-color] duration-100 ease-out",
inViewport ? "bg-foreground/70" : "bg-muted-foreground/30",
"group-hover/tick:bg-foreground",
// CSS floor states for when no inline wave value is present:
// the open card's tick stays grown while the pointer rests on the
// card, keyboard focus grows without a pointer, and reduced-motion
// swaps the wave for a plain hover grow.
isPreviewOpen && "scale-x-[1.7] bg-foreground",
"group-focus-visible/tick:scale-x-[1.7] group-focus-visible/tick:bg-foreground",
"motion-reduce:group-hover/tick:scale-x-[1.7]",
)}
/>
</button>
);
}
export function ThreadMinimap({ threads, scrollContainerEl, onJump, className }: ThreadMinimapProps) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const visibleIds = useVisibleThreadIds(threads, scrollContainerEl);
// Flattened previews, cached per thread by content so an unrelated timeline
// update (reaction, new reply elsewhere) doesn't re-flatten every comment.
const prevPreviewsRef = useRef<Map<string, { content: string | undefined; preview: { title: string; body: string } }>>(new Map());
const previews = useMemo(() => {
const next = new Map<string, { content: string | undefined; preview: { title: string; body: string } }>();
const arr = threads.map((th) => {
const cached = prevPreviewsRef.current.get(th.id);
const preview =
cached && cached.content === th.entry.content
? cached.preview
: commentPreview(th.entry.content ?? "");
next.set(th.id, { content: th.entry.content, preview });
return preview;
});
prevPreviewsRef.current = next;
return arr;
}, [threads]);
const shimRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
// Hover wave + preview targeting. Pointer position lives in refs and ticks
// are scaled with direct style writes so pointermove never re-renders the
// component; the rAF guard coalesces bursts to one batched read-then-write
// per frame. The same rect pass derives which tick the card should anchor
// to, so the card and the wave can never disagree about the hovered tick.
const waveRafRef = useRef(0);
const pointerYRef = useRef<number | null>(null);
const reducedMotionRef = useRef(false);
const [preview, setPreview] = useState<PreviewAnchor | null>(null);
const previewRef = useRef<PreviewAnchor | null>(null);
const pendingAnchorRef = useRef<PreviewAnchor | null>(null);
const openTimerRef = useRef<number | null>(null);
const closeTimerRef = useRef<number | null>(null);
const showPreview = useCallback((anchor: PreviewAnchor | null) => {
previewRef.current = anchor;
setPreview((prev) =>
prev?.index === anchor?.index && prev?.y === anchor?.y ? prev : anchor,
);
}, []);
useEffect(() => {
reducedMotionRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return () => {
if (waveRafRef.current) cancelAnimationFrame(waveRafRef.current);
if (openTimerRef.current !== null) window.clearTimeout(openTimerRef.current);
if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current);
};
}, []);
const cancelClose = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
showPreview(null);
}, PREVIEW_CLOSE_DELAY_MS);
}, [cancelClose, showPreview]);
const runWave = useCallback(() => {
waveRafRef.current = 0;
const nav = navRef.current;
const shim = shimRef.current;
if (!nav || !shim) return;
const y = pointerYRef.current;
const buttons = nav.querySelectorAll<HTMLButtonElement>("button");
// Read pass, then write pass — never interleaved, one reflow at most.
const shimTop = shim.getBoundingClientRect().top;
const shimHeight = shim.clientHeight;
const cardHalf = (cardRef.current?.offsetHeight ?? 96) / 2;
const scales: string[] = [];
let nearest: { index: number; centerY: number; dist: number } | null = null;
buttons.forEach((b, i) => {
if (y === null) {
scales.push("");
return;
}
const r = b.getBoundingClientRect();
const centerY = r.top + r.height / 2;
const dist = Math.abs(y - centerY);
const s = reducedMotionRef.current ? 1 : waveScale(y - centerY);
scales.push(s > 1.001 ? `${s.toFixed(3)} 1` : "");
if (!nearest || dist < nearest.dist) nearest = { index: i, centerY, dist };
});
buttons.forEach((b, i) => {
const tick = b.firstElementChild as HTMLElement | null;
if (!tick) return;
const s = scales[i]!;
// Clearing the inline value hands control back to the CSS floor states
// (open-card tick / focus-visible / reduced-motion hover).
if (s) tick.style.setProperty("scale", s);
else tick.style.removeProperty("scale");
});
// Preview targeting from the same pass. Clamp the anchor so the card
// never sticks out of the rail's column at the extremes.
if (y === null || !nearest) return;
const { index, centerY } = nearest as { index: number; centerY: number };
const anchor: PreviewAnchor = {
index,
y: Math.min(Math.max(centerY - shimTop, cardHalf + 6), shimHeight - cardHalf - 6),
};
pendingAnchorRef.current = anchor;
if (previewRef.current) {
// Already open: gliding retargets the card instantly — no re-delay.
showPreview(anchor);
} else if (openTimerRef.current === null) {
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
if (pointerYRef.current !== null) showPreview(pendingAnchorRef.current);
}, PREVIEW_OPEN_DELAY_MS);
}
}, [showPreview]);
const scheduleWave = useCallback(() => {
if (!waveRafRef.current) waveRafRef.current = requestAnimationFrame(runWave);
}, [runWave]);
const handleWaveMove = useCallback(
(e: React.PointerEvent) => {
cancelClose();
pointerYRef.current = e.clientY;
scheduleWave();
},
[cancelClose, scheduleWave],
);
const handleWaveLeave = useCallback(() => {
pointerYRef.current = null;
scheduleWave();
scheduleClose();
}, [scheduleWave, scheduleClose]);
// Keyboard parity: focusing a tick anchors the card to it immediately —
// there is no pointer, so there is no accidental-hover to debounce.
const handleFocus = useCallback(
(e: React.FocusEvent) => {
const nav = navRef.current;
const shim = shimRef.current;
const btn = (e.target as HTMLElement).closest("button");
if (!nav || !shim || !btn) return;
cancelClose();
const buttons = [...nav.querySelectorAll<HTMLButtonElement>("button")];
const index = buttons.indexOf(btn as HTMLButtonElement);
if (index < 0) return;
const r = btn.getBoundingClientRect();
const cardHalf = (cardRef.current?.offsetHeight ?? 96) / 2;
const y = r.top + r.height / 2 - shim.getBoundingClientRect().top;
showPreview({
index,
y: Math.min(Math.max(y, cardHalf + 6), shim.clientHeight - cardHalf - 6),
});
},
[cancelClose, showPreview],
);
if (threads.length < MIN_THREADS) return null;
const activeThread = preview ? threads[preview.index] : undefined;
const activePreview = preview ? previews[preview.index] : undefined;
const activeTitle = activeThread && activePreview
? activePreview.title ||
getActorName(activeThread.entry.actor_type, activeThread.entry.actor_id)
: undefined;
return (
// Positioning shim; only the nav and the card take pointer events so the
// strip never blocks content clicks.
<div ref={shimRef} className={cn("pointer-events-none z-10 flex flex-col justify-center py-6", className)}>
<nav
ref={navRef}
aria-label={t(($) => $.detail.thread_nav_label)}
onPointerMove={handleWaveMove}
onPointerLeave={handleWaveLeave}
onFocusCapture={handleFocus}
onBlurCapture={scheduleClose}
// Bounded height + shrinkable ticks: when threads outgrow the rail,
// flex compresses the spacing (down to min-h) instead of overflowing.
className="pointer-events-auto flex max-h-full flex-col overflow-hidden"
>
{threads.map((thread, i) => (
<MinimapTick
key={thread.id}
label={
previews[i]!.title ||
getActorName(thread.entry.actor_type, thread.entry.actor_id)
}
inViewport={visibleIds.has(thread.id)}
isPreviewOpen={preview?.index === i}
onClick={() => onJump(thread.id)}
/>
))}
</nav>
{/* The rail's single preview card. Mounted without an enter animation
(the open-intent delay already gates accidental flashes; once the
user waited, showing content instantly is the responsive choice)
and slid between ticks with a short transform transition. Hovering
the card keeps it open so its text stays selectable. */}
{preview && activeThread && activePreview && (
<div
ref={cardRef}
onPointerEnter={cancelClose}
onPointerLeave={scheduleClose}
className="pointer-events-auto absolute left-9 top-0 w-72 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 transition-transform duration-150 ease-out motion-reduce:transition-none"
style={{ transform: `translateY(${preview.y}px) translateY(-50%)` }}
>
<p className="truncate text-sm font-semibold text-foreground">{activeTitle}</p>
{activePreview.body && (
<p className="mt-1 line-clamp-3 text-sm text-muted-foreground">{activePreview.body}</p>
)}
</div>
)}
</div>
);
}

View File

@@ -227,6 +227,7 @@
"archive_tooltip": "Archive",
"pin_tooltip": "Pin to sidebar",
"unpin_tooltip": "Unpin from sidebar",
"thread_nav_label": "Jump to comment thread",
"sidebar_tooltip": "Toggle sidebar",
"find": {
"placeholder": "Find in issue...",

View File

@@ -224,6 +224,7 @@
"archive_tooltip": "アーカイブ",
"pin_tooltip": "サイドバーにピン留め",
"unpin_tooltip": "サイドバーのピン留めを解除",
"thread_nav_label": "スレッドへ移動",
"sidebar_tooltip": "サイドバーの開閉",
"find": {
"placeholder": "イシュー内を検索...",

View File

@@ -224,6 +224,7 @@
"archive_tooltip": "보관",
"pin_tooltip": "사이드바에 고정",
"unpin_tooltip": "사이드바 고정 해제",
"thread_nav_label": "스레드로 이동",
"sidebar_tooltip": "사이드바 열기/닫기",
"find": {
"placeholder": "이슈에서 찾기...",

View File

@@ -224,6 +224,7 @@
"archive_tooltip": "",
"pin_tooltip": "",
"unpin_tooltip": "",
"thread_nav_label": "",
"sidebar_tooltip": "",
"find": {
"placeholder": " issue ...",