diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx
index 84de0d50624..532bce01ce8 100644
--- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx
+++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx
@@ -1,9 +1,13 @@
+import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentConfigurePage } from '../page'
const mocks = vi.hoisted(() => ({
+ applyBuildDraft: vi.fn(),
+ checkoutBuildDraft: vi.fn(),
+ discardBuildDraft: vi.fn(),
refreshDebugConversation: vi.fn(),
queryState: {
agent: {
@@ -15,21 +19,35 @@ const mocks = vi.hoisted(() => ({
name: 'Research Agent',
},
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: true,
},
composer: {
data: undefined as unknown,
isFetching: true,
+ isError: false,
isPending: true,
isSuccess: false,
+ refetch: vi.fn(),
},
version: {
data: undefined as unknown,
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: false,
},
+ buildDraft: {
+ data: undefined as unknown,
+ dataUpdatedAt: 0,
+ error: null as unknown,
+ isFetching: false,
+ isError: false,
+ isPending: false,
+ isSuccess: false,
+ refetch: vi.fn(),
+ },
},
}))
@@ -47,10 +65,13 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
return mocks.queryState.composer
if (queryKey === 'version')
return mocks.queryState.version
+ if (queryKey === 'build-draft')
+ return mocks.queryState.buildDraft
return {
data: undefined,
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: false,
}
@@ -85,6 +106,24 @@ vi.mock('@/service/client', () => ({
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
+ buildDraft: {
+ get: {
+ queryOptions: () => ({ queryKey: ['build-draft'] }),
+ },
+ checkout: {
+ post: {
+ mutationOptions: () => ({ mutationFn: mocks.checkoutBuildDraft }),
+ },
+ },
+ apply: {
+ post: {
+ mutationOptions: () => ({ mutationFn: mocks.applyBuildDraft }),
+ },
+ },
+ delete: {
+ mutationOptions: () => ({ mutationFn: mocks.discardBuildDraft }),
+ },
+ },
versions: {
byVersionId: {
get: {
@@ -108,7 +147,31 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('../components/orchestrate', () => ({
- AgentOrchestratePanel: () =>
@@ -158,6 +222,9 @@ vi.mock('../components/preview/header', () => ({
+
@@ -177,6 +244,13 @@ describe('AgentConfigurePage', () => {
mocks.refreshDebugConversation.mockResolvedValue({
debug_conversation_id: 'debug-conversation-new',
})
+ mocks.applyBuildDraft.mockResolvedValue({ result: 'success', draft: {} })
+ mocks.checkoutBuildDraft.mockResolvedValue({
+ variant: 'agent_app',
+ draft: {},
+ agent_soul: {},
+ })
+ mocks.discardBuildDraft.mockResolvedValue({ result: 'success' })
mocks.queryState.agent = {
data: {
icon: 'agent',
@@ -186,21 +260,35 @@ describe('AgentConfigurePage', () => {
debug_conversation_id: 'debug-conversation-old',
},
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: true,
}
mocks.queryState.composer = {
data: undefined as unknown,
isFetching: true,
+ isError: false,
isPending: true,
isSuccess: false,
+ refetch: vi.fn(),
}
mocks.queryState.version = {
data: undefined as unknown,
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: false,
}
+ mocks.queryState.buildDraft = {
+ data: undefined as unknown,
+ dataUpdatedAt: 0,
+ error: null,
+ isFetching: false,
+ isError: false,
+ isPending: false,
+ isSuccess: false,
+ refetch: vi.fn(),
+ }
})
describe('Loading state', () => {
@@ -225,8 +313,10 @@ describe('AgentConfigurePage', () => {
mocks.queryState.composer = {
data: {},
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: true,
+ refetch: vi.fn(),
}
render(
@@ -271,8 +361,10 @@ describe('AgentConfigurePage', () => {
mocks.queryState.composer = {
data: {},
isFetching: false,
+ isError: false,
isPending: false,
isSuccess: true,
+ refetch: vi.fn(),
}
render(
@@ -289,5 +381,249 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:debug-conversation-old')
expect(screen.queryByRole('region', { name: 'preview-chat', hidden: true })).not.toBeInTheDocument()
})
+
+ it('should stay in normal draft mode when build draft returns 404 even if a debug conversation exists', () => {
+ const queryClient = new QueryClient()
+ 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: undefined as unknown,
+ dataUpdatedAt: 0,
+ error: new Response(null, { status: 404 }),
+ isFetching: false,
+ isError: true,
+ isPending: false,
+ isSuccess: false,
+ refetch: vi.fn(),
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
+ expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('publish:yes')
+ expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
+ })
+
+ it('should enter build draft mode when build draft data exists', () => {
+ const queryClient = new QueryClient()
+ 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: vi.fn(),
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes')
+ expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('publish:no')
+ expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
+ })
+
+ 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()
+ 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: vi.fn(),
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'open versions' }))
+ await user.click(screen.getByRole('button', { name: 'select version' }))
+
+ expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes')
+ expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('publish:yes')
+ expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
+ })
+
+ it('should apply the build draft and start a new build session', async () => {
+ const user = userEvent.setup()
+ const queryClient = new QueryClient()
+ const refetchComposer = vi.fn().mockResolvedValue({})
+ mocks.queryState.composer = {
+ data: {},
+ isFetching: false,
+ isError: false,
+ isPending: false,
+ isSuccess: true,
+ refetch: refetchComposer,
+ }
+ 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(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'apply build draft' }))
+
+ await waitFor(() => expect(mocks.applyBuildDraft).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(refetchComposer).toHaveBeenCalled()
+ expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
+ })
+
+ it('should discard the build draft and start a new build session', 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(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'discard build draft' }))
+
+ 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')
+ })
})
})
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx
new file mode 100644
index 00000000000..61f7bd8de43
--- /dev/null
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { Button } from '@langgenius/dify-ui/button'
+import { useTranslation } from 'react-i18next'
+import { PublishBarBottomActions } from './publish-bar'
+
+type AgentBuildDraftBarProps = {
+ changesCount: number
+ isApplying?: boolean
+ isDiscarding?: boolean
+ onApply: () => void
+ onDiscard: () => void
+}
+
+export function AgentBuildDraftBar({
+ changesCount,
+ isApplying = false,
+ isDiscarding = false,
+ onApply,
+ onDiscard,
+}: AgentBuildDraftBarProps) {
+ const { t } = useTranslation('agentV2')
+ const { t: tCustom } = useTranslation('custom')
+ const isPending = isApplying || isDiscarding
+ const metaLabel = changesCount > 0
+ ? t('agentDetail.configure.buildDraft.changes', { count: changesCount })
+ : t('agentDetail.configure.buildDraft.noChanges')
+
+ return (
+
+
+
+
+ {t('agentDetail.configure.buildDraft.title')}
+
+
+ {metaLabel}
+
+
+
+
+
+
+ )
+}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx
index c27aa7ae4fe..625e3517770 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx
@@ -1,6 +1,7 @@
'use client'
import type { AgentConfigSnapshotDetailResponse, AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
+import type { ReactNode } from 'react'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
@@ -36,6 +37,7 @@ type AgentOrchestratePanelProps = {
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
showHeader?: boolean
showPublishBar?: boolean
+ bottomBar?: ReactNode
onSelectModel: (model: DefaultModel) => void
onPublish: () => void | Promise
onExitVersions?: () => void
@@ -59,6 +61,7 @@ export function AgentOrchestratePanel({
selectedVersionSnapshot,
showHeader = true,
showPublishBar = true,
+ bottomBar,
onSelectModel,
onPublish,
onExitVersions,
@@ -67,6 +70,7 @@ export function AgentOrchestratePanel({
const { t } = useTranslation('agentV2')
const orchestrateHeadingId = 'agent-configure-orchestrate-heading'
const orchestrateLabel = t('agentDetail.configure.orchestrate')
+ const hasBottomBar = showPublishBar || !!bottomBar
const driveApiContext = useMemo(() => appId && nodeId
? {
agentId,
@@ -92,8 +96,8 @@ export function AgentOrchestratePanel({
labelledBy={showHeader ? orchestrateHeadingId : undefined}
slotClassNames={{
viewport: 'overscroll-contain',
- content: cn('min-h-full px-4 py-3', showPublishBar && 'pb-20'),
- scrollbar: showPublishBar ? 'z-20' : undefined,
+ content: cn('min-h-full px-4 py-3', hasBottomBar && 'pb-20'),
+ scrollbar: hasBottomBar ? 'z-20' : undefined,
}}
>
@@ -115,7 +119,8 @@ export function AgentOrchestratePanel({
- {showPublishBar && (
+ {bottomBar}
+ {showPublishBar && !bottomBar && (