feat: inline agent show debug and time to show build draft bar

This commit is contained in:
Joel 2026-06-25 16:42:51 +08:00
parent 58a7b38a55
commit 6c28ac15fe
9 changed files with 709 additions and 164 deletions

View File

@ -182,6 +182,20 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
<div role="region" aria-label={props.isInline ? 'inline-orchestrate-panel' : 'readonly-roster-orchestrate-panel'} />
)
},
WorkflowInlineAgentConfigureWorkspace: (props: {
agentId?: string
appId?: string
inlineComposerState?: unknown
isInline: boolean
nodeId: string
open: boolean
}) => {
mockOrchestrateDrawerPanelProps.push(props)
return (
<div role="region" aria-label="inline-orchestrate-panel" />
)
},
}))
vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({
@ -462,7 +476,7 @@ describe('agent/panel', () => {
expect(screen.queryByText(/^workflow\.errorMsg\.fieldRequired/)).not.toBeInTheDocument()
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change', hidden: true })).toBeDisabled()
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.agent.roster.editInConsole')).not.toBeInTheDocument()
@ -478,6 +492,43 @@ describe('agent/panel', () => {
expect(container.querySelector('[inert]')).toBeInTheDocument()
})
it('opens a pending inline agent modal and creates the inline agent before rendering details', () => {
const { container, rerender } = render(
<AgentV2Panel
id="agent-node"
data={createData({
agent_binding: {
binding_type: 'inline_agent',
},
})}
panelProps={panelProps}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('agent-node', expect.objectContaining({
onSuccess: expect.any(Function),
}))
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
rerender(
<AgentV2Panel
id="agent-node"
data={createData({
agent_binding: {
binding_type: 'inline_agent',
},
})}
panelProps={panelProps}
/>,
)
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
})
it('renders inline agent detail from workflow composer state and opens the inline panel', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
const { container } = render(
@ -497,18 +548,15 @@ describe('agent/panel', () => {
expect(mockUseAgentRosterDetail).toHaveBeenCalledWith(undefined)
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
expect(screen.queryByText('Workflow Agent 1')).not.toBeInTheDocument()
const trigger = screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })
expect(screen.getByRole('dialog', { name: 'Workflow Agent 1' })).toBeInTheDocument()
const trigger = screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/, hidden: true })
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument()
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
const panelConfigureIcon = panel.querySelector('.i-custom-vender-agent-v2-configure')
const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' })
expect(container.querySelector('.i-custom-vender-agent-v2-configure')).toHaveClass('h-3.5', 'w-3')
expect(container.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
expect(panelConfigureIcon).toHaveClass('h-3.5', 'w-3')
expect(panelConfigureIcon?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument()
expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
agentId: 'inline-agent-1',
@ -554,14 +602,11 @@ describe('agent/panel', () => {
/>,
)
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' })
expect(panel).toBeInTheDocument()
expect(panel.querySelector('header')).not.toHaveClass('h-[108px]')
expect(panel.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
expect(within(panel).queryByText('Workflow Agent 1')).not.toBeInTheDocument()
expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
expect(within(panel).getByText('Workflow Agent 1')).toBeInTheDocument()
expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.description')).not.toBeInTheDocument()
expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument()
expect(within(panel).queryByRole('link', { name: 'workflow.nodes.agent.roster.editInConsole' })).not.toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' })).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
@ -583,10 +628,10 @@ describe('agent/panel', () => {
/>,
)
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' })
fireEvent.click(within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.more' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'agentV2.roster.saveToRoster' }))
fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster' }))
fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster', hidden: true }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined)
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(

View File

@ -2,8 +2,11 @@
import type { AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel, useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -11,6 +14,11 @@ import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provid
import { useHydrateAgentSoulConfigDraft } from '@/features/agent-v2/agent-composer/store'
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
import { AgentOrchestratePanel } from '@/features/agent-v2/agent-detail/configure/components/orchestrate'
import { AgentBuildPanelBackground } from '@/features/agent-v2/agent-detail/configure/components/preview/build-background'
import { AgentBuildChat } from '@/features/agent-v2/agent-detail/configure/components/preview/build-chat'
import { AgentPreviewHeader } from '@/features/agent-v2/agent-detail/configure/components/preview/header'
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from '@/features/agent-v2/agent-detail/configure/components/workspace'
import { useAgentPreviewSoulConfig } from '@/features/agent-v2/agent-detail/configure/hooks'
import { consoleQuery } from '@/service/client'
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
@ -20,6 +28,7 @@ type AgentOrchestrateDrawerPanelProps = {
inlineComposerState?: WorkflowAgentComposerResponse
isInline: boolean
nodeId: string
onClose?: () => void
open: boolean
}
@ -31,6 +40,14 @@ export function AgentOrchestrateDrawerPanel(props: AgentOrchestrateDrawerPanelPr
)
}
export function WorkflowInlineAgentConfigureWorkspace(props: AgentOrchestrateDrawerPanelProps) {
return (
<AgentComposerProvider>
<WorkflowInlineAgentConfigureWorkspaceContent {...props} />
</AgentComposerProvider>
)
}
function AgentOrchestrateDrawerPanelContent({
agentId,
appId,
@ -99,6 +116,183 @@ function AgentOrchestrateDrawerPanelContent({
)
}
function WorkflowInlineAgentConfigureWorkspaceContent({
agentId,
appId,
inlineComposerState,
nodeId,
onClose,
open,
}: AgentOrchestrateDrawerPanelProps) {
const [clearChatList, setClearChatList] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const composerState = inlineComposerState
const agentSoulConfig = composerState?.agent_soul
const activeConfigSnapshot = ('active_config_snapshot' in (composerState ?? {}))
? composerState?.active_config_snapshot as AgentConfigSnapshotSummaryResponse | null | undefined
: undefined
const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions()
const { draftSavedAt, saveDraft } = useWorkflowInlineAgentConfigureSync({
nodeId,
baseConfig: agentSoulConfig,
currentModel,
enabled: open && !!agentSoulConfig,
})
const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig as AgentSoulConfig | undefined)
useHydrateAgentSoulConfigDraft({
agentId: `${nodeId}:${agentId ?? 'pending'}`,
activeVersionId: activeConfigSnapshot?.id,
config: agentSoulConfig as AgentSoulConfig | undefined,
})
if (!agentId || !agentSoulConfig) {
return (
<div className="flex h-full min-h-80 items-center justify-center bg-components-panel-bg">
<Loading type="app" />
</div>
)
}
const handleSave = async () => {
if (isSaving)
return
setIsSaving(true)
try {
await saveDraft()
onClose?.()
}
finally {
setIsSaving(false)
}
}
return (
<AgentConfigureWorkspace
className="rounded-[inherit]"
leftPanel={(
<AgentOrchestratePanel
agentId={agentId}
appId={appId}
nodeId={nodeId}
activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={agentSoulConfig as AgentSoulConfig}
agentName={composerState?.agent?.name}
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
draftSavedAt={draftSavedAt}
showPublishBar={false}
bottomBar={(
<WorkflowInlineAgentConfigureActionBar
isSaving={isSaving}
onCancel={() => onClose?.()}
onSave={() => {
void handleSave()
}}
/>
)}
className="min-w-90"
onSelectModel={setConfigureModel}
onPublish={() => {
void saveDraft()
}}
onOpenVersions={() => undefined}
/>
)}
rightPanel={(
<AgentConfigurePreviewSurface
background={<AgentBuildPanelBackground visible />}
header={(
<AgentPreviewHeader
mode="build"
previewEnabled={false}
isChatFeaturesOpen={false}
onModeChange={() => undefined}
onToggleChatFeatures={() => undefined}
onOpenVersions={() => undefined}
onRefresh={() => {
setConversationId(null)
setClearChatList(true)
}}
showChatFeaturesAction={false}
/>
)}
chat={(
<AgentBuildChat
agentId={agentId}
agentIcon={composerState?.agent?.icon}
agentIconBackground={composerState?.agent?.icon_background}
agentIconType={composerState?.agent?.icon_type as Parameters<typeof AgentBuildChat>[0]['agentIconType']}
agentName={composerState?.agent?.name}
agentSoulConfig={previewAgentSoulConfig}
clearChatList={clearChatList}
conversationId={conversationId}
draftType="debug_build"
onClearChatListChange={setClearChatList}
onConversationIdChange={setConversationId}
onSaveDraftBeforeRun={saveDraft}
/>
)}
/>
)}
/>
)
}
function WorkflowInlineAgentConfigureActionBar({
isSaving,
onCancel,
onSave,
}: {
isSaving: boolean
onCancel: () => void
onSave: () => void
}) {
const { t } = useTranslation('common')
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center bg-gradient-to-t from-components-panel-bg pt-4 pb-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
<Button
type="button"
variant="secondary"
size="medium"
className="min-w-18"
disabled={isSaving}
onClick={onCancel}
>
{t('operation.cancel')}
</Button>
<div className="flex h-4 items-start px-1">
<div className="h-full w-px bg-divider-regular" />
</div>
<Button
type="button"
variant="secondary"
size="medium"
className="px-2"
disabled={isSaving}
aria-label={t('operation.more')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</Button>
<Button
type="button"
variant="primary"
size="medium"
className="min-w-20"
loading={isSaving}
onClick={onSave}
>
{t('operation.save')}
</Button>
</div>
</div>
)
}
function useAgentOrchestrateModelOptions() {
const [model, setModel] = useAtom(agentComposerModelAtom)
const {

View File

@ -3,6 +3,7 @@ import type { AgentRosterNodeData } from '@/app/components/workflow/block-select
import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Drawer,
DrawerCloseButton,
@ -243,6 +244,60 @@ function AgentRosterDrawer({
)
}
function AgentRosterInlineConfigureDialog({
agent,
children,
onSaveInlineToRoster,
open,
onClose,
}: {
agent: AgentRosterDisplayData
children?: ReactNode
onSaveInlineToRoster?: () => void
open: boolean
onClose: () => void
}) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
disablePointerDismissal
>
<DialogContent className="h-[min(760px,calc(100dvh-32px))] w-[min(1120px,calc(100vw-32px))] max-w-none overflow-hidden p-0">
<DialogCloseButton className="z-10" />
{onSaveInlineToRoster && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
className="absolute top-3 right-12 z-10 flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary 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 aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max">
<DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}>
<span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" />
<span>{t('roster.saveToRoster', { ns: 'agentV2' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DialogTitle className="sr-only">
{agent.name}
</DialogTitle>
<DialogDescription className="sr-only">
{t(`${i18nPrefix}.roster.inlineSetup.description`, { ns: 'workflow' })}
</DialogDescription>
{children ?? <div className="h-full min-h-80 bg-components-panel-bg" />}
</DialogContent>
</Dialog>
)
}
export function AgentRosterField({
agent,
agentId,
@ -382,21 +437,33 @@ export function AgentRosterField({
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</span>
</button>
<AgentRosterDrawer
agent={agent}
isInlineSetup={isInlineSetup}
mode={panelMode}
open={panelOpen}
portalContainerRef={portalContainerRef}
showAccessIcon={!isInlineSetup}
showDetailActions={showPanelDetailActions}
isCopyPending={isPanelCopyPending}
onMakeCopy={onMakeCopy}
onSaveInlineToRoster={onSaveInlineToRoster}
onClose={() => setPanelOpen(false)}
>
{panelBody}
</AgentRosterDrawer>
{isInlineSetup
? (
<AgentRosterInlineConfigureDialog
agent={agent}
onSaveInlineToRoster={onSaveInlineToRoster}
open={panelOpen}
onClose={() => setPanelOpen(false)}
>
{panelBody}
</AgentRosterInlineConfigureDialog>
)
: (
<AgentRosterDrawer
agent={agent}
mode={panelMode}
open={panelOpen}
portalContainerRef={portalContainerRef}
showAccessIcon
showDetailActions={showPanelDetailActions}
isCopyPending={isPanelCopyPending}
onMakeCopy={onMakeCopy}
onSaveInlineToRoster={onSaveInlineToRoster}
onClose={() => setPanelOpen(false)}
>
{panelBody}
</AgentRosterDrawer>
)}
</>
)
: (

View File

@ -15,7 +15,7 @@ import { useStore } from '@/app/components/workflow/store'
import { consoleQuery } from '@/service/client'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { AgentAdvancedSettings } from './components/agent-advanced-settings'
import { AgentOrchestrateDrawerPanel } from './components/agent-orchestrate-drawer-panel'
import { AgentOrchestrateDrawerPanel, WorkflowInlineAgentConfigureWorkspace } from './components/agent-orchestrate-drawer-panel'
import { AgentOutputVariables } from './components/agent-output-variables'
import { AgentRosterField } from './components/agent-roster-field'
import { AgentTaskField } from './components/agent-task-field'
@ -211,6 +211,29 @@ export function AgentV2Panel({
)
}, [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId])
const handleInlineAgentBindingCreated = useCallback((binding: {
agent_id: string
binding_type: 'inline_agent'
current_snapshot_id: string
}) => {
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
draft.agent_binding = binding
draft._openInlineAgentPanel = true
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleStartFromScratch = useCallback(() => {
setIsRosterAgentPanelOpen(false)
setIsInlineAgentPanelOpenedFromTrigger(false)
@ -236,38 +259,26 @@ export function AgentV2Panel({
)
createInlineAgentBinding(id, {
onSuccess: (binding) => {
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
draft.agent_binding = binding
draft._openInlineAgentPanel = true
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
},
onSuccess: handleInlineAgentBindingCreated,
})
}, [createInlineAgentBinding, handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId])
}, [createInlineAgentBinding, handleInlineAgentBindingCreated, handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId])
const handleAgentPanelOpenChange = useCallback((open: boolean) => {
if (isInlineAgentReady) {
if (isInlineAgentReady || isInlineAgentPending) {
if (open)
setIsInlineAgentPanelOpenedFromTrigger(true)
setOpenInlineAgentPanelNodeId(open ? id : undefined)
if (open && isInlineAgentPending && !isCreatingInlineAgent) {
createInlineAgentBinding(id, {
onSuccess: handleInlineAgentBindingCreated,
})
}
return
}
setIsRosterAgentPanelOpen(open)
}, [id, isInlineAgentReady, setOpenInlineAgentPanelNodeId])
}, [createInlineAgentBinding, handleInlineAgentBindingCreated, id, isCreatingInlineAgent, isInlineAgentPending, isInlineAgentReady, setOpenInlineAgentPanelNodeId])
const handleDeclaredOutputsChange = useCallback((outputs: ReturnType<typeof getAgentV2DeclaredOutputs>, agentTask?: string) => {
const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current)
@ -322,14 +333,28 @@ export function AgentV2Panel({
isPending={isAgentBindingPending}
panelBody={isAgentPanelOpen && displayedAgent
? (
<AgentOrchestrateDrawerPanel
agentId={inlineAgentId ?? rosterAgentId}
appId={appId}
inlineComposerState={inlineAgentQuery.data}
isInline={isInlineAgentReady || isInlineAgentPending}
nodeId={id}
open={isAgentPanelOpen}
/>
isInlineAgentReady || isInlineAgentPending
? (
<WorkflowInlineAgentConfigureWorkspace
agentId={inlineAgentId ?? undefined}
appId={appId}
inlineComposerState={inlineAgentQuery.data}
isInline
nodeId={id}
onClose={() => handleAgentPanelOpenChange(false)}
open={isAgentPanelOpen}
/>
)
: (
<AgentOrchestrateDrawerPanel
agentId={rosterAgentId}
appId={appId}
inlineComposerState={inlineAgentQuery.data}
isInline={false}
nodeId={id}
open={isAgentPanelOpen}
/>
)
)
: undefined}
panelMode={isInlineAgentPending || (isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger) ? 'setup' : 'detail'}

View File

@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentConfigurePage } from '../page'
@ -196,13 +196,26 @@ vi.mock('../components/orchestrate/build-draft-bar', () => ({
vi.mock('../components/preview/build-chat', () => ({
AgentBuildChat: (props: {
conversationId?: string | null
onConversationComplete?: () => void
onConversationIdChange?: (conversationId: string) => void
onSaveDraftBeforeRun?: () => Promise<void>
}) => (
<div role="region" aria-label="build-chat">
<span>{`build:${props.conversationId ?? 'none'}`}</span>
<button type="button" onClick={() => props.onConversationIdChange?.('build-conversation-new')}>
save build conversation
</button>
<button
type="button"
onClick={() => {
void props.onSaveDraftBeforeRun?.()
}}
>
send build message
</button>
<button type="button" onClick={() => props.onConversationComplete?.()}>
complete build conversation
</button>
</div>
),
}))
@ -310,6 +323,10 @@ describe('AgentConfigurePage', () => {
}
})
afterEach(() => {
vi.useRealTimers()
})
describe('Loading state', () => {
it('should show loading instead of the configure panels while composer data is pending', () => {
const queryClient = new QueryClient()
@ -487,6 +504,120 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
})
it('should show the build draft bar after a new build conversation refresh completes', async () => {
vi.useFakeTimers()
const queryClient = new QueryClient()
const refetchBuildDraft = vi.fn().mockResolvedValue({})
mocks.queryState.composer = {
data: {
agent_soul: {
prompt: {
system_prompt: 'draft prompt',
},
},
},
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: vi.fn(),
}
mocks.queryState.buildDraft = {
data: {
agent_soul: {
prompt: {
system_prompt: 'build prompt',
},
},
draft: {},
variant: 'agent_app',
},
dataUpdatedAt: 1,
error: null,
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: refetchBuildDraft,
}
render(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
expect(refetchBuildDraft).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(refetchBuildDraft).toHaveBeenCalled()
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
})
it('should discard the build draft when restarting build mode with a build draft', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient()
mocks.queryState.composer = {
data: {},
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: vi.fn(),
}
mocks.queryState.buildDraft = {
data: {
agent_soul: {},
draft: {},
variant: 'agent_app',
},
dataUpdatedAt: 1,
error: null,
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: vi.fn(),
}
render(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
await user.click(screen.getByRole('button', { name: 'restart preview' }))
await waitFor(() => expect(mocks.discardBuildDraft).toHaveBeenCalledWith(
{
params: {
agent_id: 'agent-1',
},
},
expect.any(Object),
))
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
params: {
agent_id: 'agent-1',
},
body: {
debug_conversation_id: 'debug-conversation-old',
},
}, expect.any(Object))
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
})
it('should switch soul source to view version when selecting a version from build draft mode', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient()

View File

@ -35,6 +35,7 @@ export function AgentPreviewHeader({
onOpenVersions,
onRefresh,
refreshDisabled,
showChatFeaturesAction = true,
}: {
mode: AgentConfigureRightPanelMode
previewEnabled: boolean
@ -44,6 +45,7 @@ export function AgentPreviewHeader({
onOpenVersions: () => void
onRefresh: () => void
refreshDisabled?: boolean
showChatFeaturesAction?: boolean
}) {
const { t } = useTranslation('agentV2')
const docLink = useDocLink()
@ -129,20 +131,24 @@ export function AgentPreviewHeader({
</button>
)}
</div>
<SegmentedControlDivider className="mx-3" />
<button
type="button"
aria-pressed={isChatFeaturesOpen}
onClick={onToggleChatFeatures}
className={cn(
'flex h-8 items-center justify-center gap-1 rounded-lg px-2 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isChatFeaturesOpen && 'bg-state-base-hover text-text-secondary',
)}
aria-label={t('agentDetail.configure.preview.chatFeatures')}
>
<span aria-hidden className="i-ri-chat-settings-line size-4" />
<span className="px-0.5 system-sm-medium">{t('agentDetail.configure.preview.chatFeatures')}</span>
</button>
{showChatFeaturesAction && (
<>
<SegmentedControlDivider className="mx-3" />
<button
type="button"
aria-pressed={isChatFeaturesOpen}
onClick={onToggleChatFeatures}
className={cn(
'flex h-8 items-center justify-center gap-1 rounded-lg px-2 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isChatFeaturesOpen && 'bg-state-base-hover text-text-secondary',
)}
aria-label={t('agentDetail.configure.preview.chatFeatures')}
>
<span aria-hidden className="i-ri-chat-settings-line size-4" />
<span className="px-0.5 system-sm-medium">{t('agentDetail.configure.preview.chatFeatures')}</span>
</button>
</>
)}
</div>
</div>
)

View File

@ -0,0 +1,59 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type AgentConfigureWorkspaceProps = {
'aria-busy'?: boolean
'className'?: string
'leftPanel': ReactNode
'rightPanel': ReactNode
'sidePanels'?: ReactNode
}
export function AgentConfigureWorkspace({
'aria-busy': ariaBusy,
className,
leftPanel,
rightPanel,
sidePanels,
}: AgentConfigureWorkspaceProps) {
const { t } = useTranslation('agentV2')
return (
<section
aria-label={t('agentDetail.sections.configure')}
aria-busy={ariaBusy}
className={cn('flex h-full min-w-0 flex-1 gap-1 overflow-hidden bg-background-body p-1', className)}
>
{leftPanel}
<div className="flex min-w-105 flex-1 gap-1 overflow-hidden">
{rightPanel}
{sidePanels}
</div>
</section>
)
}
type AgentConfigurePreviewSurfaceProps = {
background?: ReactNode
chat: ReactNode
header: ReactNode
}
export function AgentConfigurePreviewSurface({
background,
chat,
header,
}: AgentConfigurePreviewSurfaceProps) {
return (
<div className="relative isolate flex min-w-105 flex-1 flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-linear-to-b from-background-gradient-bg-fill-chat-bg-1 to-background-gradient-bg-fill-chat-bg-2 shadow-xl shadow-shadow-shadow-5">
{background}
{header}
<div className="relative z-1 min-h-0 flex-1">
{chat}
</div>
</div>
)
}

View File

@ -16,6 +16,7 @@ import { AgentChatFeaturesPanel } from './components/preview/chat-features-panel
import { AgentPreviewHeader } from './components/preview/header'
import { AgentPreviewChat } from './components/preview/preview-chat'
import { AgentPreviewVersionsPanel } from './components/preview/versions-panel'
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from './components/workspace'
import { useAgentConfigureData, useAgentConfigureModelOptions, useAgentPreviewSoulConfig } from './hooks'
import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from './use-agent-configure-build-draft'
import { useAgentConfigureSync } from './use-agent-configure-sync'
@ -90,6 +91,7 @@ function AgentConfigurePageLoadedContent({
const [showPreviewVersions, setShowPreviewVersions] = useState(false)
const [clearPreviewChat, setClearPreviewChat] = useState(false)
const [rightPanelMode, setRightPanelMode] = useState<AgentConfigureRightPanelMode>('build')
const [hideBuildDraftBarUntilRefresh, setHideBuildDraftBarUntilRefresh] = useState(false)
const {
agentQuery,
composerQuery,
@ -113,6 +115,7 @@ function AgentConfigurePageLoadedContent({
isViewingVersion,
normalAgentSoulConfig: agentSoulConfig,
})
const showBuildDraftBar = buildDraft.isActive && !hideBuildDraftBarUntilRefresh
const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({
onSuccess: ({ debug_conversation_id }) => {
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
@ -162,16 +165,6 @@ function AgentConfigurePageLoadedContent({
[mode]: conversationId,
}))
}
const restartCurrentChat = () => {
if (rightPanelChatMode === 'build')
refreshDebugConversation(conversationIds.build ?? '')
setConversationIds(current => ({
...current,
[rightPanelChatMode]: null,
}))
setClearPreviewChat(true)
}
const resetBuildChatSession = useCallback(async () => {
await refreshDebugConversationAsync(conversationIds.build ?? '')
setConversationIds(current => ({
@ -215,6 +208,21 @@ function AgentConfigurePageLoadedContent({
buildDraft.setSoulSourceOverride(versionId ? 'view-version' : null)
onSelectVersion(versionId)
}, [buildDraft, onSelectVersion])
const restartCurrentChat = () => {
if (rightPanelChatMode === 'build' && buildDraft.isActive) {
void buildDraftActions.discardBuildDraft()
return
}
if (rightPanelChatMode === 'build')
refreshDebugConversation(conversationIds.build ?? '')
setConversationIds(current => ({
...current,
[rightPanelChatMode]: null,
}))
setClearPreviewChat(true)
}
if (buildDraft.isPending) {
return (
@ -229,62 +237,60 @@ function AgentConfigurePageLoadedContent({
}
return (
<section
aria-label={t('agentDetail.sections.configure')}
<AgentConfigureWorkspace
aria-busy={agentQuery.isFetching}
className="flex h-full min-w-0 flex-1 gap-1 overflow-hidden bg-background-body p-1"
>
<AgentOrchestratePanel
agentId={agentId}
activeConfigIsPublished={agentQuery.data?.active_config_is_published}
activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={buildDraft.agentSoulConfig}
agentName={agentQuery.data?.name}
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
draftSavedAt={draftSavedAt}
isPublishing={isPublishing}
readOnly={isViewingVersion || buildDraft.isActive}
selectedVersionSnapshot={isViewingVersion ? activeConfigSnapshot : undefined}
isBuildDraftActive={buildDraft.isActive}
showPublishBar={!buildDraft.isActive}
bottomBar={buildDraft.isActive
? (
<AgentBuildDraftBar
changesCount={buildDraft.changesCount}
isApplying={buildDraftActions.isApplyingBuildDraft}
isDiscarding={buildDraftActions.isDiscardingBuildDraft}
onApply={() => {
void buildDraftActions.applyBuildDraft()
}}
onDiscard={() => {
void buildDraftActions.discardBuildDraft()
}}
/>
)
: undefined}
onSelectModel={setConfigureModel}
onPublish={publishDraft}
onOpenVersions={() => setShowPreviewVersions(true)}
onExitVersions={() => selectVersion(null)}
/>
{/* Preview area */}
<div className="flex min-w-105 flex-1 gap-1 overflow-hidden">
<div className="relative isolate flex min-w-105 flex-1 flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-linear-to-b from-background-gradient-bg-fill-chat-bg-1 to-background-gradient-bg-fill-chat-bg-2 shadow-xl shadow-shadow-shadow-5">
<AgentBuildPanelBackground visible={rightPanelChatMode === 'build'} />
<AgentPreviewHeader
mode={rightPanelChatMode}
previewEnabled={false}
isChatFeaturesOpen={showChatFeatures}
onModeChange={setRightPanelMode}
onToggleChatFeatures={() => setShowChatFeatures(open => !open)}
onOpenVersions={() => setShowPreviewVersions(true)}
onRefresh={restartCurrentChat}
refreshDisabled={isRefreshingDebugConversation}
/>
<div className="relative z-1 min-h-0 flex-1">
leftPanel={(
<AgentOrchestratePanel
agentId={agentId}
activeConfigIsPublished={agentQuery.data?.active_config_is_published}
activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={buildDraft.agentSoulConfig}
agentName={agentQuery.data?.name}
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
draftSavedAt={draftSavedAt}
isPublishing={isPublishing}
readOnly={isViewingVersion || buildDraft.isActive}
selectedVersionSnapshot={isViewingVersion ? activeConfigSnapshot : undefined}
isBuildDraftActive={buildDraft.isActive}
showPublishBar={!buildDraft.isActive}
bottomBar={showBuildDraftBar
? (
<AgentBuildDraftBar
changesCount={buildDraft.changesCount}
isApplying={buildDraftActions.isApplyingBuildDraft}
isDiscarding={buildDraftActions.isDiscardingBuildDraft}
onApply={() => {
void buildDraftActions.applyBuildDraft()
}}
onDiscard={() => {
void buildDraftActions.discardBuildDraft()
}}
/>
)
: undefined}
onSelectModel={setConfigureModel}
onPublish={publishDraft}
onOpenVersions={() => setShowPreviewVersions(true)}
onExitVersions={() => selectVersion(null)}
/>
)}
rightPanel={(
<AgentConfigurePreviewSurface
background={<AgentBuildPanelBackground visible={rightPanelChatMode === 'build'} />}
header={(
<AgentPreviewHeader
mode={rightPanelChatMode}
previewEnabled={false}
isChatFeaturesOpen={showChatFeatures}
onModeChange={setRightPanelMode}
onToggleChatFeatures={() => setShowChatFeatures(open => !open)}
onOpenVersions={() => setShowPreviewVersions(true)}
onRefresh={restartCurrentChat}
refreshDisabled={isRefreshingDebugConversation || buildDraftActions.isDiscardingBuildDraft}
/>
)}
chat={(
<AgentRightPanelChatWithDraftConfig
agentId={agentId}
agentIcon={agentQuery.data?.icon}
@ -297,29 +303,40 @@ function AgentConfigurePageLoadedContent({
draftType={rightPanelChatMode === 'build' ? 'debug_build' : undefined}
mode={rightPanelChatMode}
onClearChatListChange={setClearPreviewChat}
onConversationComplete={buildDraftActions.refreshBuildDraftAfterBuildChat}
onConversationComplete={(mode) => {
if (mode === 'build')
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setHideBuildDraftBarUntilRefresh(false))
}}
onConversationIdChange={updateConversationId}
onSaveDraftBeforeRun={rightPanelChatMode === 'build' ? buildDraftActions.prepareBuildDraftBeforeRun : saveDraft}
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
? async () => {
setHideBuildDraftBarUntilRefresh(true)
await buildDraftActions.prepareBuildDraftBeforeRun()
}
: saveDraft}
/>
</div>
</div>
{showPreviewVersions && (
<AgentPreviewVersionsPanel
agentId={agentId}
activeVersionId={activeVersionId}
onSelectVersion={selectVersion}
onClose={() => setShowPreviewVersions(false)}
/>
)}
<AgentChatFeaturesPanel
show={showChatFeatures}
appFeatures={agentSoulConfig?.app_features}
disabled={versionQuery.isPending}
onClose={() => setShowChatFeatures(false)}
)}
/>
</div>
</section>
)}
sidePanels={(
<>
{showPreviewVersions && (
<AgentPreviewVersionsPanel
agentId={agentId}
activeVersionId={activeVersionId}
onSelectVersion={selectVersion}
onClose={() => setShowPreviewVersions(false)}
/>
)}
<AgentChatFeaturesPanel
show={showChatFeatures}
appFeatures={agentSoulConfig?.app_features}
disabled={versionQuery.isPending}
onClose={() => setShowChatFeatures(false)}
/>
</>
)}
/>
)
}

View File

@ -157,13 +157,14 @@ export function useAgentConfigureBuildDraftActions({
setSoulSourceOverride('build-draft')
}, [agentId, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isActive, queryClient, saveDraft, setSoulSourceOverride])
const refreshBuildDraftAfterBuildChat = useCallback(() => {
const refreshBuildDraftAfterBuildChat = useCallback((onRefreshed?: () => void) => {
if (buildDraftRefreshTimerRef.current)
clearTimeout(buildDraftRefreshTimerRef.current)
buildDraftRefreshTimerRef.current = setTimeout(() => {
buildDraftRefreshTimerRef.current = setTimeout(async () => {
buildDraftRefreshTimerRef.current = null
void refetchBuildDraft()
await refetchBuildDraft()
onRefreshed?.()
}, 1000)
}, [refetchBuildDraft])