Merge main HEAD (segment 5) into sandboxed-agent-rebase

Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files.
Preserve sandbox/agent/collaboration features while adopting main's
UI refactorings (Dialog/AlertDialog/Popover), model provider updates,
and enterprise features.

Made-with: Cursor
This commit is contained in:
Novice
2026-03-23 14:20:06 +08:00
1671 changed files with 124822 additions and 22302 deletions

View File

@ -0,0 +1,59 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import ChatVariableButton from '../chat-variable-button'
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
describe('ChatVariableButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('opens the chat variable panel and closes the other workflow panels', () => {
const { store } = renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
showGlobalVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showChatVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
})
it('applies the active dark theme styles when the chat variable panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
initialStoreState: {
showChatVariablePanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('stays disabled without mutating panel state', () => {
const { store } = renderWorkflowComponent(<ChatVariableButton disabled />, {
initialStoreState: {
showChatVariablePanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showChatVariablePanel).toBe(false)
})
})

View File

@ -0,0 +1,63 @@
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import EditingTitle from '../editing-title'
const mockFormatTime = vi.fn()
const mockFormatTimeFromNow = vi.fn()
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: mockFormatTime,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
describe('EditingTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatTime.mockReturnValue('08:00:00')
mockFormatTimeFromNow.mockReturnValue('2 hours ago')
})
it('should render autosave, published time, and syncing status when the draft has metadata', () => {
const { container } = renderWorkflowComponent(<EditingTitle />, {
initialStoreState: {
draftUpdatedAt: 1_710_000_000_000,
publishedAt: 1_710_003_600_000,
isSyncingWorkflowDraft: true,
maximizeCanvas: true,
},
})
expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss')
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000)
expect(container.firstChild).toHaveClass('ml-2')
expect(container).toHaveTextContent('workflow.common.autoSaved')
expect(container).toHaveTextContent('08:00:00')
expect(container).toHaveTextContent('workflow.common.published')
expect(container).toHaveTextContent('2 hours ago')
expect(container).toHaveTextContent('workflow.common.syncingData')
})
it('should render unpublished status without autosave metadata when the workflow has not been published', () => {
const { container } = renderWorkflowComponent(<EditingTitle />, {
initialStoreState: {
draftUpdatedAt: 0,
publishedAt: 0,
isSyncingWorkflowDraft: false,
maximizeCanvas: false,
},
})
expect(mockFormatTime).not.toHaveBeenCalled()
expect(mockFormatTimeFromNow).not.toHaveBeenCalled()
expect(container.firstChild).not.toHaveClass('ml-2')
expect(container).toHaveTextContent('workflow.common.unpublished')
expect(container).not.toHaveTextContent('workflow.common.autoSaved')
expect(container).not.toHaveTextContent('workflow.common.syncingData')
})
})

View File

