diff --git a/web/app/components/workflow/panel/vibe-panel/index.spec.tsx b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx
new file mode 100644
index 0000000000..f47a171f28
--- /dev/null
+++ b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx
@@ -0,0 +1,323 @@
+/**
+ * VibePanel Component Tests
+ *
+ * Covers rendering states, user interactions, and edge cases for the vibe panel.
+ */
+
+import type { Shape as WorkflowState } from '@/app/components/workflow/store/workflow'
+import type { Edge, Node } from '@/app/components/workflow/types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import Toast from '@/app/components/base/toast'
+import { WorkflowContext } from '@/app/components/workflow/context'
+import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
+import { createHooksStore } from '@/app/components/workflow/hooks-store/store'
+import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { VIBE_APPLY_EVENT, VIBE_COMMAND_EVENT } from '../../constants'
+import VibePanel from './index'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+const mockCopy = vi.hoisted(() => vi.fn())
+const mockUseVibeFlowData = vi.hoisted(() => vi.fn())
+
+vi.mock('copy-to-clipboard', () => ({
+ default: mockCopy,
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ defaultModel: null }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
+ __esModule: true,
+ default: ({ modelId, provider }: { modelId: string, provider: string }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/hooks/use-workflow-vibe', () => ({
+ useVibeFlowData: () => mockUseVibeFlowData(),
+}))
+
+vi.mock('@/app/components/workflow/workflow-preview', () => ({
+ __esModule: true,
+ default: ({ nodes, edges }: { nodes: Node[], edges: Edge[] }) => (
+
+ ),
+}))
+
+// ============================================================================
+// Test Utilities
+// ============================================================================
+
+type FlowGraph = {
+ nodes: Node[]
+ edges: Edge[]
+}
+
+type VibeFlowData = {
+ versions: FlowGraph[]
+ currentVersionIndex: number
+ setCurrentVersionIndex: (index: number) => void
+ current?: FlowGraph
+}
+
+const createMockNode = (overrides: Partial = {}): Node => ({
+ id: 'node-1',
+ type: 'custom',
+ position: { x: 0, y: 0 },
+ data: {
+ title: 'Start',
+ desc: '',
+ type: BlockEnum.Start,
+ },
+ ...overrides,
+})
+
+const createMockEdge = (overrides: Partial = {}): Edge => ({
+ id: 'edge-1',
+ source: 'node-1',
+ target: 'node-2',
+ data: {
+ sourceType: BlockEnum.Start,
+ targetType: BlockEnum.End,
+ },
+ ...overrides,
+})
+
+const createFlowGraph = (overrides: Partial = {}): FlowGraph => ({
+ nodes: [],
+ edges: [],
+ ...overrides,
+})
+
+const createVibeFlowData = (overrides: Partial = {}): VibeFlowData => ({
+ versions: [],
+ currentVersionIndex: 0,
+ setCurrentVersionIndex: vi.fn(),
+ current: undefined,
+ ...overrides,
+})
+
+const renderVibePanel = ({
+ workflowState,
+ vibeFlowData,
+}: {
+ workflowState?: Partial
+ vibeFlowData?: VibeFlowData
+} = {}) => {
+ if (vibeFlowData)
+ mockUseVibeFlowData.mockReturnValue(vibeFlowData)
+
+ const workflowStore = createWorkflowStore({})
+ workflowStore.setState({
+ showVibePanel: true,
+ isVibeGenerating: false,
+ vibePanelInstruction: '',
+ vibePanelMermaidCode: '',
+ ...workflowState,
+ })
+
+ const hooksStore = createHooksStore({})
+
+ return {
+ workflowStore,
+ ...render(
+
+
+
+
+ ,
+ ),
+ }
+}
+
+const getCopyButton = () => {
+ const buttons = screen.getAllByRole('button')
+ const copyButton = buttons.find(button => button.textContent?.trim() === '' && button.querySelector('svg'))
+ if (!copyButton)
+ throw new Error('Copy button not found')
+ return copyButton
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('VibePanel', () => {
+ let toastNotifySpy: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseVibeFlowData.mockReturnValue(createVibeFlowData())
+ toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
+ })
+
+ afterEach(() => {
+ toastNotifySpy.mockRestore()
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering: default visibility and primary view states.
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render nothing when panel is hidden', () => {
+ renderVibePanel({ workflowState: { showVibePanel: false } })
+
+ expect(screen.queryByText(/app\.gotoAnything\.actions\.vibeTitle/i)).not.toBeInTheDocument()
+ })
+
+ it('should render placeholder when no preview data and not generating', () => {
+ renderVibePanel({
+ workflowState: { showVibePanel: true, isVibeGenerating: false },
+ vibeFlowData: createVibeFlowData({ current: undefined }),
+ })
+
+ expect(screen.getByText(/appDebug\.generate\.newNoDataLine1/i)).toBeInTheDocument()
+ })
+
+ it('should render loading state when generating', () => {
+ renderVibePanel({
+ workflowState: { showVibePanel: true, isVibeGenerating: true },
+ })
+
+ expect(screen.getByText(/workflow\.vibe\.generatingFlowchart/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'appDebug.generate.generate' })).toBeDisabled()
+ })
+
+ it('should render preview panel when nodes exist', () => {
+ const flowGraph = createFlowGraph({
+ nodes: [createMockNode()],
+ edges: [createMockEdge()],
+ })
+
+ renderVibePanel({
+ vibeFlowData: createVibeFlowData({
+ current: flowGraph,
+ versions: [flowGraph],
+ }),
+ })
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'workflow.vibe.apply' })).toBeInTheDocument()
+ expect(screen.getByText(/appDebug\.generate\.version/i)).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props: store-driven inputs that toggle behavior.
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ it('should render modal content when showVibePanel is true', () => {
+ renderVibePanel({ workflowState: { showVibePanel: true } })
+
+ expect(screen.getByText(/app\.gotoAnything\.actions\.vibeTitle/i)).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // User Interactions: input edits and action triggers.
+ // --------------------------------------------------------------------------
+ describe('User Interactions', () => {
+ it('should update instruction in store when typing', async () => {
+ const user = userEvent.setup()
+ const { workflowStore } = renderVibePanel()
+
+ const textarea = screen.getByPlaceholderText('workflow.vibe.missingInstruction')
+ await user.type(textarea, 'Build a vibe flow')
+
+ expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow')
+ })
+
+ it('should dispatch command event with instruction when generate clicked', async () => {
+ const user = userEvent.setup()
+ const { workflowStore } = renderVibePanel({
+ workflowState: { vibePanelInstruction: 'Generate a workflow' },
+ })
+
+ const handler = vi.fn()
+ document.addEventListener(VIBE_COMMAND_EVENT, handler)
+
+ await user.click(screen.getByRole('button', { name: 'appDebug.generate.generate' }))
+
+ expect(handler).toHaveBeenCalledTimes(1)
+ const event = handler.mock.calls[0][0] as CustomEvent<{ dsl?: string }>
+ expect(event.detail).toEqual({ dsl: workflowStore.getState().vibePanelInstruction })
+
+ document.removeEventListener(VIBE_COMMAND_EVENT, handler)
+ })
+
+ it('should close panel when dismiss clicked', async () => {
+ const user = userEvent.setup()
+ const { workflowStore } = renderVibePanel({
+ workflowState: {
+ vibePanelMermaidCode: 'graph TD',
+ isVibeGenerating: true,
+ },
+ })
+
+ await user.click(screen.getByRole('button', { name: 'appDebug.generate.dismiss' }))
+
+ const state = workflowStore.getState()
+ expect(state.showVibePanel).toBe(false)
+ expect(state.vibePanelMermaidCode).toBe('')
+ expect(state.isVibeGenerating).toBe(false)
+ })
+
+ it('should dispatch apply event and close panel when apply clicked', async () => {
+ const user = userEvent.setup()
+ const flowGraph = createFlowGraph({
+ nodes: [createMockNode()],
+ edges: [createMockEdge()],
+ })
+ const { workflowStore } = renderVibePanel({
+ workflowState: { vibePanelMermaidCode: 'graph TD' },
+ vibeFlowData: createVibeFlowData({
+ current: flowGraph,
+ versions: [flowGraph],
+ }),
+ })
+
+ const handler = vi.fn()
+ document.addEventListener(VIBE_APPLY_EVENT, handler)
+
+ await user.click(screen.getByRole('button', { name: 'workflow.vibe.apply' }))
+
+ expect(handler).toHaveBeenCalledTimes(1)
+ const state = workflowStore.getState()
+ expect(state.showVibePanel).toBe(false)
+ expect(state.vibePanelMermaidCode).toBe('')
+ expect(state.isVibeGenerating).toBe(false)
+
+ document.removeEventListener(VIBE_APPLY_EVENT, handler)
+ })
+
+ it('should copy mermaid and notify when copy clicked', async () => {
+ const user = userEvent.setup()
+ const flowGraph = createFlowGraph({
+ nodes: [createMockNode()],
+ edges: [createMockEdge()],
+ })
+
+ renderVibePanel({
+ workflowState: { vibePanelMermaidCode: 'graph TD' },
+ vibeFlowData: createVibeFlowData({
+ current: flowGraph,
+ versions: [flowGraph],
+ }),
+ })
+
+ await user.click(getCopyButton())
+
+ expect(mockCopy).toHaveBeenCalledWith('graph TD')
+ expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'common.actionMsg.copySuccessfully',
+ }))
+ })
+ })
+})