diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 9b57f724..39a989d6 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -239,6 +239,8 @@ "Comment re-opened successfully": "Comment re-opened successfully", "Comment unresolved successfully": "Comment unresolved successfully", "Failed to resolve comment": "Failed to resolve comment", + "Failed to re-open comment": "Failed to re-open comment", + "Comment no longer exists": "Comment no longer exists", "Resolve comment": "Resolve comment", "Unresolve comment": "Unresolve comment", "Resolve Comment Thread": "Resolve Comment Thread", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index e22d8746..bac99bd2 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -239,6 +239,8 @@ "Comment re-opened successfully": "Комментарий успешно открыт повторно", "Comment unresolved successfully": "Комментарий успешно переведён в нерешённые", "Failed to resolve comment": "Не удалось разрешить комментарий", + "Failed to re-open comment": "Не удалось переоткрыть комментарий", + "Comment no longer exists": "Комментарий больше не существует", "Resolve comment": "Решить комментарий", "Unresolve comment": "Снять статус решённого с комментария", "Resolve Comment Thread": "Решить ветку комментариев", diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx index cdc3bcb4..41967c9d 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx @@ -27,11 +27,15 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({ import { buildChildrenByParent, CommentEditorWithActions, + sortResolvedByResolvedAt, } from "./comment-list-with-tabs"; const c = (id: string, parentCommentId: string | null = null): IComment => ({ id, parentCommentId }) as IComment; +const resolvedAtComment = (id: string, resolvedAt: unknown): IComment => + ({ id, resolvedAt }) as unknown as IComment; + describe("buildChildrenByParent (childrenByParent grouping)", () => { it("returns an empty map for undefined or empty input", () => { expect(buildChildrenByParent(undefined).size).toBe(0); @@ -71,6 +75,48 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => { }); }); +describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => { + it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => { + // At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so + // the sort must coerce with new Date(...) before .getTime(). + const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z"); + const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z"); + const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z"); + + const out = sortResolvedByResolvedAt([older, newest, middle]); + expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]); + }); + + it("also handles Date instances (optimistic onMutate window)", () => { + const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z")); + const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z")); + expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([ + "newer", + "older", + ]); + }); + + it("does not mutate the input array", () => { + const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z"); + const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z"); + const input = [a, b]; + sortResolvedByResolvedAt(input); + expect(input.map((x) => x.id)).toEqual(["a", "b"]); + }); + + it("keeps stable order for equal resolvedAt timestamps", () => { + const ts = "2026-03-03T03:03:03.000Z"; + const x = resolvedAtComment("x", ts); + const y = resolvedAtComment("y", ts); + const z = resolvedAtComment("z", ts); + expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([ + "x", + "y", + "z", + ]); + }); +}); + function renderReplyEditor() { return render( diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index 1b667e47..73075348 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -53,6 +53,22 @@ export function buildChildrenByParent( return m; } +// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate +// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it +// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a +// real Date only during the optimistic onMutate window — so it MUST be coerced +// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string +// throws / yields NaN. ES2019's stable sort preserves order for equal +// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so +// the non-null assertion is safe. +// Exported for unit testing. +export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] { + return [...resolved].sort( + (a, b) => + new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(), + ); +} + function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { const { t } = useTranslation(); const { pageSlug } = useParams(); @@ -91,7 +107,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { (comment: IComment) => comment.resolvedAt, ); - return { activeComments: active, resolvedComments: resolved }; + return { + activeComments: active, + resolvedComments: sortResolvedByResolvedAt(resolved), + }; }, [comments]); // Index replies by their parent once, instead of an O(n^2) filter per thread. diff --git a/apps/client/src/features/comment/queries/comment-query.resolve-undo.test.tsx b/apps/client/src/features/comment/queries/comment-query.resolve-undo.test.tsx new file mode 100644 index 00000000..2bd7cd0f --- /dev/null +++ b/apps/client/src/features/comment/queries/comment-query.resolve-undo.test.tsx @@ -0,0 +1,353 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { + QueryClient, + QueryClientProvider, + InfiniteData, +} from "@tanstack/react-query"; + +/** + * Coverage for the resolve/reopen mutation (#542): the Undo-in-toast reopen and + * its double-click guard, the terminal 404 branch (drop from cache + clear the + * inline mark, no rollback), and the directional error copy. + */ + +// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert +// the mutation clears the inline comment mark (unsetComment / setCommentResolved). +const editorMock = vi.hoisted(() => ({ + current: { + isDestroyed: false, + commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() }, + } as { + isDestroyed: boolean; + commands: { + unsetComment: (id: string) => void; + setCommentResolved: (id: string, v: boolean) => void; + }; + } | null, +})); + +vi.mock("@mantine/notifications", () => ({ + notifications: { show: vi.fn(), hide: vi.fn() }, +})); + +vi.mock("jotai", () => ({ + atom: (v: unknown) => v, + useAtomValue: () => editorMock.current, +})); + +vi.mock("@/features/comment/services/comment-service", () => ({ + applySuggestion: vi.fn(), + dismissSuggestion: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + resolveComment: vi.fn(), + getPageComments: vi.fn(), +})); + +import { notifications } from "@mantine/notifications"; +import { resolveComment } from "@/features/comment/services/comment-service"; +import { + useResolveCommentMutation, + RESOLVE_UNDO_AUTOCLOSE_MS, + RQ_KEY, +} from "@/features/comment/queries/comment-query"; +import { IComment } from "@/features/comment/types/comment.types"; + +const PAGE_ID = "page-1"; + +function seededClient(comment: IComment) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + const seed: InfiniteData = { + pageParams: [undefined], + pages: [ + { items: [comment], meta: { hasNextPage: false, nextCursor: null } }, + ], + }; + queryClient.setQueryData(RQ_KEY(PAGE_ID), seed); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return { queryClient, wrapper }; +} + +function items(queryClient: QueryClient): IComment[] { + const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as + | InfiniteData + | undefined; + return cache?.pages.flatMap((p) => p.items) ?? []; +} + +const comment = (over?: Partial): IComment => + ({ + id: "c-1", + pageId: PAGE_ID, + content: "{}", + creatorId: "u-1", + workspaceId: "ws-1", + createdAt: new Date(), + resolvedAt: null, + ...over, + }) as IComment; + +// Pull the inline Undo button's onClick out of the success toast's message tree. +function undoOnClickFromToast(): () => void { + const call = vi + .mocked(notifications.show) + .mock.calls.map((c) => c[0]) + .find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS); + expect(call).toBeTruthy(); + const message: any = (call as any).message; + // message = Group( Text, Button ); grab the Button element's onClick. + const children = message.props.children as any[]; + const button = children[1]; + return button.props.onClick; +} + +describe("useResolveCommentMutation — Undo toast (#542)", () => { + beforeEach(() => { + vi.clearAllMocks(); + editorMock.current = { + isDestroyed: false, + commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() }, + }; + }); + + it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => { + vi.mocked(resolveComment).mockImplementation(async (data) => + comment({ + resolvedAt: data.resolved ? (new Date() as any) : null, + }), + ); + const { wrapper } = seededClient(comment()); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current.mutateAsync({ + commentId: "c-1", + pageId: PAGE_ID, + resolved: true, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const resolveToast = vi + .mocked(notifications.show) + .mock.calls.map((c) => c[0]) + .find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS); + expect(resolveToast).toBeTruthy(); + expect((resolveToast as any).id).toBe("resolve-undo-c-1"); + expect((resolveToast as any).autoClose).toBe(10000); + + // Now a reopen → plain toast, no autoClose/Undo, no id. + vi.clearAllMocks(); + await result.current.mutateAsync({ + commentId: "c-1", + pageId: PAGE_ID, + resolved: false, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]); + expect( + calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS), + ).toBe(false); + expect(calls).toContainEqual({ message: "Comment re-opened successfully" }); + }); + + it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => { + vi.mocked(resolveComment).mockImplementation(async (data) => + comment({ resolvedAt: data.resolved ? (new Date() as any) : null }), + ); + const { wrapper } = seededClient(comment()); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current.mutateAsync({ + commentId: "c-1", + pageId: PAGE_ID, + resolved: true, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const onClick = undoOnClickFromToast(); + // Fire twice synchronously (notifications.hide is not synchronous). + onClick(); + onClick(); + + await waitFor(() => { + const reopenCalls = vi + .mocked(resolveComment) + .mock.calls.filter(([d]) => d.resolved === false); + expect(reopenCalls).toHaveLength(1); + }); + // The mark was cleared once via setCommentResolved(id, false). + expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith( + "c-1", + false, + ); + // The toast was hidden. + expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1"); + }); + + it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => { + vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } }); + const { queryClient, wrapper } = seededClient(comment()); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current + .mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true }) + .catch(() => undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // Removed from cache (NOT rolled back to a phantom). + expect(items(queryClient)).toHaveLength(0); + // Inline mark cleared via unsetComment (mandatory — no panel row left to do it). + expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith( + "c-1", + ); + // Neutral message, red, and crucially NOT the success copy and NO Undo toast. + expect(notifications.show).toHaveBeenCalledWith({ + message: "Comment no longer exists", + color: "red", + }); + const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]); + expect( + calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS), + ).toBe(false); + }); + + it("404 does not crash when the editor is gone (read-only / panel closed)", async () => { + editorMock.current = null; + vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } }); + const { queryClient, wrapper } = seededClient(comment()); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current + .mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true }) + .catch(() => undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(items(queryClient)).toHaveLength(0); + expect(notifications.show).toHaveBeenCalledWith({ + message: "Comment no longer exists", + color: "red", + }); + }); + + it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => { + vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } }); + // Seed a RESOLVED comment (the reopen target). + const resolved = comment({ resolvedAt: new Date() as any }); + const { queryClient, wrapper } = seededClient(resolved); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current + .mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false }) + .catch(() => undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(notifications.show).toHaveBeenCalledWith({ + message: "Failed to re-open comment", + color: "red", + }); + expect(notifications.show).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Failed to resolve comment" }), + ); + // Rolled back: the comment is still present and still resolved. + expect(items(queryClient)).toHaveLength(1); + expect(items(queryClient)[0].resolvedAt).toBeTruthy(); + }); + + it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => { + // First resolve succeeds → produces the Undo toast (no mark change on resolve). + vi.mocked(resolveComment).mockResolvedValueOnce( + comment({ resolvedAt: new Date() as any }), + ); + const { queryClient, wrapper } = seededClient(comment()); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current.mutateAsync({ + commentId: "c-1", + pageId: PAGE_ID, + resolved: true, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Now the reopen fired by Undo fails with a 500. + vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } }); + const onClick = undoOnClickFromToast(); + onClick(); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + // Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a + // FAILED reopen must never flip the inline mark to unresolved — otherwise the + // doc would show an active highlight the panel still treats as resolved and + // the collab mark would diverge with nothing committed on the server. + expect( + editorMock.current!.commands.setCommentResolved, + ).not.toHaveBeenCalledWith("c-1", false); + // Cache rolled back: the comment stays resolved and present. + expect(items(queryClient)).toHaveLength(1); + expect(items(queryClient)[0].resolvedAt).toBeTruthy(); + }); + + it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => { + // Read-only view / panel closed: pageEditorAtom is null on the success path. + editorMock.current = null; + vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null })); + const resolved = comment({ resolvedAt: new Date() as any }); + const { queryClient, wrapper } = seededClient(resolved); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current.mutateAsync({ + commentId: "c-1", + pageId: PAGE_ID, + resolved: false, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // No crash from the reopen mark-clear; the plain reopen toast is still shown. + expect(notifications.show).toHaveBeenCalledWith({ + message: "Comment re-opened successfully", + }); + // Cache updated to reopened (resolvedAt cleared by the server payload). + expect(items(queryClient)).toHaveLength(1); + expect(items(queryClient)[0].resolvedAt).toBeFalsy(); + }); + + it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => { + vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } }); + const { queryClient, wrapper } = seededClient(comment()); + const { result } = renderHook(() => useResolveCommentMutation(), { + wrapper, + }); + + await result.current + .mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true }) + .catch(() => undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(notifications.show).toHaveBeenCalledWith({ + message: "Failed to resolve comment", + color: "red", + }); + // Rolled back to open (previousCache), still present. + expect(items(queryClient)).toHaveLength(1); + expect(items(queryClient)[0].resolvedAt).toBeFalsy(); + }); +}); diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index 6065cbec..9599d008 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -20,12 +20,19 @@ import { ISuggestionOutcome, } from "@/features/comment/types/comment.types"; import { notifications } from "@mantine/notifications"; +import { Button, Group, Text } from "@mantine/core"; import { IPagination } from "@/lib/types.ts"; import { useTranslation } from "react-i18next"; -import { useEffect, useMemo } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; +import { useAtomValue } from "jotai"; +import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; export const RQ_KEY = (pageId: string) => ["comments", pageId]; +// How long the resolve success toast (with its inline Undo) stays up before it +// auto-closes. Policy constant — no env override. +export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000; + export function useCommentsQuery(params: ICommentParams) { const query = useInfiniteQuery({ queryKey: RQ_KEY(params.pageId), @@ -376,7 +383,25 @@ export function useResolveCommentMutation() { const queryClient = useQueryClient(); const { t } = useTranslation(); - return useMutation({ + // Keep the live editor in a ref: the toast's Undo (and the 404 branch) must + // clear the inline comment mark AFTER the originating CommentListItem has + // unmounted (resolving pulls the comment out of the Open list, so its item is + // already gone by the time the 10s toast is clicked). In read-only view + // pageEditorAtom is null and the mark converges via the server's + // COMMENT_MARK_UPDATE job instead. + const editor = useAtomValue(pageEditorAtom); + const editorRef = useRef(editor); + editorRef.current = editor; + + // Self-reference the mutation so the toast's Undo can re-invoke it (reopen) + // long after the triggering component unmounted. Declared BEFORE useMutation + // and assigned AFTER; the onClick reads mutationRef.current at CALL time, not + // definition time, so there is no initialization cycle. + const mutationRef = useRef<{ + mutate: (vars: IResolveComment) => void; + } | null>(null); + + const mutation = useMutation({ mutationFn: (data: IResolveComment) => resolveComment(data), onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) }); @@ -401,7 +426,39 @@ export function useResolveCommentMutation() { return { previousCache }; }, - onError: (_err, variables, context) => { + onError: (err: any, variables, context) => { + // Terminal 404: the comment was really deleted (missing comment or deleted + // page — access denial is 403, resolve is idempotent so no 400). Do NOT + // roll back (that would resurrect a phantom row in Resolved); instead drop + // it from the cache and clear its now-orphaned inline mark. Mirrors + // handleDeleteComment and the dismiss-mutation 404 branch. + if (err?.response?.status === 404) { + const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as + | InfiniteData> + | undefined; + if (cache) { + queryClient.setQueryData( + RQ_KEY(variables.pageId), + removeCommentFromCache(cache, variables.commentId), + ); + } + const ed = editorRef.current; + if (ed && !ed.isDestroyed) { + try { + ed.commands.unsetComment(variables.commentId); + } catch { + /* editor gone / mark already removed */ + } + } + notifications.show({ + message: t("Comment no longer exists"), + color: "red", + }); + return; + } + + // Generic failure: roll back the optimistic update and show a DIRECTIONAL + // error (resolve vs. reopen), not always "resolve". if (context?.previousCache) { queryClient.setQueryData( RQ_KEY(variables.pageId), @@ -409,7 +466,9 @@ export function useResolveCommentMutation() { ); } notifications.show({ - message: t("Failed to resolve comment"), + message: variables.resolved + ? t("Failed to resolve comment") + : t("Failed to re-open comment"), color: "red", }); }, @@ -430,11 +489,72 @@ export function useResolveCommentMutation() { ); } + // Reopen keeps the plain toast without an Undo. + if (!variables.resolved) { + // Clear the inline mark ONLY after the server confirms the reopen, so a + // failed reopen never leaves an active highlight the panel still treats + // as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch. + // The button-triggered reopen already set the mark, so this is an + // idempotent no-op there. + const ed = editorRef.current; + if (ed && !ed.isDestroyed) { + try { + ed.commands.setCommentResolved(variables.commentId, false); + } catch { + /* editor gone — server COMMENT_MARK_UPDATE converges it */ + } + } + notifications.show({ message: t("Comment re-opened successfully") }); + return; + } + + // Resolve: attach an inline Undo (reopen) to the success toast. Built with + // React.createElement because this is a .ts module (no JSX). + const { commentId, pageId } = variables; + const notificationId = `resolve-undo-${commentId}`; + // Double-click guard: notifications.hide is NOT synchronous, so the button + // stays clickable for a frame or two — without this a fast double-click + // would fire reopen twice. + let done = false; notifications.show({ - message: variables.resolved - ? t("Comment resolved successfully") - : t("Comment re-opened successfully"), + id: notificationId, + autoClose: RESOLVE_UNDO_AUTOCLOSE_MS, + message: React.createElement( + Group, + { justify: "space-between", wrap: "nowrap", gap: "md" }, + React.createElement( + Text, + { size: "sm" }, + t("Comment resolved successfully"), + ), + React.createElement( + Button, + { + variant: "subtle", + size: "compact-sm", + onClick: () => { + if (done) return; + done = true; + // Reopen via the SAME mutation (read at click time — the + // originating item is already unmounted). + mutationRef.current?.mutate({ + commentId, + pageId, + resolved: false, + }); + // The inline mark is cleared in the reopen mutation's onSuccess + // (bound to server confirmation), NOT here — clearing it eagerly + // would desync the doc from the panel if reopen then fails. + notifications.hide(notificationId); + }, + }, + t("Undo"), + ), + ), }); }, }); + + mutationRef.current = mutation; + return mutation; }