Mark comments (and, via existing page provenance, pages) created under an is_agent service account as authored by AI, derived from the SIGNED server identity rather than any client field, and render the existing AI badge in the comments sidebar. Backend (B1): - Add additive users.is_agent boolean (default false) migration; reflect in the Users Kysely type, the user repo baseFields, and (via Selectable) the User entity. - jwt.strategy: derive req.raw.actor from user.isAgent (an is_agent account stamps every write 'agent'); external MCP has no internal ai_chats row so aiChatId stays null. Non-spoofable: a plain user cannot obtain created_source='agent'. - Loosen the provenance aiChatId type to string|null across token.service and the JwtPayload/JwtCollabPayload claims (type-level only; the internal AI-chat path still passes a real aiChatId). Frontend (B2): - Extend IComment with createdSource/aiChatId/resolvedSource (backend already returns them via selectAll). - Extract the local AiAgentBadge from history-item into a shared components/ui/ai-agent-badge.tsx (clickable deep-link when aiChatId present, plain label when null/absent); reuse it in history-item and render it in comment-list-item next to the author name when createdSource==='agent'. Tests: comment.service agent/null-aiChatId provenance, jwt.strategy provenance derivation + anti-spoof, AiAgentBadge clickable/non-clickable branches, and comment-list-item badge render/no-render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
6.9 KiB
TypeScript
220 lines
6.9 KiB
TypeScript
import { Group, Text, Box } from "@mantine/core";
|
|
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import classes from "./comment.module.css";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
|
import CommentActions from "@/features/comment/components/comment-actions";
|
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
|
import ResolveComment from "@/features/comment/components/resolve-comment";
|
|
import { useHover } from "@mantine/hooks";
|
|
import {
|
|
useDeleteCommentMutation,
|
|
useResolveCommentMutation,
|
|
useUpdateCommentMutation,
|
|
} from "@/features/comment/queries/comment-query";
|
|
import { IComment } from "@/features/comment/types/comment.types";
|
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
interface CommentListItemProps {
|
|
comment: IComment;
|
|
pageId: string;
|
|
canComment: boolean;
|
|
userSpaceRole?: string;
|
|
}
|
|
|
|
function CommentListItem({
|
|
comment,
|
|
pageId,
|
|
canComment,
|
|
userSpaceRole,
|
|
}: CommentListItemProps) {
|
|
const { t } = useTranslation();
|
|
const { hovered, ref } = useHover();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const editor = useAtomValue(pageEditorAtom);
|
|
const [content, setContent] = useState<string>(comment.content);
|
|
const editContentRef = useRef<any>(null);
|
|
const updateCommentMutation = useUpdateCommentMutation();
|
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
|
const resolveCommentMutation = useResolveCommentMutation();
|
|
const [currentUser] = useAtom(currentUserAtom);
|
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
|
|
|
useEffect(() => {
|
|
setContent(comment.content);
|
|
}, [comment]);
|
|
|
|
async function handleUpdateComment() {
|
|
try {
|
|
setIsLoading(true);
|
|
const commentToUpdate = {
|
|
commentId: comment.id,
|
|
content: JSON.stringify(editContentRef.current ?? content),
|
|
};
|
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
|
if (editContentRef.current) {
|
|
setContent(editContentRef.current);
|
|
editContentRef.current = null;
|
|
}
|
|
setIsEditing(false);
|
|
} catch (error) {
|
|
console.error("Failed to update comment:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteComment() {
|
|
try {
|
|
await deleteCommentMutation.mutateAsync(comment.id);
|
|
editor?.commands.unsetComment(comment.id);
|
|
} catch (error) {
|
|
console.error("Failed to delete comment:", error);
|
|
}
|
|
}
|
|
|
|
async function handleResolveComment() {
|
|
try {
|
|
const isResolved = comment.resolvedAt != null;
|
|
await resolveCommentMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
resolved: !isResolved,
|
|
});
|
|
if (editor) {
|
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to toggle resolved state:", error);
|
|
}
|
|
}
|
|
|
|
function handleCommentClick(comment: IComment) {
|
|
const el = document.querySelector(
|
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
el.classList.add("comment-highlight");
|
|
setTimeout(() => {
|
|
el.classList.remove("comment-highlight");
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function handleEditToggle() {
|
|
setIsEditing(true);
|
|
}
|
|
function cancelEdit() {
|
|
editContentRef.current = null;
|
|
setIsEditing(false);
|
|
}
|
|
|
|
return (
|
|
<Box ref={ref} pb={6}>
|
|
<Group gap="xs">
|
|
<CustomAvatar
|
|
size="sm"
|
|
avatarUrl={comment.creator.avatarUrl}
|
|
name={comment.creator.name}
|
|
/>
|
|
|
|
<div style={{ flex: 1 }}>
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
|
{comment.creator.name}
|
|
</Text>
|
|
|
|
{comment.createdSource === "agent" && (
|
|
<AiAgentBadge
|
|
authorName={comment.creator?.name}
|
|
aiChatId={comment.aiChatId}
|
|
/>
|
|
)}
|
|
</Group>
|
|
|
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
|
{!comment.parentCommentId && canComment && (
|
|
<ResolveComment
|
|
editor={editor}
|
|
commentId={comment.id}
|
|
pageId={comment.pageId}
|
|
resolvedAt={comment.resolvedAt}
|
|
/>
|
|
)}
|
|
|
|
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
|
<CommentMenu
|
|
onEditComment={handleEditToggle}
|
|
onDeleteComment={handleDeleteComment}
|
|
onResolveComment={handleResolveComment}
|
|
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
canComment={canComment}
|
|
isResolved={comment.resolvedAt != null}
|
|
isParentComment={!comment.parentCommentId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
|
|
<Group gap="xs">
|
|
<Text size="xs" fw={500} c="dimmed" lh={1.1}>
|
|
{createdAtAgo}
|
|
</Text>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
|
|
<div>
|
|
{!comment.parentCommentId && comment?.selection && (
|
|
<Box
|
|
className={classes.textSelection}
|
|
onClick={() => handleCommentClick(comment)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handleCommentClick(comment);
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t("Jump to comment selection")}
|
|
>
|
|
<Text size="xs">{comment?.selection}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{!isEditing ? (
|
|
<CommentEditor defaultContent={content} editable={false} />
|
|
) : (
|
|
<>
|
|
<CommentEditor
|
|
defaultContent={content}
|
|
editable={true}
|
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
|
onSave={handleUpdateComment}
|
|
autofocus={true}
|
|
/>
|
|
|
|
<CommentActions
|
|
onSave={handleUpdateComment}
|
|
isLoading={isLoading}
|
|
onCancel={cancelEdit}
|
|
isCommentEditor={true}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default CommentListItem;
|