mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 03:15:34 +02:00
The rail looks and behaves like a scroll affordance, but sat on the far left: on a wide screen it was hundreds of pixels from both the body text and the scrollbar the pointer was already on, so every jump cost a full sweep across the page. Move it to the right edge, just inside the scrollbar. right-3 plus a 20px strip is the inset that holds in both scrollbar modes: it clears a classic scrollbar's ~11px gutter, and lands exactly on the content column's 32px padding when the gutter is 0 (overlay scrollbars), so it covers neither the scrollbar nor body text. Ticks flush right and the hover wave grows them inward; the preview card opens leftward. The find bar steps inside the rail on desktop so it can't cover the top ticks. No left/right preference setting: the evidence is one-sided, and a toggle would mean maintaining and testing two layouts for a preference nobody has argued for yet. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
169 lines
6.4 KiB
TypeScript
169 lines
6.4 KiB
TypeScript
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 \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("")).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("hugs the scrollbar side: ticks are right-aligned and the card opens inward", () => {
|
|
vi.useFakeTimers({
|
|
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"],
|
|
});
|
|
try {
|
|
renderWithI18n(
|
|
<ThreadMinimap threads={threads} scrollContainerEl={null} onJump={vi.fn()} />,
|
|
);
|
|
|
|
// The rail sits in the right gutter, so ticks flush right and the wave
|
|
// grows them inward (away from the scrollbar) rather than over it.
|
|
const tick = screen.getByRole("button", { name: "First thread opener" });
|
|
expect(tick).toHaveClass("justify-end");
|
|
expect(tick.firstElementChild).toHaveClass("origin-right");
|
|
|
|
const nav = screen.getByRole("navigation", { name: "Jump to comment thread" });
|
|
fireEvent.pointerMove(nav, { clientY: 0 });
|
|
act(() => vi.advanceTimersByTime(30 + 150)); // rAF flush + intent delay
|
|
|
|
const card = screen.getByText("with details").closest("div");
|
|
expect(card).toHaveClass("right-8");
|
|
expect(card?.className).not.toMatch(/(?:^|\s)left-/);
|
|
} 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");
|
|
});
|
|
});
|