Compare commits

...

13 Commits

Author SHA1 Message Date
claude code agent 227
ad9cc78f00 fix(#268): don't open an empty hover card; align flip height estimate (F1,F2)
- F1: gate the card on rows-WITH-text (`thread.some(row => row.text.length > 0)`)
  instead of thread length. A text-less root whose only reply is also text-less
  would otherwise open an empty <Paper> (the render already filters empty rows).
  New test locks it (parent + reply both empty → no card).
- F2: ESTIMATED_CARD_HEIGHT 200 -> 300 (= CARD_MAX_HEIGHT) so the flip-above
  decision reserves the real worst-case height and a tall thread near the
  viewport bottom flips up instead of overflowing off-screen.

vitest 19/19, tsc 0, eslint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 03:51:40 +03:00
claude code agent 227
64a18298e6 feat(comment): hover tooltip shows author + all comments as plain lines (#268)
Per maintainer feedback: show the comment author and the whole thread (parent
+ replies), but as simple "Author: text" lines — no avatars, timestamps, or
thread chrome ("it's already clear they're comments on one entry, one after
another"). Also lengthen the open delay so the card doesn't pop up on a
passing glance.

- Render each comment in the thread as a plain line: bold "Name:" + text,
  parent first then replies (createdAt asc). Empty-text comments are skipped.
- OPEN_DELAY_MS 120 -> 350.
- Drop the avatar/relative-time/divider UI (and the CustomAvatar/timeAgo
  imports). buildThread (root + direct replies) is unchanged — the comment
  model is flat, so direct children of the root are the full thread.

Tests updated to the "Author: text" shape (textContent-based, incl. ordering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 03:06:12 +03:00
claude code agent 227
d58fe967a4 test(#268): assert the hover card's pointer-events:none (F1)
Lock the feature's central invariant — the tooltip must never intercept the
comment-mark's click (which opens the side panel). pointer-events:none is the
single property guaranteeing that, and it was unasserted: a regression dropping
it from the style object would let a lingering card swallow the click with no
test failing. Assert it in the "shows after delay" test.
2026-07-01 01:57:40 +03:00
claude code agent 227
a848003db2 feat(comment): hover tooltip with the comment text over comment marks (#268)
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>:
hovering a `.comment-mark[data-comment-id]` span shows a small floating card
(createPortal, position:fixed, pointer-events:none so it never intercepts the
mark's click) with the parent comment's plain text. Uses useCommentsQuery
(shares the ["comments", pageId] cache with the side panel — no extra
request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or
resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids
flicker; hides on mouseout / mousedown / scroll(capture) / resize / page
change. commentContentToText flattens the comment's ProseMirror doc
(stringified or parsed) to plain text, preserving hardBreaks as newlines and
never throwing. Main editor only (read-only / shares / history out of scope).
closes #268

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:58:13 +03:00
38f9a7938a Merge pull request 'feat(editor): restore reading scroll position on reload (#266)' (#267) from feat/266-scroll-position into develop
Reviewed-on: #267
2026-06-30 19:59:50 +03:00
claude code agent 227
30cdd65b92 test/refactor(#266): cover anti-clobber capture + once-guard; log storage errors
Review round 1 on the scroll-position feature:
- F1: add two tests for the hook's subtlest invariants — (a2) the restore
  target is captured synchronously at mount and survives a fresh scroll@0
  overwriting storage on load (a regression moving the capture into an effect
  would now fail); (a3) restore runs at most once per mount even when called
  again (the wiring effect can re-run).
- F2: log instead of silently swallowing sessionStorage errors in
  readStorage/writeStorage (AGENTS.md "errors must never be swallowed" rule);
  no user notification since a missed scroll restore is not actionable.
- F3: document the hard dependency on PageEditor remounting per page
  (key={page.id}) at the refs declaration — the per-mount refs are not reset
  on an in-place pageId change, so removing that key would break restore on
  the 2nd page.

vitest 9/9, tsc 0, eslint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:13:44 +03:00
claude code agent 227
b601c78c21 feat(editor): restore reading scroll position on reload (#266)
Adds useScrollPosition(pageId): saves window.scrollY to sessionStorage
(key gitmost:scroll-position:<pageId>) on throttled scroll / pagehide /
visibilitychange / cleanup, capturing the previously-saved value
synchronously at mount before any handler can overwrite it with the fresh 0.
restoreScrollPosition() (wired in page-editor.tsx to fire once the live
content is laid out, !showStatic && editor) yields to a #hash anchor, then
polls the document height and scrolls to the saved Y once the content is
tall enough, with a 5s timeout clamped to the max reachable position. All
storage access is try/caught so a disabled/quota'd Storage never breaks the
page. The in-flight restore poll is held in a ref and cancelled on unmount,
so a fast SPA navigation can't scroll the next page. closes #266

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:43:14 +03:00
79394b3ef8 Merge pull request 'test(#244): dictation ordered-emitter + internal-link paste (Phase 2 tail)' (#263) from test/244-phase2-tail into develop
Reviewed-on: #263
2026-06-30 11:21:17 +03:00
e3ec9a2965 Merge pull request 'fix(#262): reindex counter polls past the stale pre-reindex snapshot' (#264) from fix/262-reindex-progress-realtime into develop
Reviewed-on: #264
2026-06-30 11:21:01 +03:00
449a304657 Merge pull request 'fix(#260): open MCP collab docs by canonical UUID (slugId doc-name split)' (#265) from fix/260-collab-docname-slugid into develop
Reviewed-on: #265
2026-06-30 11:20:51 +03:00
claude code agent 227
42a1fa1d3a test(#244): cover the out-of-order failure branch of the dictation emitter (F1)
The reviewer noted the in-order emitter's else branch (a NOT-next-to-emit
segment failing → buffer an empty placeholder so the drain can skip it,
use-streaming-dictation.ts:215-218) was the one reachable ordering branch
left uncovered. Add a non-vacuous case: with 3 segments, reject seq 1
(out of order) → one notification, nothing emitted; resolve seq 0 → "alpha";
resolve seq 2 → "gamma". The seq-2 flush proves the empty placeholder let the
emitter advance PAST the failed seq 1 — without the else branch the drain
would stall at the missing seq 1 and "gamma" would never emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:01:49 +03:00
claude code agent 227
67312a3753 fix(#262): keep polling the reindex counter past the stale pre-reindex snapshot
After "Reindex now" the "Indexed X of Y" counter froze at 0 until a manual
reload. Root cause is purely client-side: right after the mutation the
client still holds the PRE-reindex settings snapshot, which for an already
fully-indexed workspace reads reindexing=false, indexed>=total. The
deadline-clearing effect evaluated isReindexComplete() against that stale
snapshot, read it as "done", and cleared the poll deadline before the first
post-reindex poll ever landed — so polling never ran and the counter stayed
at 0 (a reload just fetched one fresh snapshot).

Gate completion on having actually observed the active run: a
reindexSeenActiveRef, reset on each new reindex (mutation onSuccess, before
setting the deadline) and latched true once a poll reports reindexing=true.
isReindexComplete(status, seenActive) and nextReindexPollInterval now require
seenActive, so the stale fully-indexed snapshot no longer reads as finished.
The server pre-seeds reindexing=true from enqueue time, so seenActive latches
early and a genuine completion still stops polling promptly; the
REINDEX_POLL_CAP_MS cap is checked first and always wins, so polling can
never run away. closes #262

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:12:15 +03:00
claude code agent 227
ef27b6d440 test(#244): cover dictation ordered-emitter + internal-link paste (Phase 2 tail)
Backfill the two genuinely-uncovered infra-free units from the #244 Part B
test backlog (the rest was already covered by #248/#257):

- use-streaming-dictation: the in-order transcription emitter. Drives the
  real hook via renderHook with mocked VAD + deferred transcribeAudio so the
  test controls response order. Asserts out-of-order HTTP responses still
  emit text in segment order; whitespace trimmed and empty results dropped
  while the sequence advances; a failed segment shows one notification and is
  skipped so later segments still flush; a response resolving after cancel()
  is dropped (stale-epoch guard).
- internal-link-paste (handleInternalLink / createMentionAction): validateFn
  reject → no resolve/dispatch; resolve → mention node with the resolved page
  + anchor dispatched via replaceWith at pos; "Untitled" fallback; reject →
  raw url inserted as text under a link mark; createMentionAction wiring to
  getPageById on success + failure.

Test-only; no production code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:07:45 +03:00
10 changed files with 1713 additions and 24 deletions

View File

@@ -0,0 +1,434 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { useRef } from "react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// Stub the comments query so the component renders without react-query/network.
const mockUseCommentsQuery = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useCommentsQuery: (params: { pageId: string }) =>
mockUseCommentsQuery(params),
}));
import CommentHoverPreview from "./comment-hover-preview";
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
const doc = (text: string) =>
JSON.stringify({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
const comment = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
content: doc("Hello world"),
creatorId: "u-1",
pageId: "page-1",
workspaceId: "ws-1",
createdAt: new Date(),
creator: { id: "u-1", name: "User", avatarUrl: null } as any,
...over,
}) as IComment;
function setComments(items: IComment[]) {
mockUseCommentsQuery.mockReturnValue({
data: { items, meta: {} },
isLoading: false,
isError: false,
});
}
// Test harness: owns the container ref, hosts a comment-mark span and the
// preview component, mirroring how page-editor mounts it next to EditorContent.
function Harness({
spanAttrs = { "data-comment-id": "c-1" },
pageId = "page-1",
}: {
spanAttrs?: Record<string, string>;
pageId?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
return (
<MantineProvider>
<div ref={containerRef}>
<span data-testid="mark" className="comment-mark" {...spanAttrs}>
marked text
</span>
<CommentHoverPreview pageId={pageId} containerRef={containerRef} />
</div>
</MantineProvider>
);
}
function hoverMark() {
const span = screen.getByTestId("mark");
act(() => {
span.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
}
function leaveMark() {
const span = screen.getByTestId("mark");
act(() => {
span.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
});
}
describe("commentContentToText", () => {
it("flattens a multi-node ProseMirror doc to plain text", () => {
const content = JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Hello " },
{ type: "text", text: "world" },
],
},
{ type: "paragraph", content: [{ type: "text", text: "Second line" }] },
],
});
expect(commentContentToText(content)).toBe("Hello world\nSecond line");
});
it("joins nested block structures (lists) on block boundaries", () => {
const content = {
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "one" }] },
],
},
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "two" }] },
],
},
],
},
],
};
expect(commentContentToText(content)).toBe("one\ntwo");
});
it("accepts an already-parsed object", () => {
expect(commentContentToText({ type: "doc", content: [] })).toBe("");
});
it("returns '' for empty / missing / malformed content", () => {
expect(commentContentToText("")).toBe("");
expect(commentContentToText(" ")).toBe("");
expect(commentContentToText(undefined)).toBe("");
expect(commentContentToText(null)).toBe("");
expect(commentContentToText(JSON.stringify({ type: "doc", content: [] }))).toBe(
"",
);
});
it("falls back to the raw string when content is not JSON", () => {
expect(commentContentToText("plain text")).toBe("plain text");
});
it("preserves a hardBreak inside a paragraph as a newline", () => {
const content = JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "line1" },
{ type: "hardBreak" },
{ type: "text", text: "line2" },
],
},
],
});
expect(commentContentToText(content)).toBe("line1\nline2");
});
});
describe("CommentHoverPreview — hover behaviour", () => {
beforeEach(() => {
vi.useFakeTimers();
mockUseCommentsQuery.mockReset();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("shows the parent comment text and author after the open delay", () => {
setComments([
comment({
content: doc("Hello world"),
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
}),
]);
render(<Harness />);
hoverMark();
// Before the delay elapses there is no card.
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
act(() => {
vi.advanceTimersByTime(350);
});
const card = screen.getByTestId("comment-hover-preview");
// The line shows "Author: text" — both the author name and the comment text.
expect(card.textContent).toContain("Alice:");
expect(card.textContent).toContain("Hello world");
// The card MUST NOT intercept the mark's click (which opens the side panel):
// pointer-events:none is the single property guaranteeing that — lock it so
// a regression dropping it from the style object fails here.
expect(card.style.pointerEvents).toBe("none");
});
it("renders the whole thread: parent plus replies, each with its author", () => {
setComments([
comment({
id: "c-1",
content: doc("Parent comment"),
createdAt: new Date("2026-01-01T10:00:00Z"),
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
}),
comment({
id: "c-3",
content: doc("Second reply"),
parentCommentId: "c-1",
createdAt: new Date("2026-01-01T12:00:00Z"),
creator: { id: "u-3", name: "Carol", avatarUrl: null } as any,
}),
comment({
id: "c-2",
content: doc("First reply"),
parentCommentId: "c-1",
createdAt: new Date("2026-01-01T11:00:00Z"),
creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
}),
]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
const card = screen.getByTestId("comment-hover-preview");
// Parent and both replies are present, each as "Author: text".
const body = card.textContent ?? "";
expect(body).toContain("Alice: Parent comment");
expect(body).toContain("Bob: First reply");
expect(body).toContain("Carol: Second reply");
// Replies are ordered by createdAt ascending after the parent
// (Parent -> First reply -> Second reply), even though the input was
// out of order (Second reply's comment came before First reply's).
expect(body.indexOf("Parent comment")).toBeLessThan(
body.indexOf("First reply"),
);
expect(body.indexOf("First reply")).toBeLessThan(
body.indexOf("Second reply"),
);
});
it("shows the thread even when the parent text is empty but it has replies", () => {
setComments([
comment({
id: "c-1",
content: JSON.stringify({ type: "doc", content: [] }),
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
}),
comment({
id: "c-2",
content: doc("A reply"),
parentCommentId: "c-1",
createdAt: new Date(),
creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
}),
]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
const card = screen.getByTestId("comment-hover-preview");
expect(card.textContent).toContain("Bob: A reply");
});
it("shows nothing when neither the parent nor its reply has any text", () => {
// The card is gated on rows-with-text (not thread length), so a text-less
// root whose only reply is also text-less must NOT open an empty card.
const emptyDoc = JSON.stringify({ type: "doc", content: [] });
setComments([
comment({
id: "c-1",
content: emptyDoc,
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
}),
comment({
id: "c-2",
content: emptyDoc,
parentCommentId: "c-1",
createdAt: new Date(),
creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
}),
]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("hides on mouseout", () => {
setComments([comment()]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
expect(
screen.getByTestId("comment-hover-preview").textContent,
).toContain("Hello world");
leaveMark();
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("does not show a card for a resolved comment (data-resolved)", () => {
setComments([comment()]);
render(
<Harness
spanAttrs={{ "data-comment-id": "c-1", "data-resolved": "true" }}
/>,
);
hoverMark();
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("does not show a card for a resolved comment (resolvedAt set)", () => {
setComments([comment({ resolvedAt: new Date() })]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("does not show a card for an unknown comment id", () => {
setComments([comment()]);
render(<Harness spanAttrs={{ "data-comment-id": "missing" }} />);
hoverMark();
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("does not show a card when the comment text is empty", () => {
setComments([comment({ content: JSON.stringify({ type: "doc", content: [] }) })]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("hides on scroll", () => {
setComments([comment()]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
expect(
screen.getByTestId("comment-hover-preview").textContent,
).toContain("Hello world");
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("hides on mousedown (clicking the mark to open the panel dismisses the card)", () => {
setComments([comment()]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
expect(
screen.getByTestId("comment-hover-preview").textContent,
).toContain("Hello world");
const span = screen.getByTestId("mark");
act(() => {
span.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
it("does not hide when the pointer moves WITHIN the same span (anti-flicker)", () => {
setComments([comment()]);
render(<Harness />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
// mouseout whose relatedTarget is still inside the span must NOT hide.
const span = screen.getByTestId("mark");
act(() => {
span.dispatchEvent(
new MouseEvent("mouseout", { bubbles: true, relatedTarget: span }),
);
});
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
});
it("hides when the page changes", () => {
setComments([comment()]);
const { rerender } = render(<Harness pageId="page-1" />);
hoverMark();
act(() => {
vi.advanceTimersByTime(350);
});
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
act(() => {
rerender(<Harness pageId="page-2" />);
});
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
});
});

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Paper, Text } from "@mantine/core";
import { useCommentsQuery } from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
interface CommentHoverPreviewProps {
pageId: string;
containerRef: React.RefObject<HTMLElement>;
}
// Delay before the card appears, to avoid flicker when the pointer quickly
// passes over comment marks (kept generous so it does not pop up on a passing
// glance).
const OPEN_DELAY_MS = 350;
const CARD_MAX_WIDTH = 360;
const CARD_MAX_HEIGHT = 300;
const GAP = 6;
// Reserve roughly this much room below the span; flip above when it doesn't fit.
// Match CARD_MAX_HEIGHT so the flip-above decision reserves the real worst-case
// height — otherwise a tall thread placed below near the viewport bottom passes
// the "fits below" check and then overflows off-screen (clipped, no scroll).
const ESTIMATED_CARD_HEIGHT = 300;
// One rendered line of the thread: the author and the comment's plain text,
// pre-computed at hover time so render stays cheap. Shown as "Author: text".
interface ThreadRow {
id: string;
name: string;
text: string;
}
interface HoverState {
thread: ThreadRow[];
rect: { top: number; bottom: number; left: number };
}
function isResolved(comment: IComment): boolean {
return comment.resolvedAt != null || comment.resolvedById != null;
}
// Build the thread for a root (parent) comment: the root first, followed by its
// replies sorted by createdAt ascending. Reads every comment from the map.
function buildThread(
commentMap: Map<string, IComment>,
root: IComment,
): ThreadRow[] {
const replies: IComment[] = [];
commentMap.forEach((comment) => {
if (comment.parentCommentId === root.id) replies.push(comment);
});
replies.sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return [root, ...replies].map((comment) => ({
id: comment.id,
name: comment.creator?.name ?? "",
text: commentContentToText(comment.content),
}));
}
/**
* Shows a small floating card when the user hovers a `.comment-mark` span in the
* main editor: the parent comment plus all its replies, one per line as
* "Author: text" (plain — no avatars or timestamps). Read-only:
* `pointer-events: none` so it never intercepts the mark's click (which opens
* the side panel via ACTIVE_COMMENT_EVENT). Resolved/unknown marks show nothing.
*/
export default function CommentHoverPreview({
pageId,
containerRef,
}: CommentHoverPreviewProps) {
const { data } = useCommentsQuery({ pageId });
// Map of commentId -> comment. The map indexes every comment (parents and
// replies) so a thread can be assembled from a single source.
const commentMap = useMemo(() => {
const map = new Map<string, IComment>();
data?.items?.forEach((comment) => map.set(comment.id, comment));
return map;
}, [data]);
// Read the latest map from the delegated listeners without re-attaching them
// every time the comments query refreshes.
const commentMapRef = useRef(commentMap);
useEffect(() => {
commentMapRef.current = commentMap;
}, [commentMap]);
const [hover, setHover] = useState<HoverState | null>(null);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeSpanRef = useRef<HTMLElement | null>(null);
const clearOpenTimer = () => {
if (openTimerRef.current !== null) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
};
const hide = () => {
clearOpenTimer();
activeSpanRef.current = null;
setHover(null);
};
// Hide and reset when the page changes (the comment set belongs to a page):
// the cleanup runs on every pageId change before the effect re-runs.
useEffect(() => {
return () => hide();
}, [pageId]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleMouseOver = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const span = target?.closest<HTMLElement>(
".comment-mark[data-comment-id]",
);
if (!span) return;
const commentId = span.getAttribute("data-comment-id");
if (!commentId) return;
const comment = commentMapRef.current.get(commentId);
// Unknown (not loaded yet) or resolved -> no tooltip. Resolved marks also
// carry data-resolved="true"; check both the data attribute and the model.
if (
!comment ||
span.hasAttribute("data-resolved") ||
isResolved(comment)
) {
return;
}
// Already tracking this span: nothing to do (avoids re-building the thread
// on every intra-span mousemove).
if (span === activeSpanRef.current) return;
const thread = buildThread(commentMapRef.current, comment);
// Show the card only when SOME comment has text. Gating on thread length
// could open an empty card (a text-less root whose only reply is also
// text-less), since the render filters out empty-text rows.
const hasContent = thread.some((row) => row.text.length > 0);
if (!hasContent) return;
activeSpanRef.current = span;
clearOpenTimer();
openTimerRef.current = setTimeout(() => {
openTimerRef.current = null;
if (activeSpanRef.current !== span || !span.isConnected) return;
const rect = span.getBoundingClientRect();
setHover({
thread,
rect: { top: rect.top, bottom: rect.bottom, left: rect.left },
});
}, OPEN_DELAY_MS);
};
const handleMouseOut = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const span = target?.closest<HTMLElement>(
".comment-mark[data-comment-id]",
);
if (!span) return;
// Ignore moves that stay within the same comment-mark span.
const related = event.relatedTarget as HTMLElement | null;
if (related && span.contains(related)) return;
if (span === activeSpanRef.current) hide();
};
// Scroll uses capture so it also catches scrolling inside nested containers.
const handleScroll = () => hide();
const handleResize = () => hide();
// Dismiss on press: clicking a mark opens the side panel, and the card
// would otherwise linger (no mouseout fires while the pointer stays put).
const handleMouseDown = () => hide();
container.addEventListener("mouseover", handleMouseOver);
container.addEventListener("mouseout", handleMouseOut);
container.addEventListener("mousedown", handleMouseDown);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
container.removeEventListener("mouseover", handleMouseOver);
container.removeEventListener("mouseout", handleMouseOut);
container.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
clearOpenTimer();
};
}, [containerRef]);
if (!hover) return null;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Flip above when there isn't enough room below the span.
const placeAbove =
hover.rect.bottom + ESTIMATED_CARD_HEIGHT > viewportHeight &&
hover.rect.top > ESTIMATED_CARD_HEIGHT;
const left = Math.max(
8,
Math.min(hover.rect.left, viewportWidth - CARD_MAX_WIDTH - 8),
);
const positionStyle: React.CSSProperties = placeAbove
? { bottom: viewportHeight - hover.rect.top + GAP }
: { top: hover.rect.bottom + GAP };
return createPortal(
<Paper
withBorder
shadow="md"
radius="sm"
role="tooltip"
data-testid="comment-hover-preview"
style={{
position: "fixed",
left,
...positionStyle,
zIndex: 1000,
maxWidth: CARD_MAX_WIDTH,
// The card is pointer-events:none, so it can't scroll; clamp long
// threads instead (most threads are short).
maxHeight: CARD_MAX_HEIGHT,
overflow: "hidden",
padding: "8px 10px",
fontSize: "13px",
lineHeight: 1.4,
// Never intercept clicks targeting the comment-mark span beneath.
pointerEvents: "none",
wordBreak: "break-word",
}}
>
{hover.thread
// A comment with no plain text (e.g. an image-only reply) adds nothing
// to a text preview — skip its line.
.filter((row) => row.text.length > 0)
.map((row) => (
<Text
key={row.id}
size="xs"
mt={4}
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{/* "Author: text" — one line per comment, parent then replies. */}
<Text span fw={600}>
{row.name}:
</Text>{" "}
{row.text}
</Text>
))}
</Paper>,
document.body,
);
}

View File

@@ -0,0 +1,71 @@
/**
* Flatten a comment's ProseMirror JSON document to plain text.
*
* `IComment.content` is stored as a stringified ProseMirror doc, but this also
* accepts an already-parsed object. Walks the node tree, concatenating `text`
* leaves and joining text-bearing blocks with newlines. Missing, empty or
* malformed content yields an empty string (never throws).
*/
export function commentContentToText(content: unknown): string {
let doc: any = content;
if (typeof content === "string") {
const trimmed = content.trim();
if (!trimmed) return "";
try {
doc = JSON.parse(trimmed);
} catch {
// Not JSON — fall back to treating the raw string as plain text.
return trimmed;
}
}
if (!doc || typeof doc !== "object") return "";
const blocks: string[] = [];
const walk = (node: any): void => {
if (!node || typeof node !== "object") return;
if (typeof node.text === "string") {
// Inline text leaf: append to the current block line.
if (blocks.length === 0) blocks.push("");
blocks[blocks.length - 1] += node.text;
return;
}
if (node.type === "hardBreak") {
// A soft line break inside a block: keep the newline so the two halves
// do not run together.
if (blocks.length === 0) blocks.push("");
blocks[blocks.length - 1] += "\n";
return;
}
const children = Array.isArray(node.content) ? node.content : [];
const containsText = children.some(
(child: any) =>
child && typeof child === "object" && typeof child.text === "string",
);
if (containsText) {
// Text-bearing block (paragraph, heading, ...): start a fresh line, then
// collect its inline text.
blocks.push("");
children.forEach(walk);
return;
}
// Structural container (doc, list, blockquote, ...): recurse so each nested
// text block becomes its own line.
children.forEach(walk);
};
walk(doc);
return blocks
.map((block) => block.trim())
.filter((block) => block.length > 0)
.join("\n")
.trim();
}

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// Shared, hoisted test state the module mocks write into. `onSpeechEnd` is the
// VAD callback the hook registers on MicVAD.new — capturing it lets us drive
// "a speech segment ended" deterministically. `pending` collects the deferred
// transcription promises so the test controls their resolution order, which is
// the whole point: out-of-order HTTP responses must NOT scramble the emitted
// text (the in-order emitter under test).
const h = vi.hoisted(() => {
return {
onSpeechEnd: null as null | ((audio: Float32Array) => void),
pending: [] as { resolve: (s: string) => void; reject: (e: unknown) => void }[],
notify: null as null | ReturnType<typeof Object>,
};
});
// Lazy-imported VAD: capture the onSpeechEnd handler and hand back a no-op
// instance (start/pause/destroy all resolve).
vi.mock("@ricky0123/vad-web", () => ({
MicVAD: {
new: vi.fn(async (opts: { onSpeechEnd: (a: Float32Array) => void }) => {
h.onSpeechEnd = opts.onSpeechEnd;
return {
start: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
destroy: vi.fn(async () => {}),
};
}),
},
}));
// Each transcribeAudio call returns a promise we resolve/reject by index.
vi.mock("@/features/dictation/services/dictation-service", () => ({
transcribeAudio: vi.fn(
() =>
new Promise<string>((resolve, reject) => {
h.pending.push({ resolve, reject });
}),
),
}));
// Avoid real WAV encoding; the segment payload is irrelevant to ordering.
vi.mock("@/features/dictation/utils/encode-wav", () => ({
encodeWavPcm16: vi.fn(() => new Blob()),
}));
const notifyShow = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (...args: unknown[]) => notifyShow(...args) },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (s: string) => s }),
}));
import { useStreamingDictation } from "./use-streaming-dictation";
// jsdom has no AudioContext; the hook constructs one and calls resume(). A
// trivial stub is enough — the real audio path is irrelevant to ordering.
class FakeAudioContext {
state = "running";
resume() {
return Promise.resolve();
}
close() {
this.state = "closed";
return Promise.resolve();
}
}
async function startRecording(onText: (t: string) => void) {
const hook = renderHook(() => useStreamingDictation({ onText }));
await act(async () => {
await hook.result.current.start();
});
// The VAD registered its onSpeechEnd and start() resolved into "recording".
expect(h.onSpeechEnd).toBeTypeOf("function");
expect(hook.result.current.status).toBe("recording");
return hook;
}
// Fire N ended speech segments (seq 0..N-1), each kicking off one transcription.
async function emitSegments(n: number) {
await act(async () => {
for (let i = 0; i < n; i++) h.onSpeechEnd!(new Float32Array(8));
});
}
describe("useStreamingDictation — in-order segment emitter", () => {
beforeEach(() => {
vi.clearAllMocks();
h.onSpeechEnd = null;
h.pending = [];
notifyShow.mockClear();
(window as unknown as { AudioContext: unknown }).AudioContext =
FakeAudioContext;
});
it("emits transcriptions in segment order even when responses resolve out of order", async () => {
const emitted: string[] = [];
await startRecording((t) => emitted.push(t));
await emitSegments(3);
expect(h.pending).toHaveLength(3);
// Resolve seq 1 FIRST: it must be buffered, not emitted, because seq 0 is
// still outstanding (nextEmit == 0).
await act(async () => {
h.pending[1].resolve("second");
});
expect(emitted).toEqual([]);
// Resolve seq 0: this unblocks the buffer and flushes 0 then 1 in order.
await act(async () => {
h.pending[0].resolve("first");
});
expect(emitted).toEqual(["first", "second"]);
// seq 2 resolves last and flushes immediately (it is now next).
await act(async () => {
h.pending[2].resolve("third");
});
expect(emitted).toEqual(["first", "second", "third"]);
});
it("trims whitespace and drops empty/whitespace-only transcriptions while still advancing", async () => {
const emitted: string[] = [];
await startRecording((t) => emitted.push(t));
await emitSegments(3);
await act(async () => {
h.pending[0].resolve(" hello "); // leading/trailing space trimmed
h.pending[1].resolve(" "); // whitespace-only -> not emitted, but seq advances
h.pending[2].resolve("world");
});
expect(emitted).toEqual(["hello", "world"]);
});
it("a failed segment shows one notification and is skipped so later segments still flush in order", async () => {
const emitted: string[] = [];
await startRecording((t) => emitted.push(t));
await emitSegments(2);
// seq 0 fails: the user sees a notification and the emitter advances past it.
await act(async () => {
h.pending[0].reject({ message: "boom" });
});
expect(notifyShow).toHaveBeenCalledTimes(1);
expect(emitted).toEqual([]);
// seq 1 still flushes (it is now next), proving one failure did not stall.
await act(async () => {
h.pending[1].resolve("survivor");
});
expect(emitted).toEqual(["survivor"]);
});
it("an OUT-OF-ORDER failed segment is buffered as empty and skipped without stalling later text", async () => {
const emitted: string[] = [];
await startRecording((t) => emitted.push(t));
await emitSegments(3);
// seq 1 (NOT next-to-emit) fails first: it takes the else branch — an empty
// placeholder is buffered (resultsRef.set(seq, "")) so the emitter can later
// skip it. One notification, nothing emitted yet (seq 0 still gates).
await act(async () => {
h.pending[1].reject({ message: "boom" });
});
expect(notifyShow).toHaveBeenCalledTimes(1);
expect(emitted).toEqual([]);
// seq 0 flushes; the drain then reaches the buffered empty seq 1 and SKIPS
// past it to seq 2.
await act(async () => {
h.pending[0].resolve("alpha");
});
expect(emitted).toEqual(["alpha"]);
// seq 2 emits — proving the empty placeholder let the emitter advance past
// the failed seq 1. Without the else branch's placeholder the drain would
// stall at the missing seq 1 and "gamma" would never flush.
await act(async () => {
h.pending[2].resolve("gamma");
});
expect(emitted).toEqual(["alpha", "gamma"]);
});
it("ignores a transcription that resolves AFTER cancel() (stale epoch — no emit)", async () => {
const emitted: string[] = [];
const hook = await startRecording((t) => emitted.push(t));
await emitSegments(1);
// Hard discard the session: the in-flight request is now stale.
act(() => {
hook.result.current.cancel();
});
expect(hook.result.current.status).toBe("idle");
// Its late resolution must be dropped (no emit into the new/empty session).
await act(async () => {
h.pending[0].resolve("late");
});
expect(emitted).toEqual([]);
});
});

View File

@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the page-service so importing the module under test does not pull in the
// axios/api-client chain. `createMentionAction` is wired to `getPageById`; the
// spy lets us assert that wiring without any network. `vi.hoisted` keeps the spy
// available inside the hoisted vi.mock factory.
const { getPageById } = vi.hoisted(() => ({ getPageById: vi.fn() }));
vi.mock("@/features/page/services/page-service.ts", () => ({
getPageById,
}));
// `uuid` v7 is used for the mention node id; pin only v7 so assertions are
// stable, keeping the rest (e.g. `validate`, used by extractPageSlugId) real.
vi.mock("uuid", async (importOriginal) => ({
...(await importOriginal<typeof import("uuid")>()),
v7: () => "fixed-mention-uuid",
}));
import {
handleInternalLink,
createMentionAction,
} from "./internal-link-paste";
// Minimal ProseMirror-ish EditorView fake. We record what handleInternalLink
// builds and dispatches without standing up a real schema/state.
function makeView() {
const tr = {
replaceWith: vi.fn(function (this: unknown) {
return tr;
}),
insertText: vi.fn(function (this: unknown) {
return tr;
}),
addMark: vi.fn(function (this: unknown) {
return tr;
}),
};
const schema = {
nodes: {
mention: {
// Echo the attrs back so we can assert exactly what was created.
create: vi.fn((attrs: Record<string, unknown>) => ({
type: "mention",
attrs,
})),
},
},
marks: {
link: {
create: vi.fn((attrs: Record<string, unknown>) => ({
type: "link",
attrs,
})),
},
},
};
const view = {
state: { schema, tr },
dispatch: vi.fn(),
};
return { view, tr, schema };
}
describe("handleInternalLink", () => {
beforeEach(() => vi.clearAllMocks());
it("does nothing when validateFn rejects the url (no resolve, no dispatch)", async () => {
const onResolveLink = vi.fn();
const validateFn = vi.fn(() => false);
const { view } = makeView();
await handleInternalLink({ validateFn, onResolveLink })(
"any-url",
view as never,
3,
"creator-1",
);
expect(validateFn).toHaveBeenCalledWith("any-url", view);
expect(onResolveLink).not.toHaveBeenCalled();
expect(view.dispatch).not.toHaveBeenCalled();
});
it("on resolve: inserts a mention node carrying the resolved page + anchor and dispatches replaceWith at pos", async () => {
const page = {
id: "page-id-99",
title: "My Page",
slugId: "slugABC",
};
const onResolveLink = vi.fn().mockResolvedValue(page);
const { view, tr, schema } = makeView();
// extractPageSlugId("doc-slug-xyz789") -> "xyz789" (last hyphen segment).
await handleInternalLink({ validateFn: () => true, onResolveLink })(
"doc-slug-xyz789",
view as never,
5,
"creator-7",
"anchor-42",
);
// The linked page id is the extracted slug-id, not the whole url.
expect(onResolveLink).toHaveBeenCalledWith("xyz789", "creator-7");
expect(schema.nodes.mention.create).toHaveBeenCalledWith({
id: "fixed-mention-uuid",
label: "My Page",
entityType: "page",
entityId: "page-id-99",
slugId: "slugABC",
creatorId: "creator-7",
anchorId: "anchor-42",
});
expect(tr.replaceWith).toHaveBeenCalledWith(5, 5, {
type: "mention",
attrs: expect.objectContaining({ entityId: "page-id-99" }),
});
expect(tr.insertText).not.toHaveBeenCalled();
expect(view.dispatch).toHaveBeenCalledTimes(1);
expect(view.dispatch).toHaveBeenCalledWith(tr);
});
it("falls back to 'Untitled' label when the resolved page has no title", async () => {
const onResolveLink = vi
.fn()
.mockResolvedValue({ id: "p", title: "", slugId: "s" });
const { view, schema } = makeView();
await handleInternalLink({ validateFn: () => true, onResolveLink })(
"abc-id1",
view as never,
0,
"c",
);
expect(schema.nodes.mention.create).toHaveBeenCalledWith(
expect.objectContaining({ label: "Untitled" }),
);
});
it("on reject: inserts the raw url as plain text with a link mark and dispatches", async () => {
const onResolveLink = vi.fn().mockRejectedValue(new Error("not found"));
const { view, tr, schema } = makeView();
await handleInternalLink({ validateFn: () => true, onResolveLink })(
"http://x/page-id2",
view as never,
4,
"creator-1",
);
// No mention node on the failure path.
expect(schema.nodes.mention.create).not.toHaveBeenCalled();
expect(tr.insertText).toHaveBeenCalledWith("http://x/page-id2", 4);
expect(schema.marks.link.create).toHaveBeenCalledWith({
href: "http://x/page-id2",
});
// Mark spans exactly the inserted url text: [pos, pos + url.length].
expect(tr.addMark).toHaveBeenCalledWith(4, 4 + "http://x/page-id2".length, {
type: "link",
attrs: { href: "http://x/page-id2" },
});
expect(view.dispatch).toHaveBeenCalledTimes(1);
});
});
describe("createMentionAction", () => {
beforeEach(() => vi.clearAllMocks());
it("resolves the link via getPageById and inserts the mention", async () => {
getPageById.mockResolvedValue({
id: "real-page",
title: "Real",
slugId: "rslug",
});
const { view, schema } = makeView();
await createMentionAction("ref-pageABC", view as never, 2, "creator-9");
expect(getPageById).toHaveBeenCalledWith({ pageId: "pageABC" });
expect(schema.nodes.mention.create).toHaveBeenCalledWith(
expect.objectContaining({ entityId: "real-page", label: "Real" }),
);
});
it("propagates a getPageById failure to the plain-link fallback", async () => {
getPageById.mockRejectedValue(new Error("404"));
const { view, tr } = makeView();
await createMentionAction("ref-pageABC", view as never, 1, "creator-9");
// Failure path: the url is inserted as text, not as a mention node.
expect(tr.insertText).toHaveBeenCalledWith("ref-pageABC", 1);
});
});

View File

@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useScrollPosition } from "./use-scroll-position";
const KEY_PREFIX = "gitmost:scroll-position:";
function setScrollY(value: number): void {
Object.defineProperty(window, "scrollY", {
configurable: true,
value,
});
}
function setScrollHeight(value: number): void {
Object.defineProperty(document.documentElement, "scrollHeight", {
configurable: true,
value,
});
}
function setInnerHeight(value: number): void {
Object.defineProperty(window, "innerHeight", {
configurable: true,
value,
});
}
describe("useScrollPosition", () => {
beforeEach(() => {
window.sessionStorage.clear();
setScrollY(0);
setScrollHeight(0);
setInnerHeight(800);
// jsdom does not implement window.scrollTo; stub it.
window.scrollTo = vi.fn();
// Ensure no anchor leaks between tests.
window.location.hash = "";
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
window.location.hash = "";
});
it("(a) saves window.scrollY to sessionStorage under the pageId key, throttled", () => {
vi.useFakeTimers();
const { unmount } = renderHook(() => useScrollPosition("p1"));
// Leading-edge save fires immediately.
setScrollY(123);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(window.sessionStorage.getItem(`${KEY_PREFIX}p1`)).toBe("123");
// Within the throttle window the next scroll is suppressed.
setScrollY(456);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(window.sessionStorage.getItem(`${KEY_PREFIX}p1`)).toBe("123");
// After the throttle window elapses, the next scroll persists again.
act(() => {
vi.advanceTimersByTime(250);
});
setScrollY(789);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(window.sessionStorage.getItem(`${KEY_PREFIX}p1`)).toBe("789");
unmount();
});
it("(a2) the restore target is captured at mount and survives a fresh scroll@0 clobber", () => {
vi.useFakeTimers();
// A previous session saved 500.
window.sessionStorage.setItem(`${KEY_PREFIX}clob`, "500");
const { result } = renderHook(() => useScrollPosition("clob"));
// On load the page is at the top; a scroll@0 fires and overwrites storage
// with 0. This is exactly the clobber the synchronous mount-capture defends
// against: the stored value becomes "0", but the target was already captured.
setScrollY(0);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(window.sessionStorage.getItem(`${KEY_PREFIX}clob`)).toBe("0");
// Restore still scrolls to 500 (the captured target), NOT the clobbered 0.
// If the capture were moved into an effect (after handlers register), it
// would read the clobbered 0 and this assertion would fail.
setScrollHeight(2000); // maxScroll = 1200 >= 500
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(a3) restores at most once per mount even if called again", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("once"));
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
// restoreScrollPosition]) must NOT scroll again and yank the reader.
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
});
it("(b) does not restore when the URL has a #hash anchor", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p2`, "500");
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500), so
// without the hash guard tryRestore would call scrollTo synchronously on the
// first tick. The assertion below therefore genuinely proves the hash guard
// short-circuits before any scroll (not just that the poll has not fired).
setScrollHeight(2000);
window.location.hash = "#some-heading";
const { result } = renderHook(() => useScrollPosition("p2"));
act(() => {
result.current.restoreScrollPosition();
vi.advanceTimersByTime(5000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(f) cancels the in-flight restore poll on unmount (no scroll on the next page)", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p7`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
const { result, unmount } = renderHook(() => useScrollPosition("p7"));
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
// Navigate away (the hook unmounts) BEFORE the content grows tall enough.
unmount();
// Content of the NEXT page becomes tall; advancing time must NOT resurrect
// the cancelled poll (without the cleanup it would scroll the new page).
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
// Nothing saved.
const a = renderHook(() => useScrollPosition("nope"));
act(() => {
a.result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
// Saved value <= 0.
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
const b = renderHook(() => useScrollPosition("zero"));
act(() => {
b.result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(d) scrolls to the saved Y once the content is tall enough", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p4`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700, target not yet reachable.
const { result } = renderHook(() => useScrollPosition("p4"));
act(() => {
result.current.restoreScrollPosition();
});
// Still polling: content not laid out yet.
expect(window.scrollTo).not.toHaveBeenCalled();
// Content becomes tall enough: maxScroll = 2000 - 800 = 1200 >= 500.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(100);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(d2) clamps to the max reachable position after the timeout", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}p5`, "5000");
setInnerHeight(800);
setScrollHeight(1000); // maxScroll stays 200, never reaches 5000.
const { result } = renderHook(() => useScrollPosition("p5"));
act(() => {
result.current.restoreScrollPosition();
});
// Advance past the 5s timeout; restore should fire clamped to maxScroll.
act(() => {
vi.advanceTimersByTime(5000);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
});
it("(e) never throws when storage access throws", () => {
const err = new Error("storage denied");
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
throw err;
});
vi.spyOn(window.sessionStorage, "setItem").mockImplementation(() => {
throw err;
});
expect(() => {
const { result, unmount } = renderHook(() => useScrollPosition("p6"));
act(() => {
setScrollY(42);
window.dispatchEvent(new Event("scroll"));
result.current.restoreScrollPosition();
});
unmount();
}).not.toThrow();
});
});

View File

@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef } from "react";
// Throttle interval for persisting the scroll position while the user reads.
const SAVE_THROTTLE_MS = 250;
// Give up polling for the live content height after this long and restore to
// the furthest reachable position (handles "collab never finishes laying out").
const MAX_RESTORE_WAIT_MS = 5000;
// How often to re-check the document height while waiting for content to load.
const RESTORE_POLL_MS = 100;
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
// is cleared on tab close, which is exactly the lifetime we want for an MVP
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
const STORAGE_PREFIX = "gitmost:scroll-position:";
function storageKey(pageId: string): string {
return `${STORAGE_PREFIX}${pageId}`;
}
// All storage access is wrapped: private mode / quota / disabled storage must
// never throw out of the hook and break the page.
function readStorage(pageId: string): number | null {
try {
const raw = window.sessionStorage.getItem(storageKey(pageId));
if (raw === null) return null;
const value = Number.parseInt(raw, 10);
return Number.isFinite(value) ? value : null;
} catch (err) {
// Best-effort feature: storage may be unavailable (private mode / quota).
// No user-facing notification (a missed scroll restore is not actionable),
// but log per the AGENTS.md "errors must never be swallowed" rule.
console.warn("[useScrollPosition] sessionStorage read failed", err);
return null;
}
}
function writeStorage(pageId: string, scrollY: number): void {
try {
window.sessionStorage.setItem(storageKey(pageId), String(Math.round(scrollY)));
} catch (err) {
// Storage unavailable (private mode / quota). Non-actionable for the user,
// but log it rather than swallow silently (AGENTS.md error-handling rule).
console.warn("[useScrollPosition] sessionStorage write failed", err);
}
}
/**
* Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document.
*
* Returns `restoreScrollPosition`, which the page editor calls once the live
* (non-static) content is laid out. The two scroll mechanisms are mutually
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
* wins and restore is a no-op.
*/
export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void;
} {
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
// (refs would hold the first page's target / already-restored flag) — in that
// case the refs must be reset on a pageId change.
//
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
// handler can overwrite the stored value with a fresh 0 (the page starts
// scrolled to top on load). `null` means "not yet captured".
const initialTargetRef = useRef<number | null>(null);
// Guards so restore runs at most once per page mount.
const hasRestoredRef = useRef(false);
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
const pollTimerRef = useRef<number | null>(null);
// Capture the previously-saved value synchronously during render, before the
// effect below registers handlers that would persist the current (0) scrollY.
if (initialTargetRef.current === null) {
const saved = readStorage(pageId);
// Store 0 when nothing is saved so the "already captured" check (!== null)
// holds; restore treats targetY <= 0 as a no-op anyway.
initialTargetRef.current = saved ?? 0;
}
useEffect(() => {
let throttleTimer: number | null = null;
const save = () => {
writeStorage(pageId, window.scrollY);
};
// Throttle the high-frequency scroll handler: persist immediately on the
// leading edge, then at most once per SAVE_THROTTLE_MS.
const onScroll = () => {
if (throttleTimer !== null) return;
save();
throttleTimer = window.setTimeout(() => {
throttleTimer = null;
}, SAVE_THROTTLE_MS);
};
// pagehide fires on reload/navigation (more reliable than unload); save now.
const onPageHide = () => {
save();
};
// Save when the tab is being backgrounded — covers mobile where pagehide is
// not always emitted.
const onVisibilityChange = () => {
if (document.visibilityState === "hidden") {
save();
}
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibilityChange);
if (throttleTimer !== null) {
window.clearTimeout(throttleTimer);
throttleTimer = null;
}
// Cancel any in-flight restore poll so it cannot scroll the next page.
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
// SPA navigation away from this page: persist the final position.
save();
};
}, [pageId]);
const restoreScrollPosition = useCallback(() => {
// Run at most once per page mount.
if (hasRestoredRef.current) return;
hasRestoredRef.current = true;
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
if (window.location.hash) return;
const targetY = initialTargetRef.current ?? 0;
// Nothing meaningful to restore to.
if (targetY <= 0) return;
const start = Date.now();
const tryRestore = () => {
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
// Restore once the content is tall enough to reach the target, or bail out
// after the timeout and scroll as far as currently possible.
if (maxScroll >= targetY || timedOut) {
window.scrollTo({
top: Math.min(targetY, Math.max(maxScroll, 0)),
behavior: "auto",
});
pollTimerRef.current = null;
return;
}
// Stored in a ref so the effect cleanup can cancel it on unmount.
pollTimerRef.current = window.setTimeout(tryRestore, RESTORE_POLL_MS);
};
tryRestore();
}, []);
return { restoreScrollPosition };
}

View File

@@ -42,6 +42,7 @@ import {
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog";
import CommentHoverPreview from "@/features/comment/components/comment-hover-preview";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
@@ -77,6 +78,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { useScrollPosition } from "./hooks/use-scroll-position";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
@@ -141,6 +143,7 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
const { restoreScrollPosition } = useScrollPosition(pageId);
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
@@ -479,6 +482,11 @@ export default function PageEditor({
}
}, [yjsConnectionStatus, isSynced]);
// Restore the saved reading position once the live content is laid out.
useEffect(() => {
if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]);
return (
<TransclusionLookupProvider>
<PageEmbedLookupProvider>
@@ -526,6 +534,11 @@ export default function PageEditor({
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
<CommentHoverPreview
pageId={pageId}
containerRef={menuContainerRef}
/>
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}

View File

@@ -77,7 +77,9 @@ describe('resolveKeyField (write-only key payload)', () => {
describe('nextReindexPollInterval', () => {
const INTERVAL = 5000;
const base = { now: 1_000, intervalMs: INTERVAL };
// `seenActive: true` is the steady state for most of a run — a poll has
// observed `reindexing === true` (the server pre-seeds it from enqueue time).
const base = { now: 1_000, intervalMs: INTERVAL, seenActive: true };
it('does not poll when no reindex deadline is set', () => {
expect(
@@ -111,7 +113,7 @@ describe('nextReindexPollInterval', () => {
).toBe(INTERVAL);
});
it('stops once the run is finished AND fully indexed', () => {
it('stops once the run is finished AND fully indexed (after having been active)', () => {
expect(
nextReindexPollInterval({
...base,
@@ -121,11 +123,29 @@ describe('nextReindexPollInterval', () => {
).toBe(false);
});
it('does NOT stop on the stale pre-reindex snapshot (fully indexed, never seen active)', () => {
// Regression for #262: right after "Reindex now" the client still holds the
// PRE-reindex settings (an already fully-indexed workspace reads as
// reindexing=false, indexed>=total). Without the seenActive gate this looked
// "done" and stopped polling on the very first tick, freezing the counter at
// 0 until a manual reload. The fresh window has not observed the active run,
// so polling must continue until the first real poll lands.
expect(
nextReindexPollInterval({
...base,
seenActive: false,
deadline: 10_000,
status: { reindexing: false, indexedPages: 478, totalPages: 478 },
}),
).toBe(INTERVAL);
});
it('keeps polling within the deadline when not yet done and no active flag', () => {
// First poll right after enqueue, before the worker publishes progress.
expect(
nextReindexPollInterval({
...base,
seenActive: false,
deadline: 10_000,
status: { reindexing: false, indexedPages: 0, totalPages: 478 },
}),
@@ -138,12 +158,15 @@ describe('nextReindexPollInterval', () => {
deadline: 1_000,
now: 2_000, // past the deadline
intervalMs: INTERVAL,
seenActive: true,
status: { reindexing: true, indexedPages: 200, totalPages: 478 },
}),
).toBe(false);
});
it('stops on an empty workspace (0 of 0) once the run is finished', () => {
// The pre-seed publishes reindexing=true even for 0 pages, so a poll sees the
// run active before the worker clears -> seenActive latches true.
expect(
nextReindexPollInterval({
...base,
@@ -156,26 +179,46 @@ describe('nextReindexPollInterval', () => {
describe('isReindexComplete', () => {
it('false when no status yet', () => {
expect(isReindexComplete(undefined)).toBe(false);
expect(isReindexComplete(undefined, true)).toBe(false);
});
it('false while a run is still active (even at indexed==total)', () => {
expect(
isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }),
isReindexComplete(
{ reindexing: true, indexedPages: 478, totalPages: 478 },
true,
),
).toBe(false);
});
it('false when finished but not yet fully indexed', () => {
expect(
isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }),
isReindexComplete(
{ reindexing: false, indexedPages: 120, totalPages: 478 },
true,
),
).toBe(false);
});
it('true once finished and fully indexed', () => {
it('true once finished and fully indexed (after having been active)', () => {
expect(
isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }),
isReindexComplete(
{ reindexing: false, indexedPages: 478, totalPages: 478 },
true,
),
).toBe(true);
});
it('false on the stale pre-reindex snapshot: finished+fully indexed but never seen active', () => {
// The just-started edge: the gate keeps this from clearing the poll deadline
// before the first post-reindex poll arrives.
expect(
isReindexComplete(
{ reindexing: false, indexedPages: 478, totalPages: 478 },
false,
),
).toBe(false);
});
});
describe('isReindexButtonLoading', () => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod/v4";
import {
ActionIcon,
@@ -185,14 +185,23 @@ type ReindexStatus = Pick<
* has finished AND everything is indexed (server cleared its progress record and
* fell back to the DB coverage count), or the deadline cap is hit — the cap
* always wins so a stuck/never-clearing progress record can't poll forever.
*
* `seenActive` guards the just-started window: right after "Reindex now" the
* client still holds the PRE-reindex settings snapshot, which for an already
* fully-indexed workspace reads as `reindexing=false, indexed>=total`. Treating
* that stale snapshot as "done" would stop polling before the first post-reindex
* poll ever lands (counter frozen at 0). So completion is only honored once a
* poll has actually observed the active run (the enqueue-time pre-seed makes
* `reindexing=true` visible from the first poll until the run truly clears).
*/
export function nextReindexPollInterval(args: {
deadline: number | null;
now: number;
intervalMs: number;
status?: ReindexStatus;
seenActive: boolean;
}): number | false {
const { deadline, now, intervalMs, status } = args;
const { deadline, now, intervalMs, status, seenActive } = args;
if (deadline === null) return false;
// Cap always wins.
if (now > deadline) return false;
@@ -200,20 +209,33 @@ export function nextReindexPollInterval(args: {
if (status?.reindexing) return intervalMs;
// Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse
// isReindexComplete so the completeness check lives in exactly one place.
if (isReindexComplete(status)) return false;
if (isReindexComplete(status, seenActive)) return false;
// Within the deadline and not yet done → keep polling.
return intervalMs;
}
/**
* Whether the reindex poll deadline should be cleared: the server reports no
* active run AND the count is complete. The single source of truth for the
* "reindex finished" check — `nextReindexPollInterval` reuses it for its stop
* condition (sans the cap, which the effect handles via time).
* Whether the reindex poll deadline should be cleared: a poll has observed the
* active run (`seenActive`) AND the server now reports no active run AND the
* count is complete. The single source of truth for the "reindex finished"
* check — `nextReindexPollInterval` reuses it for its stop condition (sans the
* cap, which the effect handles via time).
*
* The `seenActive` requirement is what keeps the STALE pre-reindex snapshot
* (already fully indexed → `reindexing=false, indexed>=total`) from being read
* as "finished" in the window before the first post-reindex poll arrives. Once
* a poll has seen `reindexing=true` (guaranteed by the server's enqueue-time
* pre-seed for the whole run), this flips to a genuine completion check.
*/
export function isReindexComplete(status?: ReindexStatus): boolean {
export function isReindexComplete(
status: ReindexStatus | undefined,
seenActive: boolean,
): boolean {
return (
!!status && !status.reindexing && status.indexedPages >= status.totalPages
seenActive &&
!!status &&
!status.reindexing &&
status.indexedPages >= status.totalPages
);
}
@@ -290,6 +312,14 @@ export default function AiProviderSettings() {
const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
// Whether any poll in the CURRENT window has actually observed the active run
// (`reindexing === true`). Reset when a new reindex is kicked off. Gates the
// completion check so the STALE pre-reindex snapshot (an already fully-indexed
// workspace reads as `reindexing=false, indexed>=total`) can't be mistaken for
// "finished" before the first post-reindex poll lands — which would freeze the
// counter at 0 until a manual reload. A ref (not state) because it must not
// trigger a render and is only ever read where `reindexing` is already false.
const reindexSeenActiveRef = useRef(false);
// Only admins may read the (masked) AI settings; the server enforces this too.
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
@@ -298,6 +328,7 @@ export default function AiProviderSettings() {
now: Date.now(),
intervalMs: REINDEX_POLL_INTERVAL,
status: query.state.data,
seenActive: reindexSeenActiveRef.current,
}),
);
@@ -305,12 +336,17 @@ export default function AiProviderSettings() {
// unmount because the deadline state goes away with the component.
useEffect(() => {
if (reindexDeadline === null) return;
// "Done" matches the refetchInterval stop condition: the server reports no
// active run AND the count is complete (indexed >= total, incl. an empty
// workspace 0 >= 0), so the deadline clears promptly instead of waiting out
// the cap. While `reindexing` is still true we keep the deadline so polling
// continues for the whole run.
if (isReindexComplete(settings)) {
// Latch "we have seen the active run" the moment a poll reports it, so the
// completion check below (and the refetchInterval's) only fires once the run
// has genuinely started — never on the stale pre-reindex snapshot.
if (settings?.reindexing) reindexSeenActiveRef.current = true;
// "Done" matches the refetchInterval stop condition: a poll has observed the
// active run AND the server now reports no active run AND the count is
// complete (indexed >= total, incl. an empty workspace 0 >= 0), so the
// deadline clears promptly instead of waiting out the cap. While `reindexing`
// is still true (or no poll has seen it active yet) we keep the deadline so
// polling continues for the whole run.
if (isReindexComplete(settings, reindexSeenActiveRef.current)) {
setReindexDeadline(null);
return;
}
@@ -1117,8 +1153,13 @@ export default function AiProviderSettings() {
reindexMutation.mutate(undefined, {
// Begin bounded polling so the counter climbs as the async
// background job indexes (it does not update on its own).
onSuccess: () =>
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS),
// Clear the "seen active" latch first so this fresh window
// doesn't inherit a previous run's completion state and stop
// immediately.
onSuccess: () => {
reindexSeenActiveRef.current = false;
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
},
})
}
>