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.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}