From b62db917dedb3776395089533159033ef323b813 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 3 Jul 2026 19:19:36 +0300 Subject: [PATCH] feat(comment): suggestion diff block + Apply button + mutation (#315 phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client UI for agent comment suggestions. - IComment gains suggestedText / suggestionAppliedAt / suggestionAppliedById. - comment-list-item shows a "было → стало" block (old selection struck/red, new suggestedText green) for a top-level comment with a suggestion, plus an Apply button — gated by canShowApply(comment, canEdit): edit permission AND a suggestion AND not applied AND not resolved AND top-level. Once applied, an "Applied" badge replaces the button. - canEdit comes from page.permissions.canEdit (real edit permission, NOT the looser canComment) and is threaded through CommentListItem and nested ChildComments; fail-closed when undefined. - useApplySuggestionMutation posts to /comments/apply-suggestion; on success it writes the applied + server auto-resolve fields into the react-query cache (UI flips to Applied + resolved without a refetch); on 409 it shows a specific message with the server's currentText, else a generic error. - i18n keys added in en-US + ru-RU. Tests (comment-list-item.test.tsx + canShowApply unit suite): Apply visibility across canEdit/applied/resolved/reply, click dispatches the mutation, diff rendering. 34 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 7 +- .../public/locales/ru-RU/translation.json | 7 +- .../components/comment-list-item.test.tsx | 101 +++++++++++++++++- .../comment/components/comment-list-item.tsx | 63 ++++++++++- .../components/comment-list-with-tabs.tsx | 19 +++- .../comment/components/comment.module.css | 32 ++++++ .../features/comment/queries/comment-query.ts | 58 ++++++++++ .../comment/services/comment-service.ts | 7 ++ .../features/comment/types/comment.types.ts | 7 ++ .../src/features/comment/utils/suggestion.ts | 14 +++ 10 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/features/comment/utils/suggestion.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 5dec73ff..9562d814 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1373,5 +1373,10 @@ "Updated to the latest version": "Updated to the latest version", "This role is no longer in the catalog": "This role is no longer in the catalog", "This language is no longer available in the catalog": "This language is no longer available in the catalog", - "Connecting… (read-only)": "Connecting… (read-only)" + "Connecting… (read-only)": "Connecting… (read-only)", + "Apply": "Apply", + "Applied": "Applied", + "Suggestion applied": "Suggestion applied", + "Failed to apply suggestion": "Failed to apply suggestion", + "The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied." } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index be16c5c9..e7a614c7 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1229,5 +1229,10 @@ "Updated to the latest version": "Обновлено до последней версии", "This role is no longer in the catalog": "Эта роль больше не представлена в каталоге", "This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге", - "Connecting… (read-only)": "Подключение… (только чтение)" + "Connecting… (read-only)": "Подключение… (только чтение)", + "Apply": "Применить", + "Applied": "Применено", + "Suggestion applied": "Предложение применено", + "Failed to apply suggestion": "Не удалось применить предложение", + "The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено." } diff --git a/apps/client/src/features/comment/components/comment-list-item.test.tsx b/apps/client/src/features/comment/components/comment-list-item.test.tsx index 1826e4ae..8b75b337 100644 --- a/apps/client/src/features/comment/components/comment-list-item.test.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { IComment } from "@/features/comment/types/comment.types"; @@ -7,10 +7,15 @@ import { IComment } from "@/features/comment/types/comment.types"; // The comment mutation hooks reach out to react-query/network — stub them so the // component renders in isolation. We only assert the AI-badge rendering branch. +const applyMutateAsync = vi.fn(); vi.mock("@/features/comment/queries/comment-query", () => ({ useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }), useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }), useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }), + useApplySuggestionMutation: () => ({ + mutateAsync: applyMutateAsync, + isPending: false, + }), })); // CommentEditor pulls in the full TipTap editor stack; replace it with a stub. @@ -19,6 +24,7 @@ vi.mock("@/features/comment/components/comment-editor", () => ({ })); import CommentListItem from "./comment-list-item"; +import { canShowApply } from "@/features/comment/utils/suggestion"; const baseComment = (over?: Partial): IComment => ({ @@ -32,10 +38,15 @@ const baseComment = (over?: Partial): IComment => ...over, }) as IComment; -function renderItem(comment: IComment) { +function renderItem(comment: IComment, canEdit = true) { return render( - + , ); } @@ -87,3 +98,87 @@ describe("CommentListItem — agent avatar stack", () => { // are covered directly in agent-avatar-stack.test.tsx; this integration suite // only guards the insertion gate (agent → stack, user → no stack). }); + +describe("CommentListItem — suggested edit (#315)", () => { + const suggestion = (over?: Partial): IComment => + baseComment({ + selection: "old wording here", + suggestedText: "new wording here", + ...over, + }); + + it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => { + renderItem(suggestion(), true); + // Old text appears both as the selection quote and as the struck diff row. + expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0); + expect(screen.getByText("new wording here")).toBeDefined(); + // Apply button is present. + expect(screen.getByRole("button", { name: "Apply" })).toBeDefined(); + // No Applied badge yet. + expect(screen.queryByText("Applied")).toBeNull(); + }); + + it("hides the Apply button when canEdit is false", () => { + renderItem(suggestion(), false); + // Diff still renders... + expect(screen.getByText("new wording here")).toBeDefined(); + // ...but no Apply button. + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + + it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => { + renderItem(suggestion({ suggestionAppliedAt: new Date() }), true); + expect(screen.getByText("Applied")).toBeDefined(); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + + it("hides the Apply button once the thread is resolved", () => { + renderItem(suggestion({ resolvedAt: new Date() }), true); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + + it("calls the apply mutation when the Apply button is clicked", () => { + applyMutateAsync.mockClear(); + renderItem(suggestion(), true); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(applyMutateAsync).toHaveBeenCalledWith({ + commentId: "c-1", + pageId: "page-1", + }); + }); + + it("does not render the diff block for a reply (child) comment", () => { + renderItem( + suggestion({ parentCommentId: "c-0" }), + true, + ); + expect(screen.queryByText("new wording here")).toBeNull(); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); +}); + +describe("canShowApply predicate", () => { + const c = (over?: Partial): IComment => + ({ suggestedText: "x", ...over }) as IComment; + + it("true when suggestion present, editable, not applied/resolved, top-level", () => { + expect(canShowApply(c(), true)).toBe(true); + }); + it("false without edit permission", () => { + expect(canShowApply(c(), false)).toBe(false); + }); + it("false when no suggestion", () => { + expect(canShowApply(c({ suggestedText: null }), true)).toBe(false); + }); + it("false when already applied", () => { + expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe( + false, + ); + }); + it("false when resolved", () => { + expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false); + }); + it("false for a reply comment", () => { + expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false); + }); +}); diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 072281bb..0d4b5e02 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -1,4 +1,4 @@ -import { Group, Text, Box } from "@mantine/core"; +import { Group, Text, Box, Badge, Button } from "@mantine/core"; import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx"; import React, { useEffect, useRef, useState } from "react"; import classes from "./comment.module.css"; @@ -11,11 +11,13 @@ import CommentMenu from "@/features/comment/components/comment-menu"; import ResolveComment from "@/features/comment/components/resolve-comment"; import { useHover } from "@mantine/hooks"; import { + useApplySuggestionMutation, useDeleteCommentMutation, useResolveCommentMutation, useUpdateCommentMutation, } from "@/features/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; +import { canShowApply } from "@/features/comment/utils/suggestion"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useTranslation } from "react-i18next"; @@ -24,6 +26,10 @@ interface CommentListItemProps { comment: IComment; pageId: string; canComment: boolean; + // Real page-edit permission (page.permissions.canEdit) — gates the suggestion + // "Apply" button. Distinct from `canComment`, which may be looser (viewers + // allowed to comment cannot apply edits). + canEdit?: boolean; userSpaceRole?: string; } @@ -31,6 +37,7 @@ function CommentListItem({ comment, pageId, canComment, + canEdit, userSpaceRole, }: CommentListItemProps) { const { t } = useTranslation(); @@ -43,6 +50,7 @@ function CommentListItem({ const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const resolveCommentMutation = useResolveCommentMutation(); + const applySuggestionMutation = useApplySuggestionMutation(); const [currentUser] = useAtom(currentUserAtom); const createdAtAgo = useTimeAgo(comment.createdAt); @@ -95,6 +103,18 @@ function CommentListItem({ } } + async function handleApplySuggestion() { + try { + await applySuggestionMutation.mutateAsync({ + commentId: comment.id, + pageId: comment.pageId, + }); + } catch (error) { + // Errors surface via the mutation's onError notification (incl. 409). + console.error("Failed to apply suggestion:", error); + } + } + function handleCommentClick(comment: IComment) { const el = document.querySelector( `.comment-mark[data-comment-id="${comment.id}"]`, @@ -211,6 +231,47 @@ function CommentListItem({ )} + {/* Suggested-edit (#315): "было → стало" diff for a top-level comment + carrying a suggestion. Old text struck-through/red, new text green. */} + {!comment.parentCommentId && comment.suggestedText && ( + + {comment.selection && ( + + {comment.selection} + + )} + + {comment.suggestedText} + + + {comment.suggestionAppliedAt ? ( + + {t("Applied")} + + ) : ( + canShowApply(comment, canEdit) && ( + + ) + )} + + )} + {!isEditing ? ( ) : ( 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 a29d3da8..af9d0783 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 @@ -49,8 +49,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { const [isLoading, setIsLoading] = useState(false); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); + const canEdit = page?.permissions?.canEdit ?? false; + const canComment = - (page?.permissions?.canEdit ?? false) || + canEdit || (space?.settings?.comments?.allowViewerComments === true); // Separate active and resolved comments @@ -137,6 +139,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { comment={comment} pageId={page?.id} canComment={canComment} + canEdit={canEdit} userSpaceRole={space?.membership?.role} /> @@ -160,7 +164,14 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { )} ), - [comments, handleAddReply, isLoading, space?.membership?.role, canComment], + [ + comments, + handleAddReply, + isLoading, + space?.membership?.role, + canComment, + canEdit, + ], ); if (isCommentsLoading) { @@ -300,6 +311,7 @@ interface ChildCommentsProps { parentId: string; pageId: string; canComment: boolean; + canEdit?: boolean; userSpaceRole?: string; } const ChildComments = ({ @@ -307,6 +319,7 @@ const ChildComments = ({ parentId, pageId, canComment, + canEdit, userSpaceRole, }: ChildCommentsProps) => { const getChildComments = useCallback( @@ -325,6 +338,7 @@ const ChildComments = ({ comment={childComment} pageId={pageId} canComment={canComment} + canEdit={canEdit} userSpaceRole={userSpaceRole} /> diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index 36362338..2a4b9397 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -21,6 +21,38 @@ box-sizing: border-box; } +/* Suggested-edit (#315) "было → стало" diff block. */ +.suggestionBlock { + margin-top: 8px; + margin-left: 6px; + padding: 6px; + border-radius: var(--mantine-radius-sm); + border: 1px solid var(--mantine-color-default-border); + overflow-wrap: break-word; + word-break: break-word; + max-width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.suggestionOld { + text-decoration: line-through; + color: var(--mantine-color-red-7); + background: var(--mantine-color-red-light); + border-radius: 2px; + padding: 1px 3px; +} + +.suggestionNew { + color: var(--mantine-color-green-9); + background: var(--mantine-color-green-light); + border-radius: 2px; + padding: 1px 3px; + margin-top: 4px; +} + .commentEditor { &[data-editable][data-surface="muted"] .ProseMirror:not(.focused) { diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index 5d637610..a1942532 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -5,6 +5,7 @@ import { InfiniteData, } from "@tanstack/react-query"; import { + applySuggestion, createComment, deleteComment, getPageComments, @@ -176,6 +177,63 @@ function updateCommentInCache( }; } +export function useApplySuggestionMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + // No optimistic update: apply can fail with 409 (the commented text drifted), + // so we only mutate the cache once the server confirms. + mutationFn: ({ commentId }) => applySuggestion(commentId), + onSuccess: (data, variables) => { + const cache = queryClient.getQueryData( + RQ_KEY(variables.pageId), + ) as InfiniteData> | undefined; + + if (cache) { + queryClient.setQueryData( + RQ_KEY(variables.pageId), + updateCommentInCache(cache, variables.commentId, (comment) => ({ + ...comment, + suggestionAppliedAt: data.suggestionAppliedAt, + suggestionAppliedById: data.suggestionAppliedById, + // The server auto-resolves the thread on apply — carry that through. + resolvedAt: data.resolvedAt, + resolvedById: data.resolvedById, + resolvedBy: data.resolvedBy, + })), + ); + } + + notifications.show({ message: t("Suggestion applied") }); + }, + onError: (err: any) => { + // 409 => the commented text changed since the suggestion was made. Surface + // a specific message (with the current text) rather than a generic error. + const status = err?.response?.status; + const currentText = err?.response?.data?.currentText; + if (status === 409 && typeof currentText === "string") { + const shortText = + currentText.length > 80 + ? `${currentText.slice(0, 80)}…` + : currentText; + notifications.show({ + title: t( + "The commented text changed since this suggestion was made; it was not applied.", + ), + message: shortText, + color: "red", + }); + return; + } + notifications.show({ + message: t("Failed to apply suggestion"), + color: "red", + }); + }, + }); +} + export function useResolveCommentMutation() { const queryClient = useQueryClient(); const { t } = useTranslation(); diff --git a/apps/client/src/features/comment/services/comment-service.ts b/apps/client/src/features/comment/services/comment-service.ts index f1512469..f536519a 100644 --- a/apps/client/src/features/comment/services/comment-service.ts +++ b/apps/client/src/features/comment/services/comment-service.ts @@ -18,6 +18,13 @@ export async function resolveComment(data: IResolveComment): Promise { return req.data; } +export async function applySuggestion(commentId: string): Promise { + // Mirrors resolveComment: let axios reject on non-2xx so the mutation can read + // the 409 body (`{ message, currentText }`) off err.response.data. + const req = await api.post("/comments/apply-suggestion", { commentId }); + return req.data.data ?? req.data; +} + export async function updateComment( data: Partial, ): Promise { diff --git a/apps/client/src/features/comment/types/comment.types.ts b/apps/client/src/features/comment/types/comment.types.ts index 31ff67be..ac4eac64 100644 --- a/apps/client/src/features/comment/types/comment.types.ts +++ b/apps/client/src/features/comment/types/comment.types.ts @@ -28,6 +28,13 @@ export interface IComment { createdSource?: string; aiChatId?: string | null; resolvedSource?: string | null; + // Suggested-edit (#315): when an agent proposes a replacement for the + // commented `selection`, `suggestedText` holds the "стало" text. Once a user + // applies it server-side the backend stamps `suggestionAppliedAt` / + // `suggestionAppliedById` and auto-resolves the thread. + suggestedText?: string | null; + suggestionAppliedAt?: Date | string | null; + suggestionAppliedById?: string | null; // Server-normalized "agent avatar stack" provenance (#300), present only when // createdSource === "agent": `agent` is the front identity, `launcher` the // human behind it (null for an external MCP agent). diff --git a/apps/client/src/features/comment/utils/suggestion.ts b/apps/client/src/features/comment/utils/suggestion.ts new file mode 100644 index 00000000..d14dea6e --- /dev/null +++ b/apps/client/src/features/comment/utils/suggestion.ts @@ -0,0 +1,14 @@ +import { IComment } from "@/features/comment/types/comment.types"; + +// Whether the suggested-edit (#315) "Apply" button should be shown for a +// comment: it must carry a suggestion, not already be applied or resolved, be a +// top-level comment, and the viewer must be able to edit the page. +export function canShowApply(comment: IComment, canEdit?: boolean): boolean { + return Boolean( + canEdit && + comment.suggestedText && + !comment.suggestionAppliedAt && + !comment.resolvedAt && + !comment.parentCommentId, + ); +}