diff --git a/web/app/components/workflow/block-selector/snippets.tsx b/web/app/components/workflow/block-selector/snippets.tsx
deleted file mode 100644
index 37f571e587..0000000000
--- a/web/app/components/workflow/block-selector/snippets.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-import type { CreateSnippetDialogPayload } from '../create-snippet-dialog'
-import type { Snippet as SnippetDetail } from '@/types/snippet'
-import {
- memo,
- useDeferredValue,
- useMemo,
- useState,
-} from 'react'
-import { useTranslation } from 'react-i18next'
-import Button from '@/app/components/base/button'
-import { toast } from '@/app/components/base/ui/toast'
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from '@/app/components/base/ui/tooltip'
-import { useRouter } from '@/next/navigation'
-import { consoleClient } from '@/service/client'
-import { useCreateSnippetMutation } from '@/service/use-snippets'
-import { cn } from '@/utils/classnames'
-import CreateSnippetDialog from '../create-snippet-dialog'
-import { BlockEnum } from '../types'
-import SnippetDetailCard from './snippet-detail-card'
-import SnippetListItem from './snippet-list-item'
-
-type SnippetsProps = {
- loading?: boolean
- searchText: string
-}
-
-type StaticSnippet = SnippetDetail & {
- relatedBlocks?: BlockEnum[]
-}
-
-const STATIC_SNIPPETS: StaticSnippet[] = [
- {
- id: 'customer-review',
- name: 'Customer Review',
- description: 'Collects customer review context, classifies request intent, and routes the workflow through the right generation branch.',
- author: 'Evan',
- type: 'group',
- is_published: true,
- version: '1.0.0',
- use_count: 128,
- input_fields: [],
- created_at: 1742889600,
- updated_at: 1742976000,
- icon_info: {
- icon_type: 'emoji',
- icon: '🧾',
- icon_background: '#FFEAD5',
- icon_url: '',
- },
- relatedBlocks: [
- BlockEnum.LLM,
- BlockEnum.Code,
- BlockEnum.KnowledgeRetrieval,
- BlockEnum.QuestionClassifier,
- BlockEnum.IfElse,
- ],
- },
-] as const
-
-const LoadingSkeleton = () => {
- return (
-
-
- {['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
-
- ))}
-
-
-
- )
-}
-
-const Snippets = ({
- loading = false,
- searchText,
-}: SnippetsProps) => {
- const { t } = useTranslation()
- const { push } = useRouter()
- const createSnippetMutation = useCreateSnippetMutation()
- const deferredSearchText = useDeferredValue(searchText)
- const [hoveredSnippetId, setHoveredSnippetId] = useState(null)
- const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
- const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
-
- const snippets = useMemo(() => {
- return STATIC_SNIPPETS.map(item => ({
- ...item,
- }))
- }, [])
-
- const handleCloseCreateSnippetDialog = () => {
- setIsCreateSnippetDialogOpen(false)
- }
-
- const handleCreateSnippet = async ({
- name,
- description,
- icon,
- graph,
- }: CreateSnippetDialogPayload) => {
- setIsCreatingSnippet(true)
-
- try {
- const snippet = await createSnippetMutation.mutateAsync({
- body: {
- name,
- description: description || undefined,
- icon_info: {
- icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
- icon_type: icon.type,
- icon_background: icon.type === 'emoji' ? icon.background : undefined,
- icon_url: icon.type === 'image' ? icon.url : undefined,
- },
- },
- })
-
- await consoleClient.snippets.syncDraftWorkflow({
- params: { snippetId: snippet.id },
- body: { graph },
- })
-
- toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
- handleCloseCreateSnippetDialog()
- push(`/snippets/${snippet.id}/orchestrate`)
- }
- catch (error) {
- toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
- }
- finally {
- setIsCreatingSnippet(false)
- }
- }
-
- const filteredSnippets = useMemo(() => {
- const normalizedSearch = deferredSearchText.trim().toLowerCase()
- if (!normalizedSearch)
- return snippets
-
- return snippets.filter(item => item.name.toLowerCase().includes(normalizedSearch))
- }, [deferredSearchText, snippets])
-
- if (loading)
- return
-
- if (!filteredSnippets.length) {
- return (
- <>
-
-
-
- {t('tabs.noSnippetsFound', { ns: 'workflow' })}
-
-
-
-
- >
- )
- }
-
- return (
-
- {filteredSnippets.map((item) => {
- const row = (
-
setHoveredSnippetId(item.id)}
- onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
- />
- )
-
- if (!item.description)
- return {row}
-
- return (
-
-
-
-
-
-
- )
- })}
-
- )
-}
-
-export default memo(Snippets)
diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx
new file mode 100644
index 0000000000..50598eb304
--- /dev/null
+++ b/web/app/components/workflow/block-selector/snippets/index.tsx
@@ -0,0 +1,223 @@
+import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog'
+import { useInfiniteScroll } from 'ahooks'
+import {
+ memo,
+ useDeferredValue,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import Loading from '@/app/components/base/loading'
+import {
+ ScrollAreaContent,
+ ScrollAreaRoot,
+ ScrollAreaScrollbar,
+ ScrollAreaThumb,
+ ScrollAreaViewport,
+} from '@/app/components/base/ui/scroll-area'
+import { toast } from '@/app/components/base/ui/toast'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/app/components/base/ui/tooltip'
+import { useRouter } from '@/next/navigation'
+import { consoleClient } from '@/service/client'
+import {
+ useCreateSnippetMutation,
+ useInfiniteSnippetList,
+} from '@/service/use-snippets'
+import { cn } from '@/utils/classnames'
+import CreateSnippetDialog from '../../create-snippet-dialog'
+import SnippetDetailCard from './snippet-detail-card'
+import SnippetEmptyState from './snippet-empty-state'
+import SnippetListItem from './snippet-list-item'
+
+type SnippetsProps = {
+ loading?: boolean
+ searchText: string
+}
+
+const LoadingSkeleton = () => {
+ return (
+
+
+ {['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
+
+ ))}
+
+
+
+ )
+}
+
+const Snippets = ({
+ loading = false,
+ searchText,
+}: SnippetsProps) => {
+ const { t } = useTranslation()
+ const { push } = useRouter()
+ const createSnippetMutation = useCreateSnippetMutation()
+ const deferredSearchText = useDeferredValue(searchText)
+ const viewportRef = useRef(null)
+ const [hoveredSnippetId, setHoveredSnippetId] = useState(null)
+ const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
+ const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
+
+ const keyword = deferredSearchText.trim() || undefined
+
+ const {
+ data,
+ isLoading,
+ isFetching,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ } = useInfiniteSnippetList({
+ page: 1,
+ limit: 30,
+ keyword,
+ is_published: true,
+ })
+
+ const snippets = useMemo(() => {
+ return (data?.pages ?? []).flatMap(({ data }) => data)
+ }, [data?.pages])
+
+ const isNoMore = hasNextPage === false
+
+ useInfiniteScroll(
+ async () => {
+ if (!hasNextPage || isFetchingNextPage)
+ return { list: [] }
+
+ await fetchNextPage()
+ return { list: [] }
+ },
+ {
+ target: viewportRef,
+ isNoMore: () => isNoMore,
+ reloadDeps: [isNoMore, isFetchingNextPage, keyword],
+ },
+ )
+
+ const handleCloseCreateSnippetDialog = () => {
+ setIsCreateSnippetDialogOpen(false)
+ }
+
+ const handleCreateSnippet = async ({
+ name,
+ description,
+ icon,
+ graph,
+ }: CreateSnippetDialogPayload) => {
+ setIsCreatingSnippet(true)
+
+ try {
+ const snippet = await createSnippetMutation.mutateAsync({
+ body: {
+ name,
+ description: description || undefined,
+ icon_info: {
+ icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
+ icon_type: icon.type,
+ icon_background: icon.type === 'emoji' ? icon.background : undefined,
+ icon_url: icon.type === 'image' ? icon.url : undefined,
+ },
+ },
+ })
+
+ await consoleClient.snippets.syncDraftWorkflow({
+ params: { snippetId: snippet.id },
+ body: { graph },
+ })
+
+ toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
+ handleCloseCreateSnippetDialog()
+ push(`/snippets/${snippet.id}/orchestrate`)
+ }
+ catch (error) {
+ toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
+ }
+ finally {
+ setIsCreatingSnippet(false)
+ }
+ }
+
+ if (loading || isLoading || (isFetching && snippets.length === 0))
+ return
+
+ return (
+ <>
+ {!snippets.length
+ ? (
+ setIsCreateSnippetDialogOpen(true)} />
+ )
+ : (
+
+
+
+ {snippets.map((item) => {
+ const row = (
+ setHoveredSnippetId(item.id)}
+ onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
+ />
+ )
+
+ if (!item.description)
+ return {row}
+
+ return (
+
+
+
+
+
+
+ )
+ })}
+ {isFetchingNextPage && (
+
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ >
+ )
+}
+
+export default memo(Snippets)
diff --git a/web/app/components/workflow/block-selector/snippet-detail-card.tsx b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx
similarity index 63%
rename from web/app/components/workflow/block-selector/snippet-detail-card.tsx
rename to web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx
index 65f6ba3d9e..44a44ee4ad 100644
--- a/web/app/components/workflow/block-selector/snippet-detail-card.tsx
+++ b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx
@@ -1,21 +1,17 @@
import type { FC } from 'react'
-import type { BlockEnum } from '../types'
-import type { Snippet as SnippetDetail } from '@/types/snippet'
+import type { SnippetListItem } from '@/types/snippet'
import AppIcon from '@/app/components/base/app-icon'
-import BlockIcon from '../block-icon'
-export type PublishedSnippetDetail = SnippetDetail & {
- relatedBlocks?: BlockEnum[]
-}
+export type PublishedSnippetListItem = SnippetListItem
type SnippetDetailCardProps = {
- snippet: PublishedSnippetDetail
+ snippet: PublishedSnippetListItem
}
const SnippetDetailCard: FC = ({
snippet,
}) => {
- const { author, description, icon_info, name, relatedBlocks = [] } = snippet
+ const { author, description, icon_info, name } = snippet
return (
@@ -35,17 +31,6 @@ const SnippetDetailCard: FC = ({
{description}
)}
- {!!relatedBlocks.length && (
-
- {relatedBlocks.map(block => (
-
- ))}
-
- )}
{!!author && (
diff --git a/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx
new file mode 100644
index 0000000000..f0c00115c8
--- /dev/null
+++ b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx
@@ -0,0 +1,31 @@
+import type { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import Button from '@/app/components/base/button'
+
+type SnippetEmptyStateProps = {
+ onCreate: () => void
+}
+
+const SnippetEmptyState: FC
= ({
+ onCreate,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t('tabs.noSnippetsFound', { ns: 'workflow' })}
+
+
+
+ )
+}
+
+export default SnippetEmptyState
diff --git a/web/app/components/workflow/block-selector/snippet-list-item.tsx b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx
similarity index 91%
rename from web/app/components/workflow/block-selector/snippet-list-item.tsx
rename to web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx
index ba76a84ae4..0ddddbfc0b 100644
--- a/web/app/components/workflow/block-selector/snippet-list-item.tsx
+++ b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx
@@ -2,14 +2,14 @@ import type {
ComponentPropsWithoutRef,
Ref,
} from 'react'
-import type { PublishedSnippetDetail } from './snippet-detail-card'
+import type { PublishedSnippetListItem } from './snippet-detail-card'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
type SnippetListItemProps = {
isHovered: boolean
ref?: Ref
- snippet: PublishedSnippetDetail
+ snippet: PublishedSnippetListItem
} & ComponentPropsWithoutRef<'div'>
const SnippetListItem = ({