@ -0,0 +1,68 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import EnvButton from '../env-button'
const mockCloseAllInputFieldPanels = vi.fn()
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
describe('EnvButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should open the environment panel and close the other panels when clicked', () => {
const { store } = renderWorkflowComponent(<EnvButton disabled={false} />, {
initialStoreState: {
showChatVariablePanel: true,
showGlobalVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showEnvPanel).toBe(true)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should apply the active dark theme styles when the environment panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<EnvButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('should keep the button disabled when the disabled prop is true', () => {
const { store } = renderWorkflowComponent(<EnvButton disabled />, {
initialStoreState: {
showEnvPanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showEnvPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,68 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import GlobalVariableButton from '../global-variable-button'
const mockCloseAllInputFieldPanels = vi.fn()
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
describe('GlobalVariableButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should open the global variable panel and close the other panels when clicked', () => {
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
showChatVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showGlobalVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should apply the active dark theme styles when the global variable panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
initialStoreState: {
showGlobalVariablePanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('should keep the button disabled when the disabled prop is true', () => {
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled />, {
initialStoreState: {
showGlobalVariablePanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,126 @@
import type { VersionHistory } from '@/types/workflow'
import { screen } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import HeaderInRestoring from '../header-in-restoring'
const mockRestoreWorkflow = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn(() => '09:30:00'),
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn(() => '3 hours ago'),
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../hooks', () => ({
useWorkflowRun: () => ({
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_700_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_700_000_100,
updated_by: {
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
},
tool_published: false,
version: 'v1',
marked_name: 'Release 1',
marked_comment: '',
...overrides,
})
describe('HeaderInRestoring', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should disable restore when the flow id is not ready yet', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: undefined,
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
it('should enable restore when version and flow config are both ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeEnabled()
})
it('should keep restore disabled for draft versions even when flow config is ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion({
version: WorkflowVersion.Draft,
}),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
})

View File

@ -0,0 +1,109 @@
import type { VersionHistory } from '@/types/workflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import RestoringTitle from '../restoring-title'
const mockFormatTime = vi.fn()
const mockFormatTimeFromNow = vi.fn()
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: mockFormatTime,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_700_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_700_000_100,
updated_by: {
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
},
tool_published: false,
version: 'v1',
marked_name: 'Release 1',
marked_comment: '',
...overrides,
})
describe('RestoringTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatTime.mockReturnValue('09:30:00')
mockFormatTimeFromNow.mockReturnValue('3 hours ago')
})
it('should render draft metadata when the current version is a draft', () => {
const currentVersion = createVersion({
version: WorkflowVersion.Draft,
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000)
expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss')
expect(container).toHaveTextContent('workflow.versionHistory.currentDraft')
expect(container).toHaveTextContent('workflow.common.viewOnly')
expect(container).toHaveTextContent('workflow.common.unpublished')
expect(container).toHaveTextContent('3 hours ago 09:30:00')
expect(container).toHaveTextContent('Alice')
})
it('should render published metadata and fallback version name when the marked name is empty', () => {
const currentVersion = createVersion({
marked_name: '',
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000)
expect(container).toHaveTextContent('workflow.versionHistory.defaultName')
expect(container).toHaveTextContent('workflow.common.published')
expect(container).toHaveTextContent('Alice')
})
it('should render an empty creator name when the version creator name is missing', () => {
const currentVersion = createVersion({
created_by: {
id: 'user-1',
name: '',
email: 'alice@example.com',
},
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(container).toHaveTextContent('workflow.common.published')
expect(container).not.toHaveTextContent('Alice')
})
})

View File

@ -0,0 +1,150 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RunMode from '../run-mode'
import { TriggerType } from '../test-run-menu'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn()
const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockNotify = vi.fn()
const mockTrackEvent = vi.fn()
let mockWarningNodes: Array<{ id: string }> = []
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined
let mockIsListening = false
let mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowStartRun: () => ({
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow,
}),
useWorkflowRun: () => ({
handleStopRun: mockHandleStopRun,
}),
useWorkflowRunValidation: () => ({
warningNodes: mockWarningNodes,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-circle" />,
}))
vi.mock('../test-run-menu', async (importOriginal) => {
const actual = await importOriginal<typeof import('../test-run-menu')>()
return {
...actual,
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
React.useImperativeHandle(ref, () => ({
toggle: vi.fn(),
}))
return (
<div>
<button data-testid="trigger-option" onClick={() => onSelect(options[0])}>
Trigger option
</button>
{children}
</div>
)
}),
}
})
describe('RunMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWarningNodes = []
mockWorkflowRunningData = undefined
mockIsListening = false
mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
})
it('should render the run trigger and start the workflow when a valid trigger is selected', () => {
render(<RunMode />)
expect(screen.getByText(/run/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' })
})
it('should show an error toast instead of running when the selected trigger has checklist warnings', () => {
mockWarningNodes = [{ id: 'start-node' }]
render(<RunMode />)
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.panel.checklistTip',
})
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
})
it('should render the running state and stop the workflow when it is already running', () => {
mockWorkflowRunningData = {
result: { status: WorkflowRunningStatus.Running },
task_id: 'task-1',
}
render(<RunMode />)
expect(screen.getByText(/running/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement)
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
})
it('should render the listening label when the workflow is listening', () => {
mockIsListening = true
render(<RunMode />)
expect(screen.getByText(/listening/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,61 @@
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import RunningTitle from '../running-title'
let mockIsChatMode = false
const mockFormatWorkflowRunIdentifier = vi.fn()
vi.mock('../../hooks', () => ({
useIsChatMode: () => mockIsChatMode,
}))
vi.mock('../../utils', () => ({
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
}))
describe('RunningTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)')
})
it('should render the test run title in workflow mode', () => {
const { container } = renderWorkflowComponent(<RunningTitle />, {
initialStoreState: {
historyWorkflowData: {
id: 'history-1',
status: 'succeeded',
finished_at: 1_700_000_000,
},
},
})
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000)
expect(container).toHaveTextContent('Test Run (14:30:25)')
expect(container).toHaveTextContent('workflow.common.viewOnly')
})
it('should render the test chat title in chat mode', () => {
mockIsChatMode = true
const { container } = renderWorkflowComponent(<RunningTitle />, {
initialStoreState: {
historyWorkflowData: {
id: 'history-2',
status: 'running',
finished_at: undefined,
},
},
})
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
expect(container).toHaveTextContent('Test Chat (14:30:25)')
})
it('should handle missing workflow history data', () => {
const { container } = renderWorkflowComponent(<RunningTitle />)
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
expect(container).toHaveTextContent('Test Run (14:30:25)')
})
})

View File

@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button'
const mockScrollToWorkflowNode = vi.fn()
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('../../utils/node-navigation', () => ({
scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
}))
describe('ScrollToSelectedNodeButton', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
})
it('should render nothing when there is no selected node', () => {
rfState.nodes = [
createNode({
id: 'node-1',
data: { selected: false },
}),
]
const { container } = render(<ScrollToSelectedNodeButton />)
expect(container.firstChild).toBeNull()
})
it('should render the action and scroll to the selected node when clicked', () => {
rfState.nodes = [
createNode({
id: 'node-1',
data: { selected: false },
}),
createNode({
id: 'node-2',
data: { selected: true },
}),
]
render(<ScrollToSelectedNodeButton />)
fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode'))
expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2')
expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,118 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import UndoRedo from '../undo-redo'
type TemporalSnapshot = {
pastStates: unknown[]
futureStates: unknown[]
}
const mockUnsubscribe = vi.fn()
const mockTemporalSubscribe = vi.fn()
const mockHandleUndo = vi.fn()
const mockHandleRedo = vi.fn()
let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined
let mockNodesReadOnly = false
vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({
default: () => <div data-testid="view-workflow-history" />,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('@/app/components/workflow/workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
store: {
temporal: {
subscribe: mockTemporalSubscribe,
},
},
shortcutsEnabled: true,
setShortcutsEnabled: vi.fn(),
}),
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/workflow/operator/tip-popup', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
describe('UndoRedo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
latestTemporalListener = undefined
mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => {
latestTemporalListener = listener
return mockUnsubscribe
})
})
it('enables undo and redo when history exists and triggers the callbacks', () => {
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
act(() => {
latestTemporalListener?.({
pastStates: [{}],
futureStates: [{}],
})
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' }))
expect(mockHandleUndo).toHaveBeenCalledTimes(1)
expect(mockHandleRedo).toHaveBeenCalledTimes(1)
})
it('keeps the buttons disabled before history is available', () => {
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
fireEvent.click(undoButton)
fireEvent.click(redoButton)
expect(undoButton).toBeDisabled()
expect(redoButton).toBeDisabled()
expect(mockHandleUndo).not.toHaveBeenCalled()
expect(mockHandleRedo).not.toHaveBeenCalled()
})
it('does not trigger callbacks when the canvas is read only', () => {
mockNodesReadOnly = true
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
act(() => {
latestTemporalListener?.({
pastStates: [{}],
futureStates: [{}],
})
})
fireEvent.click(undoButton)
fireEvent.click(redoButton)
expect(undoButton).toBeDisabled()
expect(redoButton).toBeDisabled()
expect(mockHandleUndo).not.toHaveBeenCalled()
expect(mockHandleRedo).not.toHaveBeenCalled()
})
it('unsubscribes from the temporal store on unmount', () => {
const { unmount } = render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
unmount()
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import VersionHistoryButton from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('../../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils')>()
return {
...actual,
getKeyboardKeyCodeBySystem: () => 'ctrl',
}
})
describe('VersionHistoryButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should call onClick when the button is clicked', () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should trigger onClick when the version history shortcut is pressed', () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'H',
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
})
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
window.dispatchEvent(keyboardEvent)
expect(keyboardEvent.defaultPrevented).toBe(true)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should render the tooltip popup content on hover', async () => {
render(<VersionHistoryButton onClick={vi.fn()} />)
fireEvent.mouseEnter(screen.getByRole('button'))
expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
})
it('should apply dark theme styles when the theme is dark', () => {
mockTheme = 'dark'
render(<VersionHistoryButton onClick={vi.fn()} />)
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
})

View File

@ -0,0 +1,276 @@
import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { ControlMode, WorkflowRunningStatus } from '../../types'
import ViewHistory from '../view-history'
const mockUseWorkflowRunHistory = vi.fn()
const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
const mockCloseAllInputFieldPanels = vi.fn()
const mockHandleNodesCancelSelected = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
let mockIsChatMode = false
vi.mock('../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
return {
...actual,
useIsChatMode: () => mockIsChatMode,
useNodesInteractions: () => ({
handleNodesCancelSelected: mockHandleNodesCancelSelected,
}),
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}
})
vi.mock('@/service/use-workflow', () => ({
useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
return {
PortalToFollowElem: ({
children,
open,
}: {
children?: React.ReactNode
open: boolean
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children?: React.ReactNode
onClick?: () => void
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({
children,
}: {
children?: React.ReactNode
}) => {
const { open } = React.useContext(PortalContext)
return open ? <div data-testid="portal-content">{children}</div> : null
},
}
})
vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
return {
...actual,
formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status),
}
})
const createHistoryItem = (overrides: Partial<WorkflowRunHistory> = {}): WorkflowRunHistory => ({
id: 'run-1',
version: 'v1',
graph: {
nodes: [],
edges: [],
},
inputs: {},
status: WorkflowRunningStatus.Succeeded,
outputs: {},
elapsed_time: 1,
total_tokens: 2,
total_steps: 3,
created_at: 100,
finished_at: 120,
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
...overrides,
})
describe('ViewHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
})
it('defers fetching until the history popup is opened and renders the empty state', () => {
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
})
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true)
expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument()
expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument()
})
it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => {
const onClearLogAndMessageModal = vi.fn()
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: true,
})
renderWorkflowComponent(
<ViewHistory
historyUrl="/history"
onClearLogAndMessageModal={onClearLogAndMessageModal}
/>,
{
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('renders workflow run history items and updates the workflow store when one is selected', () => {
const handleBackupDraft = vi.fn()
const pausedRun = createHistoryItem({
id: 'run-paused',
status: WorkflowRunningStatus.Paused,
created_at: 101,
finished_at: 0,
})
const failedRun = createHistoryItem({
id: 'run-failed',
status: WorkflowRunningStatus.Failed,
created_at: 102,
finished_at: 130,
})
const succeededRun = createHistoryItem({
id: 'run-succeeded',
status: WorkflowRunningStatus.Succeeded,
created_at: 103,
finished_at: 140,
})
mockUseWorkflowRunHistory.mockReturnValue({
data: {
data: [pausedRun, failedRun, succeededRun],
} satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
const { store } = renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
initialStoreState: {
historyWorkflowData: failedRun,
showInputsPanel: true,
showEnvPanel: true,
controlMode: ControlMode.Pointer,
},
hooksStoreProps: {
handleBackupDraft,
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(screen.getByText('Test Run (paused)')).toBeInTheDocument()
expect(screen.getByText('Test Run (failed)')).toBeInTheDocument()
expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument()
fireEvent.click(screen.getByText('Test Run (succeeded)'))
expect(store.getState().historyWorkflowData).toEqual(succeededRun)
expect(store.getState().showInputsPanel).toBe(false)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().controlMode).toBe(ControlMode.Hand)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
expect(handleBackupDraft).toHaveBeenCalledTimes(1)
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('renders chat history labels without workflow status icons in chat mode', () => {
mockIsChatMode = true
const chatRun = createHistoryItem({
id: 'chat-run',
status: WorkflowRunningStatus.Failed,
})
mockUseWorkflowRunHistory.mockReturnValue({
data: {
data: [chatRun],
} satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument()
})
it('closes the popup from the close button and clears log modals', () => {
const onClearLogAndMessageModal = vi.fn()
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
renderWorkflowComponent(
<ViewHistory
historyUrl="/history"
withText
onClearLogAndMessageModal={onClearLogAndMessageModal}
/>,
{
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})

View File

@ -1,201 +0,0 @@
import type { ChecklistItem } from '../hooks/use-checklist'
import type {
BlockEnum,
CommonEdgeType,
} from '../types'
import {
RiCloseLine,
RiListCheck3,
} from '@remixicon/react'
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useEdges,
} from 'reactflow'
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { IconR } from '@/app/components/base/icons/src/vender/line/arrows'
import {
ChecklistSquare,
} from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { cn } from '@/utils/classnames'
import BlockIcon from '../block-icon'
import {
useChecklist,
useNodesInteractions,
} from '../hooks'
type WorkflowChecklistProps = {
disabled: boolean
showGoTo?: boolean
onItemClick?: (item: ChecklistItem) => void
}
const WorkflowChecklist = ({
disabled,
showGoTo = true,
onItemClick,
}: WorkflowChecklistProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const edges = useEdges<CommonEdgeType>()
const nodes = useNodes()
const needWarningNodes = useChecklist(nodes, edges)
const { handleNodeSelect } = useNodesInteractions()
const handleChecklistItemClick = (item: ChecklistItem) => {
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
if (!goToEnabled)
return
if (onItemClick)
onItemClick(item)
else
handleNodeSelect(item.id)
setOpen(false)
}
return (
<PortalToFollowElem
placement="bottom-end"
offset={{
mainAxis: 12,
crossAxis: 4,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(v => !v)}>
<div
className={cn(
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<div
className={cn('group flex h-full w-full cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
>
<RiListCheck3
className={cn('h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
/>
</div>
{
!!needWarningNodes.length && (
<div className="absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-[#F79009] text-[11px] font-semibold text-white">
{needWarningNodes.length}
</div>
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[12]">
<div
className="w-[420px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
style={{
maxHeight: 'calc(2 / 3 * 100vh)',
}}
>
<div className="text-md sticky top-0 z-[1] flex h-[44px] items-center bg-components-panel-bg pl-4 pr-3 pt-3 font-semibold text-text-primary">
<div className="grow">
{t('panel.checklist', { ns: 'workflow' })}
{needWarningNodes.length ? `(${needWarningNodes.length})` : ''}
</div>
<div
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
onClick={() => setOpen(false)}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="pb-2">
{
!!needWarningNodes.length && (
<>
<div className="px-4 pt-1 text-xs text-text-tertiary">{t('panel.checklistTip', { ns: 'workflow' })}</div>
<div className="px-4 py-2">
{
needWarningNodes.map(node => (
<div
key={node.id}
className={cn(
'group mb-2 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0',
showGoTo && node.canNavigate && !node.disableGoTo ? 'cursor-pointer' : 'cursor-default opacity-80',
)}
onClick={() => handleChecklistItemClick(node)}
>
<div className="flex h-9 items-center p-2 text-xs font-medium text-text-secondary">
<BlockIcon
type={node.type as BlockEnum}
className="mr-1.5"
toolIcon={node.toolIcon}
/>
<span className="grow truncate">
{node.title}
</span>
{
(showGoTo && node.canNavigate && !node.disableGoTo) && (
<div className="flex h-4 w-[60px] shrink-0 items-center justify-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="whitespace-nowrap text-xs font-medium leading-4 text-primary-600">
{t('panel.goTo', { ns: 'workflow' })}
</span>
<IconR className="h-3.5 w-3.5 text-primary-600" />
</div>
)
}
</div>
<div
className={cn(
'rounded-b-lg border-t-[0.5px] border-divider-regular',
(node.unConnected || node.errorMessage) && 'bg-gradient-to-r from-components-badge-bg-orange-soft to-transparent',
)}
>
{
node.unConnected && (
<div className="px-3 py-1 first:pt-1.5 last:pb-1.5">
<div className="flex text-xs leading-4 text-text-tertiary">
<Warning className="mr-2 mt-[2px] h-3 w-3 text-[#F79009]" />
{t('common.needConnectTip', { ns: 'workflow' })}
</div>
</div>
)
}
{
node.errorMessage && (
<div className="px-3 py-1 first:pt-1.5 last:pb-1.5">
<div className="flex text-xs leading-4 text-text-tertiary">
<Warning className="mr-2 mt-[2px] h-3 w-3 text-[#F79009]" />
{node.errorMessage}
</div>
</div>
)
}
</div>
</div>
))
}
</div>
</>
)
}
{
!needWarningNodes.length && (
<div className="mx-4 mb-3 rounded-lg bg-components-panel-bg py-4 text-center text-xs text-text-tertiary">
<ChecklistSquare className="mx-auto mb-[5px] h-8 w-8 text-text-quaternary" />
{t('panel.checklistResolved', { ns: 'workflow' })}
</div>
)
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(WorkflowChecklist)

View File

@ -0,0 +1,131 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../../types'
import WorkflowChecklist from '../index'
let mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
const mockHandleNodeSelect = vi.fn()
type PopoverProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('reactflow', () => ({
useEdges: () => [],
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => [],
}))
vi.mock('../../../hooks', () => ({
useChecklist: () => mockChecklistItems,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="popover">{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
}))
vi.mock('../plugin-group', () => ({
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
}))
vi.mock('../node-group', () => ({
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
{item.title}
</button>
),
}))
describe('WorkflowChecklist', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
})
it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => {
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin')
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
})
it('should use the custom item click handler when provided', () => {
const onItemClick = vi.fn()
render(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />)
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
})
it('should render the resolved state when there are no checklist warnings', () => {
mockChecklistItems = []
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument()
})
it('should ignore popover open changes when the checklist is disabled', () => {
render(<WorkflowChecklist disabled={true} />)
latestOnOpenChange?.(true)
expect(screen.getByText('2').closest('button')).toBeDisabled()
})
})

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../../types'
import { ChecklistNodeGroup } from '../node-group'
vi.mock('../../../block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('../item-indicator', () => ({
ItemIndicator: () => <div data-testid="item-indicator" />,
}))
const createItem = (overrides: Record<string, unknown> = {}) => ({
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
disableGoTo: false,
unConnected: false,
...overrides,
})
describe('ChecklistNodeGroup', () => {
it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ unConnected: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Needs configuration')).toBeInTheDocument()
expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument()
expect(screen.getAllByText(/goToFix/i)).toHaveLength(2)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
})
it('should not allow navigation when go-to is disabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ disableGoTo: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).not.toHaveBeenCalled()
expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,96 @@
import type { ChecklistItem } from '../../../hooks/use-checklist'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { Popover, PopoverContent } from '@/app/components/base/ui/popover'
import { useStore as usePluginDependencyStore } from '../../../plugin-dependency/store'
import { BlockEnum } from '../../../types'
import { ChecklistPluginGroup } from '../plugin-group'
const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistItem => ({
id: 'node-1',
type: BlockEnum.Tool,
title: 'Tool Node',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256',
...overrides,
})
describe('ChecklistPluginGroup', () => {
const getInstallButton = () => {
return screen.getByText('workflow.nodes.agent.pluginInstaller.install').closest('button') as HTMLButtonElement
}
const renderInPopover = (items: ChecklistItem[]) => {
return render(
<Popover open>
<PopoverContent>
<ChecklistPluginGroup items={items} />
</PopoverContent>
</Popover>,
)
}
beforeEach(() => {
usePluginDependencyStore.setState({ dependencies: [] })
})
it('should set marketplace dependencies when install button is clicked', () => {
const items: ChecklistItem[] = [
createChecklistItem({ id: 'node-1', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
createChecklistItem({ id: 'node-2', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
createChecklistItem({ id: 'node-3', pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256' }),
]
renderInPopover(items)
fireEvent.click(getInstallButton())
expect(usePluginDependencyStore.getState().dependencies).toEqual([
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
version: '1.0.0',
},
},
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
version: '2.0.0',
},
},
])
})
it('should keep install button disabled when no identifier is available', () => {
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: undefined })])
const installButton = getInstallButton()
expect(installButton).toBeDisabled()
fireEvent.click(installButton)
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
})
it('should omit the version when the marketplace identifier does not include one', () => {
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })])
fireEvent.click(getInstallButton())
expect(usePluginDependencyStore.getState().dependencies).toEqual([
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256',
plugin_unique_identifier: 'langgenius/test-plugin@sha256',
version: undefined,
},
},
])
})
})

View File

@ -0,0 +1,151 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import type {
CommonEdgeType,
} from '../../types'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useEdges,
} from 'reactflow'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { cn } from '@/utils/classnames'
import {
useChecklist,
useNodesInteractions,
} from '../../hooks'
import { ChecklistNodeGroup } from './node-group'
import { ChecklistPluginGroup } from './plugin-group'
type WorkflowChecklistProps = {
disabled: boolean
showGoTo?: boolean
onItemClick?: (item: ChecklistItem) => void
}
const WorkflowChecklist = ({
disabled,
showGoTo = true,
onItemClick,
}: WorkflowChecklistProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const edges = useEdges<CommonEdgeType>()
const nodes = useNodes()
const needWarningNodes = useChecklist(nodes, edges)
const { handleNodeSelect } = useNodesInteractions()
const { pluginItems, nodeItems } = useMemo(() => {
const plugins: ChecklistItem[] = []
const regular: ChecklistItem[] = []
for (const item of needWarningNodes) {
if (item.isPluginMissing)
plugins.push(item)
else
regular.push(item)
}
return { pluginItems: plugins, nodeItems: regular }
}, [needWarningNodes])
const handleItemClick = (item: ChecklistItem) => {
if (onItemClick)
onItemClick(item)
else
handleNodeSelect(item.id)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={newOpen => !disabled && setOpen(newOpen)}>
<PopoverTrigger
render={(
<button
type="button"
className={cn(
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md border-none bg-transparent p-0',
disabled && 'cursor-not-allowed opacity-50',
)}
disabled={disabled || undefined}
>
<span
className={cn('group flex h-full w-full items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
>
<span
className={cn('i-ri-list-check-3 h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
/>
</span>
{!!needWarningNodes.length && (
<span className="absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-text-warning-secondary text-[11px] font-semibold text-white">
{needWarningNodes.length}
</span>
)}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={12}
alignOffset={-30}
popupClassName="w-[420px] rounded-2xl bg-background-default-subtle"
>
<div
className="overflow-y-auto"
style={{ maxHeight: 'calc(2 / 3 * 100vh)' }}
>
<div className="flex flex-col gap-0.5 px-3 pb-1 pt-3.5">
<div className="flex items-start px-1">
<div className="min-w-0 grow pr-8">
<h2 className="text-base font-semibold leading-6 text-text-primary">
{t('panel.checklist', { ns: 'workflow' })}
{needWarningNodes.length > 0 && `(${needWarningNodes.length})`}
</h2>
</div>
<PopoverClose className="-mr-0.5 -mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg">
<span className="i-ri-close-line size-4 text-text-tertiary" />
</PopoverClose>
</div>
{needWarningNodes.length > 0 && (
<p className="px-1 text-xs leading-4 text-text-tertiary">
{t('panel.checklistDescription', { ns: 'workflow' })}
</p>
)}
</div>
{needWarningNodes.length > 0
? (
<div className="flex flex-col gap-1 px-4 pb-4 pt-1">
{pluginItems.length > 0 && (
<ChecklistPluginGroup items={pluginItems} />
)}
{nodeItems.map(item => (
<ChecklistNodeGroup
key={item.id}
item={item}
showGoTo={showGoTo}
onItemClick={handleItemClick}
/>
))}
</div>
)
: (
<div className="mx-4 mb-3 rounded-lg py-4 text-center text-xs text-text-tertiary">
<span className="i-custom-vender-line-general-checklist-square mx-auto mb-[5px] block h-8 w-8 text-text-quaternary" />
{t('panel.checklistResolved', { ns: 'workflow' })}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
export default memo(WorkflowChecklist)

View File

@ -0,0 +1,21 @@
import { cn } from '@/utils/classnames'
type ItemIndicatorProps = {
className?: string
}
export const ItemIndicator = ({ className }: ItemIndicatorProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="24"
viewBox="0 0 20 24"
fill="none"
className={cn('shrink-0', className)}
>
<path d="M9.5 0H10.5V24H9.5V0Z" fill="currentColor" className="text-divider-regular" />
<circle cx="10" cy="12" r="3.25" fill="#F79009" stroke="white" strokeWidth="1.5" />
</svg>
)
}

View File

@ -0,0 +1,75 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import type { BlockEnum } from '../../types'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import BlockIcon from '../../block-icon'
import { ItemIndicator } from './item-indicator'
type ChecklistSubItem = {
key: string
message: string
}
export const ChecklistNodeGroup = memo(({
item,
showGoTo,
onItemClick,
}: {
item: ChecklistItem
showGoTo: boolean
onItemClick: (item: ChecklistItem) => void
}) => {
const { t } = useTranslation()
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
const subItems = useMemo(() => {
const items: ChecklistSubItem[] = []
for (let i = 0; i < item.errorMessages.length; i++)
items.push({ key: `error-${i}`, message: item.errorMessages[i] })
if (item.unConnected)
items.push({ key: 'unconnected', message: t('common.needConnectTip', { ns: 'workflow' }) })
return items
}, [item.errorMessages, item.unConnected, t])
return (
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
<div className="flex items-center gap-2 px-2 pt-2">
<BlockIcon
type={item.type as BlockEnum}
size="sm"
toolIcon={item.toolIcon}
/>
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
{item.title}
</span>
</div>
<div className="p-1">
{subItems.map(sub => (
<div
key={sub.key}
className={cn(
'group/item flex items-center gap-2 rounded-lg px-1',
goToEnabled && 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={() => goToEnabled && onItemClick(item)}
>
<ItemIndicator />
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
{sub.message}
</span>
{goToEnabled && (
<div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
<span className="whitespace-nowrap text-xs font-medium leading-4 text-text-accent">
{t('panel.goToFix', { ns: 'workflow' })}
</span>
<span className="i-ri-arrow-right-line size-3.5 text-text-accent" />
</div>
)}
</div>
))}
</div>
</div>
)
})
ChecklistNodeGroup.displayName = 'ChecklistNodeGroup'

View File

@ -0,0 +1,99 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import type { BlockEnum } from '../../types'
import type { Dependency } from '@/app/components/plugins/types'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PopoverClose } from '@/app/components/base/ui/popover'
import BlockIcon from '../../block-icon'
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
import { ItemIndicator } from './item-indicator'
function getVersionFromMarketplaceIdentifier(identifier: string): string | undefined {
const withoutHash = identifier.split('@')[0]
const [, version] = withoutHash.split(':')
return version || undefined
}
export const ChecklistPluginGroup = memo(({
items,
}: {
items: ChecklistItem[]
}) => {
const { t } = useTranslation()
const identifiers = useMemo(
() => Array.from(
new Set(
items
.map(i => i.pluginUniqueIdentifier)
.filter((id): id is string => Boolean(id)),
),
),
[items],
)
const dependencies = useMemo<Dependency[]>(() => {
return identifiers.map((identifier) => {
return {
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: identifier,
plugin_unique_identifier: identifier,
version: getVersionFromMarketplaceIdentifier(identifier),
},
}
})
}, [identifiers])
const handleInstallAll = () => {
if (dependencies.length === 0)
return
const { setDependencies } = usePluginDependencyStore.getState()
setDependencies(dependencies)
}
return (
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
<div className="flex items-center gap-2 px-2 pt-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-icon-bg-midnight-solid shadow-xs">
<span className="i-ri-download-line size-3.5 text-white" />
</div>
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
{t('nodes.common.pluginsNotInstalled', { ns: 'workflow', count: items.length })}
</span>
<PopoverClose
render={(
<Button
variant="secondary"
size="small"
onClick={handleInstallAll}
disabled={dependencies.length === 0}
/>
)}
>
{t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}
</PopoverClose>
</div>
<div className="p-1">
{items.map(item => (
<div
key={item.id}
className="flex items-center gap-2 rounded-lg px-1"
>
<ItemIndicator />
<BlockIcon
type={item.type as BlockEnum}
size="xs"
toolIcon={item.toolIcon}
/>
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
{item.title}
</span>
</div>
))}
</div>
</div>
)
})
ChecklistPluginGroup.displayName = 'ChecklistPluginGroup'

View File

@ -47,6 +47,7 @@ const HeaderInRestoring = ({
handleLoadBackupDraft,
} = useWorkflowRun()
const { requestRestore } = useLeaderRestore()
const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
@ -55,7 +56,7 @@ const HeaderInRestoring = ({
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const handleRestore = useCallback(() => {
if (!currentVersion)
if (!currentVersion || !canRestore)
return
setShowWorkflowVersionHistoryPanel(false)
@ -99,7 +100,7 @@ const HeaderInRestoring = ({
})
deleteAllInspectVars()
invalidAllLastRun()
}, [currentVersion, featuresStore, setShowWorkflowVersionHistoryPanel, workflowStore, requestRestore, userProfile, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
}, [canRestore, currentVersion, featuresStore, setShowWorkflowVersionHistoryPanel, workflowStore, requestRestore, userProfile, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
return (
<>
@ -109,7 +110,7 @@ const HeaderInRestoring = ({
<div className="flex items-center justify-end gap-x-2">
<Button
onClick={handleRestore}
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
disabled={!canRestore}
variant="primary"
className={cn(
'rounded-lg border border-transparent',

View File

@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
import type { HeaderInNormalProps } from './header-in-normal'
import type { HeaderInRestoringProps } from './header-in-restoring'
import type { HeaderInHistoryProps } from './header-in-view-history'
import dynamic from 'next/dynamic'
import { usePathname } from 'next/navigation'
import dynamic from '@/next/dynamic'
import { usePathname } from '@/next/navigation'
import {
useWorkflowMode,
} from '../hooks'

View File

@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { CommonNodeType } from '../types'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { cn } from '@/utils/classnames'
@ -11,12 +10,6 @@ const ScrollToSelectedNodeButton: FC = () => {
const nodes = useNodes<CommonNodeType>()
const selectedNode = nodes.find(node => node.data.selected)
const handleScrollToSelectedNode = useCallback(() => {
if (!selectedNode)
return
scrollToWorkflowNode(selectedNode.id)
}, [selectedNode])
if (!selectedNode)
return null
@ -25,7 +18,7 @@ const ScrollToSelectedNodeButton: FC = () => {
className={cn(
'flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 system-xs-medium hover:text-text-accent',
)}
onClick={handleScrollToSelectedNode}
onClick={() => scrollToWorkflowNode(selectedNode.id)}
>
{t('panel.scrollToSelectedNode', { ns: 'workflow' })}
</div>

View File

@ -1,8 +1,4 @@
import type { FC } from 'react'
import {
RiArrowGoBackLine,
RiArrowGoForwardFill,
} from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
@ -33,28 +29,34 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
return (
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
<div
<button
type="button"
aria-label={t('common.undo', { ns: 'workflow' })!}
data-tooltip-id="workflow.undo"
disabled={nodesReadOnly || buttonsDisabled.undo}
className={
cn('flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary system-sm-medium hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.undo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
onClick={handleUndo}
>
<RiArrowGoBackLine className="h-4 w-4" />
</div>
<span className="i-ri-arrow-go-back-line h-4 w-4" />
</button>
</TipPopup>
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
<div
<button
type="button"
aria-label={t('common.redo', { ns: 'workflow' })!}
data-tooltip-id="workflow.redo"
disabled={nodesReadOnly || buttonsDisabled.redo}
className={
cn('flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary system-sm-medium hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.redo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
onClick={handleRedo}
>
<RiArrowGoForwardFill className="h-4 w-4" />
</div>
<span className="i-ri-arrow-go-forward-fill h-4 w-4" />
</button>
</TipPopup>
<Divider type="vertical" className="mx-0.5 h-3.5" />
<ViewWorkflowHistory />

View File

@ -73,15 +73,18 @@ const ViewHistory = ({
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{
withText && (
<div className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
)}
<button
type="button"
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
)}
>
<span className="i-custom-vender-line-time-clock-play mr-1 h-4 w-4" />
{t('common.showRunHistory', { ns: 'workflow' })}
</div>
</button>
)
}
{
@ -89,14 +92,16 @@ const ViewHistory = ({
<Tooltip
popupContent={t('common.viewRunHistory', { ns: 'workflow' })}
>
<div
<button
type="button"
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
onClick={() => {
onClearLogAndMessageModal?.()
}}
>
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
</div>
</button>
</Tooltip>
)
}
@ -110,7 +115,9 @@ const ViewHistory = ({
>
<div className="sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary">
<div className="grow">{t('common.runHistory', { ns: 'workflow' })}</div>
<div
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
onClick={() => {
onClearLogAndMessageModal?.()
@ -118,7 +125,7 @@ const ViewHistory = ({
}}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</button>
</div>
{
isLoading && (