diff --git a/web/app/components/workflow/comment/comment-icon.spec.tsx b/web/app/components/workflow/comment/comment-icon.spec.tsx
new file mode 100644
index 0000000000..aee8c64fa3
--- /dev/null
+++ b/web/app/components/workflow/comment/comment-icon.spec.tsx
@@ -0,0 +1,148 @@
+import type { WorkflowCommentList } from '@/service/workflow-comment'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CommentIcon } from './comment-icon'
+
+type Position = { x: number, y: number }
+
+let mockUserId = 'user-1'
+
+const mockFlowToScreenPosition = vi.fn((position: Position) => position)
+const mockScreenToFlowPosition = vi.fn((position: Position) => position)
+
+vi.mock('reactflow', () => ({
+ useReactFlow: () => ({
+ flowToScreenPosition: mockFlowToScreenPosition,
+ screenToFlowPosition: mockScreenToFlowPosition,
+ }),
+ useViewport: () => ({
+ x: 0,
+ y: 0,
+ zoom: 1,
+ }),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: {
+ id: mockUserId,
+ name: 'User',
+ avatar_url: 'avatar',
+ },
+ }),
+}))
+
+vi.mock('@/app/components/base/user-avatar-list', () => ({
+ UserAvatarList: ({ users }: { users: Array<{ id: string }> }) => (
+
{users.map(user => user.id).join(',')}
+ ),
+}))
+
+vi.mock('./comment-preview', () => ({
+ default: ({ onClick }: { onClick?: () => void }) => (
+
+ ),
+}))
+
+const createComment = (overrides: Partial = {}): WorkflowCommentList => ({
+ id: 'comment-1',
+ position_x: 0,
+ position_y: 0,
+ content: 'Hello',
+ created_by: 'user-1',
+ created_by_account: {
+ id: 'user-1',
+ name: 'Alice',
+ email: 'alice@example.com',
+ },
+ created_at: 1,
+ updated_at: 2,
+ resolved: false,
+ mention_count: 0,
+ reply_count: 0,
+ participants: [],
+ ...overrides,
+})
+
+describe('CommentIcon', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUserId = 'user-1'
+ })
+
+ it('toggles preview on hover when inactive', () => {
+ const comment = createComment()
+ const { container } = render(
+ ,
+ )
+ const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
+ const hoverTarget = marker.firstElementChild as HTMLElement
+
+ fireEvent.mouseEnter(hoverTarget)
+ expect(screen.getByTestId('comment-preview')).toBeInTheDocument()
+
+ fireEvent.mouseLeave(hoverTarget)
+ expect(screen.queryByTestId('comment-preview')).not.toBeInTheDocument()
+ })
+
+ it('calls onPositionUpdate after dragging by author', () => {
+ const comment = createComment({ position_x: 0, position_y: 0 })
+ const onClick = vi.fn()
+ const onPositionUpdate = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
+
+ fireEvent.pointerDown(marker, {
+ pointerId: 1,
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ })
+ fireEvent.pointerMove(marker, {
+ pointerId: 1,
+ clientX: 110,
+ clientY: 110,
+ })
+ fireEvent.pointerUp(marker, {
+ pointerId: 1,
+ clientX: 110,
+ clientY: 110,
+ })
+
+ expect(mockScreenToFlowPosition).toHaveBeenCalledWith({ x: 10, y: 10 })
+ expect(onPositionUpdate).toHaveBeenCalledWith({ x: 10, y: 10 })
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('calls onClick for non-author clicks', () => {
+ mockUserId = 'user-2'
+ const comment = createComment()
+ const onClick = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
+
+ fireEvent.pointerDown(marker, {
+ pointerId: 1,
+ button: 0,
+ clientX: 50,
+ clientY: 60,
+ })
+ fireEvent.pointerUp(marker, {
+ pointerId: 1,
+ clientX: 50,
+ clientY: 60,
+ })
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/comment/comment-input.spec.tsx b/web/app/components/workflow/comment/comment-input.spec.tsx
new file mode 100644
index 0000000000..aa60b0c8bc
--- /dev/null
+++ b/web/app/components/workflow/comment/comment-input.spec.tsx
@@ -0,0 +1,106 @@
+import type { FC } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CommentInput } from './comment-input'
+
+type MentionInputProps = {
+ value: string
+ onChange: (value: string) => void
+ onSubmit: (content: string, mentionedUserIds: string[]) => void
+ placeholder?: string
+ autoFocus?: boolean
+ className?: string
+}
+
+const stableT = (key: string, options?: { ns?: string }) => (
+ options?.ns ? `${options.ns}.${key}` : key
+)
+
+let mentionInputProps: MentionInputProps | null = null
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: stableT,
+ }),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: {
+ id: 'user-1',
+ name: 'Alice',
+ avatar_url: 'avatar',
+ },
+ }),
+}))
+
+vi.mock('@/app/components/base/avatar', () => ({
+ default: ({ name }: { name: string }) => {name}
,
+}))
+
+vi.mock('./mention-input', () => ({
+ MentionInput: ((props: MentionInputProps) => {
+ mentionInputProps = props
+ return (
+
+ )
+ }) as FC,
+}))
+
+describe('CommentInput', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mentionInputProps = null
+ })
+
+ it('passes translated placeholder to mention input', () => {
+ render(
+ ,
+ )
+
+ expect(mentionInputProps?.placeholder).toBe('workflow.comments.placeholder.add')
+ expect(mentionInputProps?.autoFocus).toBe(true)
+ })
+
+ it('calls onCancel when Escape is pressed', () => {
+ const onCancel = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('forwards mention submit to onSubmit', () => {
+ const onSubmit = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('mention-input'))
+
+ expect(onSubmit).toHaveBeenCalledWith('Hello', ['user-2'])
+ })
+})
diff --git a/web/app/components/workflow/comment/comment-preview.spec.tsx b/web/app/components/workflow/comment/comment-preview.spec.tsx
new file mode 100644
index 0000000000..d411c67ecd
--- /dev/null
+++ b/web/app/components/workflow/comment/comment-preview.spec.tsx
@@ -0,0 +1,86 @@
+import type { WorkflowCommentList } from '@/service/workflow-comment'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import CommentPreview from './comment-preview'
+
+type UserProfile = WorkflowCommentList['created_by_account']
+
+const mockSetHovering = vi.fn()
+let capturedUsers: UserProfile[] = []
+
+vi.mock('@/app/components/base/user-avatar-list', () => ({
+ UserAvatarList: ({ users }: { users: UserProfile[] }) => {
+ capturedUsers = users
+ return {users.map(user => user.id).join(',')}
+ },
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: (value: number) => `time:${value}`,
+ }),
+}))
+
+vi.mock('../store', () => ({
+ useStore: (selector: (state: { setCommentPreviewHovering: (value: boolean) => void }) => unknown) =>
+ selector({ setCommentPreviewHovering: mockSetHovering }),
+}))
+
+const createComment = (overrides: Partial = {}): WorkflowCommentList => {
+ const author = { id: 'user-1', name: 'Alice', email: 'alice@example.com' }
+ const participant = { id: 'user-2', name: 'Bob', email: 'bob@example.com' }
+
+ return {
+ id: 'comment-1',
+ position_x: 0,
+ position_y: 0,
+ content: 'Hello',
+ created_by: author.id,
+ created_by_account: author,
+ created_at: 1,
+ updated_at: 10,
+ resolved: false,
+ mention_count: 0,
+ reply_count: 0,
+ participants: [author, participant],
+ ...overrides,
+ }
+}
+
+describe('CommentPreview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ capturedUsers = []
+ })
+
+ it('orders participants with author first and formats time', () => {
+ const comment = createComment()
+
+ render()
+
+ expect(capturedUsers.map(user => user.id)).toEqual(['user-1', 'user-2'])
+ expect(screen.getByText('Hello')).toBeInTheDocument()
+ expect(screen.getByText('time:10000')).toBeInTheDocument()
+ })
+
+ it('updates hover state on enter and leave', () => {
+ const comment = createComment()
+ const { container } = render()
+ const root = container.firstElementChild as HTMLElement
+
+ fireEvent.mouseEnter(root)
+ fireEvent.mouseLeave(root)
+
+ expect(mockSetHovering).toHaveBeenCalledWith(true)
+ expect(mockSetHovering).toHaveBeenCalledWith(false)
+ })
+
+ it('clears hover state on unmount', () => {
+ const comment = createComment()
+ const { unmount } = render()
+
+ unmount()
+
+ expect(mockSetHovering).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/web/app/components/workflow/comment/cursor.spec.tsx b/web/app/components/workflow/comment/cursor.spec.tsx
new file mode 100644
index 0000000000..74cf222c1a
--- /dev/null
+++ b/web/app/components/workflow/comment/cursor.spec.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ControlMode } from '../types'
+import { CommentCursor } from './cursor'
+
+const mockState = {
+ controlMode: ControlMode.Pointer,
+ mousePosition: {
+ elementX: 10,
+ elementY: 20,
+ },
+}
+
+vi.mock('@/app/components/base/icons/src/public/other', () => ({
+ Comment: (props: { className?: string }) => ,
+}))
+
+vi.mock('../store', () => ({
+ useStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
+}))
+
+describe('CommentCursor', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders nothing when not in comment mode', () => {
+ mockState.controlMode = ControlMode.Pointer
+
+ render()
+
+ expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument()
+ })
+
+ it('renders at current mouse position when in comment mode', () => {
+ mockState.controlMode = ControlMode.Comment
+
+ render()
+
+ const icon = screen.getByTestId('comment-icon')
+ const container = icon.parentElement as HTMLElement
+
+ expect(container).toHaveStyle({ left: '10px', top: '20px' })
+ })
+})