'use client' import type { FC } from 'react' import type { AppListQuery } from '@/contract/console/apps' import { cn } from '@langgenius/dify-ui/cn' import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Input from '@/app/components/base/input' import TabSliderNew from '@/app/components/base/tab-slider-new' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { TagFilter } from '@/features/tag-management/components/tag-filter' import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { consoleQuery } from '@/service/client' import { systemFeaturesQueryOptions } from '@/service/system-features' import { AppModeEnum } from '@/types/app' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants' import Empty from './empty' import Footer from './footer' import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' import NewAppCard from './new-app-card' const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), { ssr: false, }) const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { ssr: false, }) type Props = { controlRefreshList?: number } const List: FC = ({ controlRefreshList = 0, }) => { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() // eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState const { query: { category, tagIDs, keywords, isCreatedByMe }, setCategory, setKeywords, setTagIDs, setIsCreatedByMe, } = useAppsQueryState() const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS }) const newAppCardRef = useRef(null) const containerRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() const handleDSLFileDropped = useCallback((file: File) => { setDroppedDSLFile(file) setShowCreateFromDSLModal(true) }, []) const { dragging } = useDSLDragDrop({ onDSLFileDropped: handleDSLFileDropped, containerRef, enabled: isCurrentWorkspaceEditor, }) const appListQuery = useMemo(() => ({ page: 1, limit: 30, name: debouncedKeywords, ...(tagIDs.length ? { tag_ids: tagIDs } : {}), ...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}), ...(category !== 'all' ? { mode: category } : {}), }), [category, debouncedKeywords, isCreatedByMe, tagIDs]) const { data, isLoading, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage, error, refetch, } = useInfiniteQuery({ ...consoleQuery.apps.list.infiniteOptions({ input: pageParam => ({ query: { ...appListQuery, page: Number(pageParam), }, }), getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, initialPageParam: 1, placeholderData: keepPreviousData, }), enabled: !isCurrentWorkspaceDatasetOperator, refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false, }) useEffect(() => { if (controlRefreshList > 0) { refetch() } }, [controlRefreshList, refetch]) const anchorRef = useRef(null) const options = [ { value: 'all', text: t('types.all', { ns: 'app' }), icon: }, { value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: }, { value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: }, { value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: }, { value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: }, { value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: }, ] useEffect(() => { if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) refetch() } }, [refetch]) useEffect(() => { if (isCurrentWorkspaceDatasetOperator) return const hasMore = hasNextPage ?? true let observer: IntersectionObserver | undefined if (error) { if (observer) observer.disconnect() return } if (anchorRef.current && containerRef.current) { // Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness const containerHeight = containerRef.current.clientHeight const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value observer = new IntersectionObserver((entries) => { if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) fetchNextPage() }, { root: containerRef.current, rootMargin: `${dynamicMargin}px`, threshold: 0.1, // Trigger when 10% of the anchor element is visible }) observer.observe(anchorRef.current) } return () => observer?.disconnect() }, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator]) const handleCreatedByMeChange = useCallback(() => { setIsCreatedByMe(!isCreatedByMe) }, [isCreatedByMe, setIsCreatedByMe]) const pages = useMemo(() => data?.pages ?? [], [data?.pages]) const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages]) const workflowOnlineUserAppIds = useMemo(() => { const appIds = new Set() apps.forEach((app) => { if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) appIds.add(app.id) }) return Array.from(appIds) }, [apps]) const { onlineUsersMap: workflowOnlineUsersMap, } = useWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds, enabled: systemFeatures.enable_collaboration_mode, }) const hasAnyApp = (pages[0]?.total ?? 0) > 0 // Show skeleton during initial load or when refetching with no previous data const showSkeleton = isLoading || (isFetching && pages.length === 0) return ( <>
{dragging && (
)}
{ if (isAppListCategory(nextValue)) setCategory(nextValue) }} options={options} />
setShowTagManagementModal(true)} /> setKeywords(e.target.value)} onClear={() => setKeywords('')} />
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && ( )} {showSkeleton ? : hasAnyApp ? apps.map(app => ( setShowTagManagementModal(true)} /> )) : } {isFetchingNextPage && ( )}
{isCurrentWorkspaceEditor && (
{t('newApp.dropDSLToCreateApp', { ns: 'app' })}
)} {!systemFeatures.branding.enabled && (
)}
setShowTagManagementModal(false)} onTagsChange={refetch} />
{showCreateFromDSLModal && ( { setShowCreateFromDSLModal(false) setDroppedDSLFile(undefined) }} onSuccess={() => { setShowCreateFromDSLModal(false) setDroppedDSLFile(undefined) refetch() }} droppedFile={droppedDSLFile} /> )} ) } export default List