fix(web): align agent roster card interactions (#41436)

This commit is contained in:
yyh
2026-08-28 08:50:30 +00:00
committed by GitHub
parent 78c85ae7c1
commit 11bb82c731
8 changed files with 577 additions and 297 deletions
@@ -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" />
+53 -32
View File
@@ -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>
+1 -2
View File
@@ -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}>