diff --git a/web/app/components/workflow/comment/index.tsx b/web/app/components/workflow/comment/index.tsx index 558c15089c..148d5094f8 100644 --- a/web/app/components/workflow/comment/index.tsx +++ b/web/app/components/workflow/comment/index.tsx @@ -1,3 +1,4 @@ export { CommentCursor } from './cursor' export { CommentInput } from './input' export { CommentIcon } from './icon' +export { CommentThread } from './thread' diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx new file mode 100644 index 0000000000..f8d627b308 --- /dev/null +++ b/web/app/components/workflow/comment/thread.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { FC } from 'react' +import { memo, useMemo } from 'react' +import { useReactFlow, useViewport } from 'reactflow' +import { RiCloseLine } from '@remixicon/react' +import Avatar from '@/app/components/base/avatar' +import cn from '@/utils/classnames' +import { useFormatTimeFromNow } from '@/app/components/workflow/hooks' +import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment' + +type CommentThreadProps = { + comment: WorkflowCommentDetail + loading?: boolean + onClose: () => void +} + +const ThreadMessage: FC<{ + authorName: string + avatarUrl?: string | null + createdAt: number + content: string + isReply?: boolean +}> = ({ authorName, avatarUrl, createdAt, content, isReply }) => { + const { formatTimeFromNow } = useFormatTimeFromNow() + + return ( +
+
+ +
+
+
+ {authorName} + {formatTimeFromNow(createdAt * 1000)} +
+
+ {content} +
+
+
+ ) +} + +const renderReply = (reply: WorkflowCommentDetailReply) => ( + +) + +export const CommentThread: FC = memo(({ comment, loading = false, onClose }) => { + const { flowToScreenPosition } = useReactFlow() + const viewport = useViewport() + + const screenPosition = useMemo(() => { + return flowToScreenPosition({ + x: comment.position_x, + y: comment.position_y, + }) + }, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition]) + + return ( +
+
+
+
Comment
+ +
+
+ + {comment.replies?.length > 0 && ( +
+ {comment.replies.map(renderReply)} +
+ )} +
+ {loading && ( +
+ Loading… +
+ )} +
+
+ ) +}) + +CommentThread.displayName = 'CommentThread' diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts index 42eae29705..27180cb1ab 100644 --- a/web/app/components/workflow/hooks/use-workflow-comment.ts +++ b/web/app/components/workflow/hooks/use-workflow-comment.ts @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useParams } from 'next/navigation' import { useReactFlow } from 'reactflow' import { useStore } from '../store' import { ControlMode } from '../types' -import type { WorkflowCommentList } from '@/service/workflow-comment' -import { createWorkflowComment, fetchWorkflowComments } from '@/service/workflow-comment' +import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment' +import { createWorkflowComment, fetchWorkflowComment, fetchWorkflowComments } from '@/service/workflow-comment' export const useWorkflowComment = () => { const params = useParams() @@ -14,8 +14,17 @@ export const useWorkflowComment = () => { const setControlMode = useStore(s => s.setControlMode) const pendingComment = useStore(s => s.pendingComment) const setPendingComment = useStore(s => s.setPendingComment) + const setActiveCommentId = useStore(s => s.setActiveCommentId) + const activeCommentId = useStore(s => s.activeCommentId) const [comments, setComments] = useState([]) const [loading, setLoading] = useState(false) + const [activeComment, setActiveComment] = useState(null) + const [activeCommentLoading, setActiveCommentLoading] = useState(false) + const commentDetailCacheRef = useRef>({}) + const activeCommentIdRef = useRef(null) + useEffect(() => { + activeCommentIdRef.current = activeCommentId ?? null + }, [activeCommentId]) const loadComments = useCallback(async () => { if (!appId) return @@ -72,17 +81,50 @@ export const useWorkflowComment = () => { setControlMode(ControlMode.Pointer) }, [setControlMode, setPendingComment]) - const handleCommentIconClick = useCallback((comment: WorkflowCommentList) => { + const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => { + setPendingComment(null) + + activeCommentIdRef.current = comment.id + setControlMode(ControlMode.Comment) + setActiveCommentId(comment.id) + + const cachedDetail = commentDetailCacheRef.current[comment.id] + setActiveComment(cachedDetail || comment) + + reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 }) + + if (!appId) return + + if (!cachedDetail) + setActiveCommentLoading(true) + try { - const store = useStore.getState() - store.setControlMode(ControlMode.Comment) - store.setActiveCommentId(comment.id) - reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 }) + const detailResponse = await fetchWorkflowComment(appId, comment.id) + const detail = (detailResponse as any)?.data ?? detailResponse + + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [comment.id]: detail, + } + + if (activeCommentIdRef.current === comment.id) + setActiveComment(detail) } catch (e) { - console.error('Failed to open comments panel:', e) + console.warn('Failed to load workflow comment detail', e) } - }, [reactflow]) + finally { + setActiveCommentLoading(false) + } + }, [appId, reactflow, setPendingComment]) + + const handleActiveCommentClose = useCallback(() => { + setActiveComment(null) + setActiveCommentLoading(false) + setActiveCommentId(null) + setControlMode(ControlMode.Pointer) + activeCommentIdRef.current = null + }, [setActiveCommentId, setControlMode]) const handleCreateComment = useCallback((mousePosition: { pageX: number; pageY: number }) => { if (controlMode === ControlMode.Comment) { @@ -101,9 +143,12 @@ export const useWorkflowComment = () => { comments, loading, pendingComment, + activeComment, + activeCommentLoading, handleCommentSubmit, handleCommentCancel, handleCommentIconClick, + handleActiveCommentClose, handleCreateComment, loadComments, } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 8e65d1a804..0f21a6290b 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -69,7 +69,7 @@ import PanelContextmenu from './panel-contextmenu' import NodeContextmenu from './node-contextmenu' import SyncingDataModal from './syncing-data-modal' import LimitTips from './limit-tips' -import { CommentCursor, CommentIcon, CommentInput } from './comment' +import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment' import { useWorkflowComment } from './hooks/use-workflow-comment' import { useStore, @@ -164,9 +164,12 @@ export const Workflow: FC = memo(({ const { comments, pendingComment, + activeComment, + activeCommentLoading, handleCommentSubmit, handleCommentCancel, handleCommentIconClick, + handleActiveCommentClose, } = useWorkflowComment() const mousePosition = useStore(s => s.mousePosition) @@ -351,13 +354,28 @@ export const Workflow: FC = memo(({ onCancel={handleCommentCancel} /> )} - {comments.map(comment => ( - handleCommentIconClick(comment)} - /> - ))} + {comments.map((comment) => { + const isActive = activeComment?.id === comment.id + + if (isActive && activeComment) { + return ( + + ) + } + + return ( + handleCommentIconClick(comment)} + /> + ) + })} {children}