diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx
index 1cc504d7468..a62abf9ebee 100644
--- a/web/app/components/apps/app-card.tsx
+++ b/web/app/components/apps/app-card.tsx
@@ -5,6 +5,7 @@ import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
+import type { WorkflowOnlineUser } from '@/models/app'
import type { App } from '@/types/app'
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
import * as React from 'react'
@@ -27,6 +28,7 @@ import {
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { toast } from '@/app/components/base/ui/toast'
+import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
@@ -65,10 +67,11 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
type AppCardProps = {
app: App
+ onlineUsers?: WorkflowOnlineUser[]
onRefresh?: () => void
}
-const AppCard = ({ app, onRefresh }: AppCardProps) => {
+const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
const { t } = useTranslation()
const deleteAppNameInputId = useId()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@@ -360,6 +363,20 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
}, [app.updated_at, app.created_at, t])
+ const onlinePresenceUsers = useMemo(() => {
+ return onlineUsers
+ .map((user, index) => {
+ const id = user.user_id || user.sid || `${app.id}-online-${index}`
+ const name = user.username || user.user_id || user.sid || `${index + 1}`
+ return {
+ id,
+ name,
+ avatar_url: user.avatar || null,
+ }
+ })
+ .filter(user => Boolean(user.id))
+ }, [app.id, onlineUsers])
+
return (
<>
+ {onlinePresenceUsers.length > 0 && (
+
+
+
+ )}
{app.access_mode === AccessMode.PUBLIC && (
diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx
index 2ef344f816a..f997b045667 100644
--- a/web/app/components/apps/list.tsx
+++ b/web/app/components/apps/list.tsx
@@ -1,9 +1,10 @@
'use client'
import type { FC } from 'react'
+import type { WorkflowOnlineUser } from '@/models/app'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
-import { useCallback, useEffect, useRef, useState } from 'react'
+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'
@@ -15,6 +16,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
+import { fetchWorkflowOnlineUsers } from '@/service/apps'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { cn } from '@/utils/classnames'
@@ -68,6 +70,7 @@ const List: FC
= ({
const containerRef = useRef(null)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState()
+ const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState>({})
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
@@ -183,6 +186,44 @@ const List: FC = ({
}, [isCreatedByMe, setQuery])
const pages = data?.pages ?? []
+ const workflowIds = useMemo(() => {
+ const ids = new Set()
+ pages.forEach((page) => {
+ page.data?.forEach((app) => {
+ const workflowId = app.workflow?.id
+ if (workflowId)
+ ids.add(workflowId)
+ })
+ })
+ return Array.from(ids)
+ }, [pages])
+
+ const refreshWorkflowOnlineUsers = useCallback(async () => {
+ if (!workflowIds.length)
+ return
+
+ try {
+ const onlineUsersMap = await fetchWorkflowOnlineUsers({ workflowIds })
+ setWorkflowOnlineUsersMap(onlineUsersMap)
+ }
+ catch {
+ setWorkflowOnlineUsersMap({})
+ }
+ }, [workflowIds])
+
+ useEffect(() => {
+ void refreshWorkflowOnlineUsers()
+ }, [refreshWorkflowOnlineUsers])
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ void refetch()
+ void refreshWorkflowOnlineUsers()
+ }, 10000)
+
+ return () => window.clearInterval(timer)
+ }, [refetch, refreshWorkflowOnlineUsers])
+
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)
@@ -242,7 +283,12 @@ const List: FC = ({
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
-
+
))
}