mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 17:11:50 +08:00
fix(web): align agent roster card interactions (#41436)
This commit is contained in:
@@ -2,6 +2,62 @@ import { screen } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import RosterPage from '../page'
|
||||
|
||||
const infiniteOptions = vi.hoisted(() => vi.fn((options) => options))
|
||||
const useInfiniteQueryOptions = vi.hoisted(() => vi.fn())
|
||||
const queryValues = vi.hoisted(() => ({
|
||||
created_by_me: false,
|
||||
filter: 'all',
|
||||
keyword: '',
|
||||
sort_by: 'last_modified',
|
||||
}))
|
||||
const rosterQueryState = vi.hoisted(() => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
has_more: false,
|
||||
page: 1,
|
||||
publication_counts: { drafts: 2, published: 1 },
|
||||
},
|
||||
],
|
||||
} as
|
||||
| {
|
||||
pages: Array<{
|
||||
data: never[]
|
||||
has_more: boolean
|
||||
page: number
|
||||
publication_counts: { drafts: number; published: number }
|
||||
}>
|
||||
}
|
||||
| undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
const agentQuery = actual.consoleQuery.agent
|
||||
const agentQueryWithInputCapture = new Proxy(agentQuery, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== 'get') return Reflect.get(target, property, receiver)
|
||||
|
||||
return {
|
||||
...agentQuery.get,
|
||||
infiniteOptions,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: new Proxy(actual.consoleQuery, {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'agent') return agentQueryWithInputCapture
|
||||
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => path,
|
||||
}))
|
||||
@@ -10,12 +66,7 @@ vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: (name: string) => {
|
||||
if (name === 'keyword') return ['', vi.fn()]
|
||||
if (name === 'filter') return ['all', vi.fn()]
|
||||
if (name === 'created_by_me') return [false, vi.fn()]
|
||||
return ['updated_at', vi.fn()]
|
||||
},
|
||||
useQueryState: (name: keyof typeof queryValues) => [queryValues[name], vi.fn()],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,15 +74,22 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteQuery: () => ({
|
||||
data: { pages: [{ data: [], has_more: false, page: 1 }] },
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
}),
|
||||
useInfiniteQuery: (options: unknown) => {
|
||||
useInfiniteQueryOptions(options)
|
||||
return {
|
||||
data: rosterQueryState.data,
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchNextPageError: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoadingError: false,
|
||||
isPending: false,
|
||||
isRefetchError: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,18 +98,39 @@ vi.mock('../components/agent-roster-list', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../components/roster-toolbar', () => ({
|
||||
RosterToolbar: () => <div>Roster toolbar</div>,
|
||||
RosterToolbar: ({
|
||||
publicationCounts,
|
||||
}: {
|
||||
publicationCounts: { drafts: number; published: number }
|
||||
}) => (
|
||||
<div>{`Roster toolbar: ${publicationCounts.published} published, ${publicationCounts.drafts} drafts`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('RosterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryValues.created_by_me = false
|
||||
queryValues.filter = 'all'
|
||||
queryValues.keyword = ''
|
||||
queryValues.sort_by = 'last_modified'
|
||||
rosterQueryState.data = {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
has_more: false,
|
||||
page: 1,
|
||||
publication_counts: { drafts: 2, published: 1 },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the localized roster title for the page heading', () => {
|
||||
render(<RosterPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'agentV2.roster.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reconciles the route title with client branding', () => {
|
||||
@@ -66,4 +145,44 @@ describe('RosterPage', () => {
|
||||
|
||||
expect(document.title).toBe('agentV2.roster.title - Acme')
|
||||
})
|
||||
|
||||
it('uses the generated publication filter and server-owned counts', () => {
|
||||
queryValues.filter = 'drafts'
|
||||
|
||||
render(<RosterPage />)
|
||||
|
||||
const options = infiniteOptions.mock.lastCall?.[0]
|
||||
expect(options).toBeDefined()
|
||||
if (!options || typeof options.input !== 'function')
|
||||
throw new Error('Expected paginated query input')
|
||||
|
||||
expect(options.input(1)).toEqual({
|
||||
query: {
|
||||
limit: 30,
|
||||
page: 1,
|
||||
publication_status: 'drafts',
|
||||
sort_by: 'last_modified',
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Roster toolbar: 1 published, 2 drafts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('configures the roster query to keep previous filter data', () => {
|
||||
render(<RosterPage />)
|
||||
|
||||
const options = useInfiniteQueryOptions.mock.lastCall?.[0] as {
|
||||
placeholderData?: (previousData: object) => object | undefined
|
||||
}
|
||||
const previousData = { pages: [{ data: ['previous agent'] }] }
|
||||
|
||||
expect(options.placeholderData?.(previousData)).toBe(previousData)
|
||||
})
|
||||
|
||||
it('renders stable zero counts before the first server response', () => {
|
||||
rosterQueryState.data = undefined
|
||||
|
||||
render(<RosterPage />)
|
||||
|
||||
expect(screen.getByText('Roster toolbar: 0 published, 0 drafts')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AgentRosterListState } from '../agent-roster-list'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -74,26 +74,12 @@ const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderList = (
|
||||
agents: AgentAppPartial[],
|
||||
overrides: Partial<ComponentProps<typeof AgentRosterList>> = {},
|
||||
) => {
|
||||
const renderState = (state: AgentRosterListState) => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const result = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentRosterList
|
||||
agents={agents}
|
||||
hasMore={false}
|
||||
isEmptySearch={false}
|
||||
isError={false}
|
||||
isFetching={false}
|
||||
isFetchingNextPage={false}
|
||||
isPending={false}
|
||||
label="Agent roster list"
|
||||
onLoadMore={vi.fn()}
|
||||
{...overrides}
|
||||
/>
|
||||
<AgentRosterList label="Agent roster list" state={state} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
@@ -103,6 +89,18 @@ const renderList = (
|
||||
}
|
||||
}
|
||||
|
||||
type ReadyState = Extract<AgentRosterListState, { status: 'ready' }>
|
||||
|
||||
const renderList = (agents: AgentAppPartial[], overrides: Partial<ReadyState> = {}) =>
|
||||
renderState({
|
||||
status: 'ready',
|
||||
agents,
|
||||
emptyState: 'roster',
|
||||
footer: { status: 'none' },
|
||||
isFetching: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AgentRosterList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -132,7 +130,11 @@ describe('AgentRosterList', () => {
|
||||
it('exposes each agent card with the agent name', () => {
|
||||
renderList([createAgent()])
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
const card = screen.getByRole('article', { name: 'Research Agent' })
|
||||
const cardLink = within(card).getByRole('link', { name: 'Research Agent' })
|
||||
|
||||
expect(cardLink).toHaveAttribute('href', '/agents/agent-1/configure')
|
||||
expect(cardLink).toHaveAccessibleDescription('Find and summarize market materials.')
|
||||
})
|
||||
|
||||
it('uses the Figma-aligned card title and role typography', () => {
|
||||
@@ -186,36 +188,57 @@ describe('AgentRosterList', () => {
|
||||
'size-6',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
expect(placeholderGrid).toHaveClass(
|
||||
'grid',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
'grid-rows-4',
|
||||
)
|
||||
expect(placeholderGrid).not.toHaveClass(
|
||||
'grid-cols-1',
|
||||
'sm:grid-cols-2',
|
||||
'lg:grid-cols-3',
|
||||
'xl:grid-cols-4',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the same overlay treatment for empty search results', () => {
|
||||
const { container } = renderList([], { isEmptySearch: true })
|
||||
const { container } = renderList([], { emptyState: 'filtered' })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.emptySearch' })).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16)
|
||||
expect(screen.queryByText('agentV2.roster.emptySearchDescription')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the same overlay treatment for loading errors', () => {
|
||||
const { container } = renderList([], { isError: true })
|
||||
it('uses the same overlay treatment for loading errors and exposes a retry action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
const { container } = renderState({ status: 'error', onRetry })
|
||||
|
||||
expect(screen.getByRole('alert', { name: 'agentV2.roster.loadingError' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.loadingError' })).toHaveClass(
|
||||
'system-sm-regular',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16)
|
||||
expect(container.querySelector('.bg-linear-to-b')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves loaded cards and exposes a retry action when the next page fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onLoadMore = vi.fn()
|
||||
renderList([createAgent()], {
|
||||
footer: { status: 'error', onRetry: onLoadMore },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
expect(onLoadMore).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves loaded cards and refetches when a background refresh fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
renderList([createAgent()], { footer: { status: 'error', onRetry } })
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens published workflow references from the card reference trigger', async () => {
|
||||
@@ -235,7 +258,9 @@ describe('AgentRosterList', () => {
|
||||
}),
|
||||
])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.references\.trigger/ }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.roster\.references\.trigger.*1/ }),
|
||||
)
|
||||
|
||||
const workflowLink = screen.getByRole('menuitem', { name: /RFP Review Flow/ })
|
||||
expect(workflowLink).toHaveAttribute('href', '/app/workflow-app-id/workflow')
|
||||
@@ -244,6 +269,53 @@ describe('AgentRosterList', () => {
|
||||
expect(screen.getByText(/agentV2\.roster\.references\.label/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('announces zero workflow references without exposing an inactive button', () => {
|
||||
renderList([createAgent()])
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Research Agent' })
|
||||
expect(within(card).getByText(/^agentV2\.roster\.references\.trigger/)).toHaveClass('sr-only')
|
||||
expect(
|
||||
within(card).queryByRole('button', { name: /agentV2\.roster\.references\.trigger/ }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps card navigation and independent controls in visual reading order', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderList([
|
||||
createAgent({
|
||||
published_reference_count: 1,
|
||||
published_references: [
|
||||
{
|
||||
app_id: 'workflow-app-id',
|
||||
app_icon: '🐍',
|
||||
app_icon_background: '#E9F8D8',
|
||||
app_icon_type: 'emoji',
|
||||
app_name: 'RFP Review Flow',
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Research Agent' })
|
||||
const cardLink = within(card).getByRole('link', { name: 'Research Agent' })
|
||||
const references = within(card).getByRole('button', {
|
||||
name: /agentV2\.roster\.references\.trigger.*1/,
|
||||
})
|
||||
const moreActions = within(card).getByRole('button', {
|
||||
name: /agentV2\.roster\.moreActions/,
|
||||
})
|
||||
|
||||
expect(cardLink).not.toContainElement(references)
|
||||
expect(cardLink).not.toContainElement(moreActions)
|
||||
|
||||
await user.tab()
|
||||
expect(cardLink).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(moreActions).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(references).toHaveFocus()
|
||||
})
|
||||
|
||||
it('opens a duplicate dialog from the card action menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderList([createAgent()])
|
||||
|
||||
@@ -20,15 +20,17 @@ vi.mock('@/next/navigation', () => ({
|
||||
}))
|
||||
|
||||
const renderToolbar = ({
|
||||
publicationCounts = { drafts: 2, published: 1 },
|
||||
searchParams = '',
|
||||
}: {
|
||||
publicationCounts?: { drafts: number; published: number }
|
||||
searchParams?: string
|
||||
} = {}) => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const result = renderWithNuqs(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RosterToolbar draftAgents={2} publishedAgents={1} />
|
||||
<RosterToolbar publicationCounts={publicationCounts} />
|
||||
</QueryClientProvider>,
|
||||
{ searchParams },
|
||||
)
|
||||
@@ -93,6 +95,24 @@ describe('RosterToolbar', () => {
|
||||
expect(within(draftsFilter).getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero counts before server data is available', () => {
|
||||
renderToolbar({ publicationCounts: { drafts: 0, published: 0 } })
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', { name: /agentV2\.roster\.filters\.published/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('radio', { name: /agentV2\.roster\.filters\.published/ })).getByText(
|
||||
'0',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('radio', { name: /agentV2\.roster\.filters\.drafts/ })).getByText(
|
||||
'0',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders created-by-me filtering and emits checked state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderToolbar()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppPartial, AgentIconType } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { zAgentIconType } from '@dify/contracts/api/console/agent/zod.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
@@ -10,12 +11,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useExportAppDsl } from '@/app/components/app/use-export-app-dsl'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { MAIN_NAV_APP_CARD_GRID_CLASS_NAME } from '@/app/components/main-nav/app-card-grid'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import Link from '@/next/link'
|
||||
import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown'
|
||||
@@ -23,30 +26,48 @@ import { DeleteAgentDialog } from './delete-agent-dialog'
|
||||
import { DuplicateAgentDialog } from './duplicate-agent-dialog'
|
||||
import { EditAgentDialog } from './edit-agent-dialog'
|
||||
|
||||
type AgentRosterListFooterState =
|
||||
| { status: 'none' }
|
||||
| { status: 'load-more'; isLoading: boolean; onLoadMore: () => void }
|
||||
| { status: 'error'; onRetry: () => void }
|
||||
|
||||
export type AgentRosterListState =
|
||||
| { status: 'pending' }
|
||||
| { status: 'error'; onRetry: () => void }
|
||||
| {
|
||||
status: 'ready'
|
||||
agents: AgentAppPartial[]
|
||||
emptyState: 'roster' | 'filtered'
|
||||
footer: AgentRosterListFooterState
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
type AgentRosterListProps = {
|
||||
agents: AgentAppPartial[]
|
||||
hasMore: boolean
|
||||
isEmptySearch: boolean
|
||||
isError: boolean
|
||||
isFetching: boolean
|
||||
isFetchingNextPage: boolean
|
||||
isPending: boolean
|
||||
label: string
|
||||
onLoadMore: () => void
|
||||
state: AgentRosterListState
|
||||
}
|
||||
|
||||
const skeletonRows = ['primary', 'secondary', 'tertiary'] as const
|
||||
const skeletonCardIds = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `agent-roster-skeleton-card-${index}`,
|
||||
)
|
||||
const AGENT_ROSTER_GRID_CLASS_NAME = cn('gap-2.5', MAIN_NAV_APP_CARD_GRID_CLASS_NAME)
|
||||
const emptyPlaceholderCardIds = Array.from(
|
||||
{ length: 16 },
|
||||
(_, index) => `agent-roster-placeholder-card-${index}`,
|
||||
)
|
||||
|
||||
function AgentRosterSkeleton() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonRows.map((row) => (
|
||||
<span role="status" className="sr-only col-span-full">
|
||||
{t(($) => $.loading)}
|
||||
</span>
|
||||
{skeletonCardIds.map((id) => (
|
||||
<div
|
||||
key={row}
|
||||
key={id}
|
||||
className="relative h-36.5 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
||||
@@ -72,13 +93,29 @@ function AgentRosterSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRosterPlaceholderState({ title }: { title: string }) {
|
||||
function AgentRosterPlaceholderState({
|
||||
onRetry,
|
||||
role,
|
||||
title,
|
||||
}: {
|
||||
onRetry?: () => void
|
||||
role?: 'alert' | 'status'
|
||||
title: string
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-roster-placeholder-title"
|
||||
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
|
||||
role={role}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 grid-rows-4',
|
||||
AGENT_ROSTER_GRID_CLASS_NAME,
|
||||
)}
|
||||
>
|
||||
{emptyPlaceholderCardIds.map((id) => (
|
||||
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
|
||||
))}
|
||||
@@ -97,6 +134,11 @@ function AgentRosterPlaceholderState({ title }: { title: string }) {
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{onRetry && (
|
||||
<Button size="small" variant="secondary" onClick={onRetry}>
|
||||
{t(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -127,9 +169,9 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
const publishedReferences = agent.published_references ?? []
|
||||
const hasPublishedReferences = publishedReferences.length > 0
|
||||
const isDraft = agent.active_config_is_published !== true
|
||||
const imageUrl =
|
||||
agent.icon_type === 'image' || agent.icon_type === 'link' ? agent.icon : undefined
|
||||
const iconType = (imageUrl ? 'image' : agent.icon_type) as AgentIconType | null | undefined
|
||||
const parsedIconType = zAgentIconType.safeParse(agent.icon_type).data
|
||||
const imageUrl = parsedIconType === 'image' || parsedIconType === 'link' ? agent.icon : undefined
|
||||
const iconType = parsedIconType === 'link' ? 'image' : parsedIconType
|
||||
|
||||
const handleEditOpen = () => {
|
||||
setEditSessionKey((key) => key + 1)
|
||||
@@ -156,118 +198,122 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={nameId}
|
||||
className="group relative col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:content-[''] hover:shadow-lg has-[>div>a:focus-visible]:after:inset-ring-2 has-[>div>a:focus-visible]:after:inset-ring-state-accent-solid"
|
||||
className="group relative isolate col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none [@media(hover:none)]:bg-components-card-bg-alt"
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<Link
|
||||
href={`/agents/${agent.id}/configure`}
|
||||
aria-labelledby={nameId}
|
||||
aria-describedby={agent.description ? descriptionId : undefined}
|
||||
className="block shrink-0 cursor-pointer touch-manipulation outline-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
||||
<span aria-hidden className="shrink-0">
|
||||
<AppIcon
|
||||
size="xl"
|
||||
rounded
|
||||
iconType={iconType}
|
||||
icon={agent.icon ?? undefined}
|
||||
background={agent.icon_background}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
|
||||
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
|
||||
{agent.name}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
|
||||
<div id={descriptionId} className="line-clamp-2 min-h-8">
|
||||
{agent.description}
|
||||
</div>
|
||||
</div>
|
||||
{isDraft && (
|
||||
<div className="absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
|
||||
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
|
||||
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['roster.usageStatus.draft'])}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-3 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{hasPublishedReferences ? (
|
||||
<AgentWorkflowReferencesDropdown
|
||||
agentName={agent.name}
|
||||
publishedReferences={publishedReferences}
|
||||
referenceCount={referenceCount}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4 shrink-0 items-center gap-1">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span className="system-xs-regular text-text-tertiary">{referenceCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{updatedAt && (
|
||||
<>
|
||||
<span aria-hidden className="shrink-0 text-text-quaternary">
|
||||
·
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{updatedAt}</span>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={`/agents/${agent.id}/configure`}
|
||||
aria-labelledby={nameId}
|
||||
aria-describedby={agent.description ? descriptionId : undefined}
|
||||
className="flex h-full min-w-0 cursor-pointer touch-manipulation flex-col rounded-xl outline-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
||||
<span aria-hidden className="shrink-0">
|
||||
<AppIcon
|
||||
size="xl"
|
||||
rounded
|
||||
iconType={iconType}
|
||||
icon={agent.icon ?? undefined}
|
||||
background={agent.icon_background}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
|
||||
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
|
||||
{agent.name}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100',
|
||||
isDraft ? 'top-7' : 'top-2',
|
||||
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
|
||||
<div id={descriptionId} className="line-clamp-2 min-h-8">
|
||||
{agent.description}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden className="h-9 shrink-0" />
|
||||
{isDraft && (
|
||||
<div className="pointer-events-none absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
|
||||
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
|
||||
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['roster.usageStatus.draft'])}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['roster.moreActions'], { name: agent.name })}
|
||||
className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t(($) => $['roster.moreActions'], { name: agent.name })}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-more-fill size-4.5 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-40">
|
||||
<DropdownMenuItem className="gap-2" onClick={handleEditOpen}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t(($) => $['roster.editInfo'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}>
|
||||
</Link>
|
||||
<div className="pointer-events-none absolute top-[-0.5px] right-[-0.5px] flex h-16 w-30 items-start justify-end bg-[linear-gradient(67deg,var(--color-components-card-bg-alt-transparent)_0%,var(--color-components-card-bg-alt)_75%)] p-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 has-data-popup-open:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
<div className="pointer-events-none flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs group-focus-within:pointer-events-auto group-hover:pointer-events-auto has-data-popup-open:pointer-events-auto [@media(hover:none)]:pointer-events-auto">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['roster.moreActions'], { name: agent.name })}
|
||||
size="lg"
|
||||
className="data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4.5" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-40">
|
||||
<DropdownMenuItem className="gap-2" onClick={handleEditOpen}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t(($) => $['roster.editInfo'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tCommon(($) => $['operation.duplicate'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" disabled={isExporting} onClick={handleExport}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-download-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tApp(($) => $.export)}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{tCommon(($) => $['operation.delete'])}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex min-w-0 items-center pt-2 pr-3 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{hasPublishedReferences ? (
|
||||
<AgentWorkflowReferencesDropdown
|
||||
agentName={agent.name}
|
||||
publishedReferences={publishedReferences}
|
||||
referenceCount={referenceCount}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4 shrink-0 items-center gap-1">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary"
|
||||
className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tCommon(($) => $['operation.duplicate'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" disabled={isExporting} onClick={handleExport}>
|
||||
<span aria-hidden className="i-ri-download-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{tApp(($) => $.export)}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{tCommon(($) => $['operation.delete'])}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="sr-only">
|
||||
{t(($) => $['roster.references.trigger'], { name: agent.name })}:{' '}
|
||||
</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{referenceCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{updatedAt && (
|
||||
<>
|
||||
<span aria-hidden className="shrink-0 text-text-quaternary">
|
||||
·
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{updatedAt}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EditAgentDialog
|
||||
agent={agent}
|
||||
@@ -291,40 +337,47 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentRosterList({
|
||||
agents,
|
||||
hasMore,
|
||||
isEmptySearch,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPending,
|
||||
label,
|
||||
onLoadMore,
|
||||
}: AgentRosterListProps) {
|
||||
export function AgentRosterList({ label, state }: AgentRosterListProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const isBusy = state.status === 'pending' || (state.status === 'ready' && state.isFetching)
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5"
|
||||
aria-busy={isFetching || undefined}
|
||||
>
|
||||
{isPending && <AgentRosterSkeleton />}
|
||||
{!isPending && isError && (
|
||||
<AgentRosterPlaceholderState title={t(($) => $['roster.loadingError'])} />
|
||||
)}
|
||||
{!isPending && !isError && agents.length === 0 && (
|
||||
<section aria-label={label} className={AGENT_ROSTER_GRID_CLASS_NAME} aria-busy={isBusy}>
|
||||
{state.status === 'pending' && <AgentRosterSkeleton />}
|
||||
{state.status === 'error' && (
|
||||
<AgentRosterPlaceholderState
|
||||
title={isEmptySearch ? t(($) => $['roster.emptySearch']) : t(($) => $['roster.empty'])}
|
||||
onRetry={state.onRetry}
|
||||
role="alert"
|
||||
title={t(($) => $['roster.loadingError'])}
|
||||
/>
|
||||
)}
|
||||
{!isPending &&
|
||||
!isError &&
|
||||
agents.map((agent) => <AgentRosterItem key={agent.id} agent={agent} />)}
|
||||
{!isPending && !isError && hasMore && (
|
||||
{state.status === 'ready' && state.agents.length === 0 && (
|
||||
<AgentRosterPlaceholderState
|
||||
role={state.emptyState === 'filtered' ? 'status' : undefined}
|
||||
title={
|
||||
state.emptyState === 'filtered'
|
||||
? t(($) => $['roster.emptySearch'])
|
||||
: t(($) => $['roster.empty'])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{state.status === 'ready' &&
|
||||
state.agents.map((agent) => <AgentRosterItem key={agent.id} agent={agent} />)}
|
||||
{state.status === 'ready' && state.footer.status === 'error' && (
|
||||
<div
|
||||
className="col-span-full flex items-center justify-center gap-3 pt-1 system-xs-regular text-text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<span>{t(($) => $['roster.loadingError'])}</span>
|
||||
<Button size="small" variant="secondary" onClick={state.footer.onRetry}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ready' && state.footer.status === 'load-more' && (
|
||||
<div className="col-span-full flex justify-center pt-1">
|
||||
<Button loading={isFetchingNextPage} disabled={isFetchingNextPage} onClick={onLoadMore}>
|
||||
<Button loading={state.footer.isLoading} onClick={state.footer.onLoadMore}>
|
||||
{t(($) => $['roster.loadMore'])}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AgentAppPublishedReferenceResponse,
|
||||
AgentIconType,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentAppPublishedReferenceResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { zAgentIconType } from '@dify/contracts/api/console/agent/zod.gen'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
@@ -17,21 +17,14 @@ import Link from '@/next/link'
|
||||
const getWorkflowReferenceHref = (reference: AgentAppPublishedReferenceResponse) =>
|
||||
`/app/${reference.app_id}/workflow`
|
||||
|
||||
const getWorkflowReferenceIconType = (
|
||||
reference: AgentAppPublishedReferenceResponse,
|
||||
): AgentIconType | undefined => {
|
||||
if (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') return 'image'
|
||||
const getWorkflowReferenceIcon = (reference: AgentAppPublishedReferenceResponse) => {
|
||||
const parsedIconType = zAgentIconType.safeParse(reference.app_icon_type).data
|
||||
|
||||
if (reference.app_icon_type === 'emoji') return 'emoji'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getWorkflowReferenceImageUrl = (reference: AgentAppPublishedReferenceResponse) => {
|
||||
if (reference.app_icon_type === 'image' || reference.app_icon_type === 'link')
|
||||
return reference.app_icon
|
||||
|
||||
return undefined
|
||||
return {
|
||||
iconType: parsedIconType === 'link' ? 'image' : parsedIconType,
|
||||
imageUrl:
|
||||
parsedIconType === 'image' || parsedIconType === 'link' ? reference.app_icon : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentWorkflowReferencesDropdown({
|
||||
@@ -47,51 +40,54 @@ export function AgentWorkflowReferencesDropdown({
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['roster.references.trigger'], {
|
||||
name: agentName,
|
||||
count: referenceCount,
|
||||
})}
|
||||
className="relative flex h-4 shrink-0 cursor-pointer items-center gap-1 rounded-md outline-hidden before:pointer-events-none before:absolute before:-inset-x-1 before:-inset-y-0.5 before:rounded-md before:content-[''] hover:before:bg-state-base-hover focus-visible:before:ring-2 focus-visible:before:ring-state-accent-solid data-popup-open:before:bg-state-base-hover"
|
||||
>
|
||||
<DropdownMenuTrigger className="pointer-events-auto relative -m-1 flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md p-1 outline-hidden before:pointer-events-none before:absolute before:inset-0 before:rounded-md before:content-[''] hover:before:bg-state-base-hover focus-visible:before:ring-2 focus-visible:before:ring-state-accent-solid data-popup-open:before:bg-state-base-hover">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t(($) => $['roster.references.trigger'], { name: agentName })}:{' '}
|
||||
</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{referenceCount}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" sideOffset={4} className="w-[264px] p-1">
|
||||
<div className="flex h-7.5 items-center px-2 system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['roster.references.label'], { name: agentName })}
|
||||
</div>
|
||||
{publishedReferences.map((reference) => (
|
||||
<DropdownMenuLinkItem
|
||||
key={reference.app_id}
|
||||
render={
|
||||
<Link
|
||||
href={getWorkflowReferenceHref(reference)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
}
|
||||
className="mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="shrink-0">
|
||||
<AppIcon
|
||||
size="tiny"
|
||||
iconType={getWorkflowReferenceIconType(reference)}
|
||||
icon={reference.app_icon ?? undefined}
|
||||
background={reference.app_icon_background}
|
||||
imageUrl={getWorkflowReferenceImageUrl(reference)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{reference.app_name}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-external-link-line size-3 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
</DropdownMenuLinkItem>
|
||||
))}
|
||||
<DropdownMenuContent placement="bottom-start" sideOffset={4} className="w-66 p-1">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex w-full min-w-0 truncate pt-2 pr-3 pb-1.5 pl-2 system-xs-medium text-text-tertiary normal-case">
|
||||
{t(($) => $['roster.references.label'], { name: agentName })}
|
||||
</DropdownMenuLabel>
|
||||
{publishedReferences.map((reference) => {
|
||||
const { iconType, imageUrl } = getWorkflowReferenceIcon(reference)
|
||||
|
||||
return (
|
||||
<DropdownMenuLinkItem
|
||||
key={reference.app_id}
|
||||
render={
|
||||
<Link
|
||||
href={getWorkflowReferenceHref(reference)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
}
|
||||
className="group mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="shrink-0">
|
||||
<AppIcon
|
||||
size="tiny"
|
||||
iconType={iconType}
|
||||
icon={reference.app_icon ?? undefined}
|
||||
background={reference.app_icon_background}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{reference.app_name}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-external-link-line size-3 shrink-0 text-text-quaternary group-data-highlighted:text-text-secondary"
|
||||
/>
|
||||
</DropdownMenuLinkItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentPublicationCountsResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { RosterFilterValue } from './roster-filter'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
@@ -16,8 +17,7 @@ import { RosterCreateMenu } from './roster-create-menu'
|
||||
import { RosterSortSelect } from './roster-sort-select'
|
||||
|
||||
type RosterToolbarProps = {
|
||||
draftAgents: number
|
||||
publishedAgents: number
|
||||
publicationCounts: AgentPublicationCountsResponse
|
||||
}
|
||||
|
||||
type RosterFilterItemProps = {
|
||||
@@ -39,7 +39,7 @@ function RosterFilterItem({ count, label, value }: RosterFilterItemProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function RosterStatusFilter({ draftAgents, publishedAgents }: RosterToolbarProps) {
|
||||
function RosterStatusFilter({ publicationCounts }: RosterToolbarProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [filter, setFilter] = useQueryState(rosterQueryParamNames.filter, rosterFilterQueryParser)
|
||||
|
||||
@@ -54,12 +54,12 @@ function RosterStatusFilter({ draftAgents, publishedAgents }: RosterToolbarProps
|
||||
<RosterFilterItem
|
||||
value="published"
|
||||
label={t(($) => $['roster.filters.published'])}
|
||||
count={publishedAgents}
|
||||
count={publicationCounts.published}
|
||||
/>
|
||||
<RosterFilterItem
|
||||
value="drafts"
|
||||
label={t(($) => $['roster.filters.drafts'])}
|
||||
count={draftAgents}
|
||||
count={publicationCounts.drafts}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
)
|
||||
@@ -107,10 +107,10 @@ function RosterCreatedByMeFilter() {
|
||||
)
|
||||
}
|
||||
|
||||
export function RosterToolbar({ draftAgents, publishedAgents }: RosterToolbarProps) {
|
||||
export function RosterToolbar({ publicationCounts }: RosterToolbarProps) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RosterStatusFilter draftAgents={draftAgents} publishedAgents={publishedAgents} />
|
||||
<RosterStatusFilter publicationCounts={publicationCounts} />
|
||||
<RosterSearchFilter />
|
||||
<div className="flex h-4 shrink-0 px-1" aria-hidden="true">
|
||||
<div className="h-full w-px bg-divider-regular" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { RosterFilterValue } from './components/roster-filter'
|
||||
import type { AgentPublicationCountsResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentRosterListState } from './components/agent-roster-list'
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
@@ -27,14 +28,9 @@ import {
|
||||
} from './query-params'
|
||||
|
||||
const ROSTER_PAGE_SIZE = 30
|
||||
const isAgentPublished = (agent: AgentAppPartial) => agent.active_config_is_published === true
|
||||
|
||||
const getFilteredRosterItems = (agents: AgentAppPartial[], filter: RosterFilterValue) => {
|
||||
if (filter === 'published') return agents.filter(isAgentPublished)
|
||||
|
||||
if (filter === 'drafts') return agents.filter((agent) => !isAgentPublished(agent))
|
||||
|
||||
return agents
|
||||
const EMPTY_PUBLICATION_COUNTS: AgentPublicationCountsResponse = {
|
||||
drafts: 0,
|
||||
published: 0,
|
||||
}
|
||||
|
||||
export default function RosterPage() {
|
||||
@@ -48,12 +44,12 @@ export default function RosterPage() {
|
||||
)
|
||||
const [sortBy] = useQueryState(rosterQueryParamNames.sortBy, rosterSortByQueryParser)
|
||||
const debouncedKeyword = useDebounce(keyword.trim(), { wait: 300 })
|
||||
|
||||
const rosterQueryInput = {
|
||||
limit: ROSTER_PAGE_SIZE,
|
||||
sort_by: sortBy,
|
||||
...(debouncedKeyword ? { name: debouncedKeyword } : {}),
|
||||
...(createdByMe ? { is_created_by_me: true } : {}),
|
||||
...(rosterFilter !== 'all' ? { publication_status: rosterFilter } : {}),
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -61,9 +57,12 @@ export default function RosterPage() {
|
||||
isPending,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isLoadingError,
|
||||
isRefetchError,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
...consoleQuery.agent.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
@@ -74,22 +73,46 @@ export default function RosterPage() {
|
||||
}),
|
||||
getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const rosterItems: AgentAppPartial[] = rosterPages?.pages.flatMap((page) => page.data) ?? []
|
||||
const publishedAgents = rosterItems.filter(isAgentPublished).length
|
||||
const draftAgents = Math.max(rosterItems.length - publishedAgents, 0)
|
||||
const filteredRosterItems = getFilteredRosterItems(rosterItems, rosterFilter)
|
||||
const rosterItems = rosterPages?.pages.flatMap((page) => page.data) ?? []
|
||||
const publicationCounts = rosterPages?.pages[0]?.publication_counts ?? EMPTY_PUBLICATION_COUNTS
|
||||
const pageTitle = t(($) => $['roster.title'])
|
||||
const pageTitleId = useId()
|
||||
const listState: AgentRosterListState = isLoadingError
|
||||
? { status: 'error', onRetry: () => void refetch() }
|
||||
: isPending
|
||||
? { status: 'pending' }
|
||||
: {
|
||||
status: 'ready',
|
||||
agents: rosterItems,
|
||||
emptyState:
|
||||
debouncedKeyword || rosterFilter !== 'all' || createdByMe ? 'filtered' : 'roster',
|
||||
footer: isFetchNextPageError
|
||||
? { status: 'error', onRetry: () => void fetchNextPage() }
|
||||
: isRefetchError
|
||||
? { status: 'error', onRetry: () => void refetch() }
|
||||
: hasNextPage
|
||||
? {
|
||||
status: 'load-more',
|
||||
isLoading: isFetchingNextPage,
|
||||
onLoadMore: () => void fetchNextPage(),
|
||||
}
|
||||
: { status: 'none' },
|
||||
isFetching,
|
||||
}
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
return (
|
||||
<div className="flex h-0 min-w-0 grow flex-col overflow-hidden bg-background-body">
|
||||
<div className="shrink-0 bg-background-body px-8 pt-4 pb-2">
|
||||
<div className="flex h-6 min-w-0 items-center justify-between gap-4">
|
||||
<h1 className="min-w-0 flex-1 truncate text-[18px]/[21.6px] font-semibold text-text-primary">
|
||||
<h1
|
||||
id={pageTitleId}
|
||||
className="min-w-0 flex-1 truncate text-[18px]/[21.6px] font-semibold text-text-primary"
|
||||
>
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<a
|
||||
@@ -103,25 +126,23 @@ export default function RosterPage() {
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<RosterToolbar draftAgents={draftAgents} publishedAgents={publishedAgents} />
|
||||
<RosterToolbar publicationCounts={publicationCounts} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 overflow-hidden">
|
||||
<ScrollAreaViewport tabIndex={-1} className="overscroll-contain">
|
||||
<ScrollAreaContent className="min-h-full px-8 pt-2 pb-8">
|
||||
<AgentRosterList
|
||||
agents={filteredRosterItems}
|
||||
hasMore={!!hasNextPage}
|
||||
isEmptySearch={!!debouncedKeyword || rosterFilter !== 'all'}
|
||||
isError={!!error}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPending={isPending}
|
||||
label={t(($) => $['roster.listLabel'])}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
<ScrollAreaViewport
|
||||
role="region"
|
||||
aria-labelledby={pageTitleId}
|
||||
className="overscroll-contain"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<ScrollAreaContent
|
||||
className="min-h-full w-full max-w-full px-8 pt-2 pb-8"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<AgentRosterList label={t(($) => $['roster.listLabel'])} state={listState} />
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
|
||||
@@ -464,8 +464,7 @@ function SkillCard({
|
||||
{(canEdit || canDelete || !!skill.latest_published_version_id) && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100',
|
||||
isDraft ? 'top-7' : 'top-2',
|
||||
'pointer-events-none absolute top-2 right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100',
|
||||
)}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
|
||||
Reference in New Issue
Block a user