ef16743406
Two visual defects in the agent avatar stack (PR #304), missed by the code-only review: - The launcher (human) avatar was fully occluded behind the opaque agent glyph — the container was exactly the glyph size, so the launcher sat underneath it. Enlarge the container by an overhang and vertically center the glyph so the launcher peeks out at the bottom-right and stays visible. - On comments the human creator stayed the PRIMARY avatar and name while the stack was crammed into the old badge slot, duplicating the identity and failing the "agent is primary" requirement. AgentAvatarStack gains a showName prop; with showName=false it now replaces the leading avatar for agent comments, and the name slot renders agent.name (+ dimmed · launcher.name). Non-agent comments are byte-identical to before; history-item keeps the default (names shown). Tests: add showName=false and external-MCP (no-launcher) coverage, assert no identity duplication. client tsc clean, 9 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
7.6 KiB
TypeScript
240 lines
7.6 KiB
TypeScript
import { Group, Text, Box } 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";
|
|
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">
|
|
{comment.createdSource === "agent" && comment.agent ? (
|
|
<AgentAvatarStack
|
|
agent={comment.agent}
|
|
launcher={comment.launcher}
|
|
aiChatId={comment.aiChatId}
|
|
showName={false}
|
|
/>
|
|
) : (
|
|
<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 }}>
|
|
{comment.createdSource === "agent" && comment.agent ? (
|
|
<>
|
|
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
|
{comment.agent.name}
|
|
</Text>
|
|
{comment.launcher && (
|
|
<>
|
|
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
|
·
|
|
</Text>
|
|
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
|
{comment.launcher.name}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
|
{comment.creator.name}
|
|
</Text>
|
|
)}
|
|
</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;
|