diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 450ea1823c0..2ce927dde5c 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -182,6 +182,20 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
) }, + WorkflowInlineAgentConfigureWorkspace: (props: { + agentId?: string + appId?: string + inlineComposerState?: unknown + isInline: boolean + nodeId: string + open: boolean + }) => { + mockOrchestrateDrawerPanelProps.push(props) + + return ( +
+ ) + }, })) 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( + , + ) + + 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( + , + ) + + 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( diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-drawer-panel.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-drawer-panel.tsx index 0dff1fe9583..ad70ca413b1 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-drawer-panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-drawer-panel.tsx @@ -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 ( + + + + ) +} + 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(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 ( +
+ +
+ ) + } + + const handleSave = async () => { + if (isSaving) + return + + setIsSaving(true) + try { + await saveDraft() + onClose?.() + } + finally { + setIsSaving(false) + } + } + + return ( + onClose?.()} + onSave={() => { + void handleSave() + }} + /> + )} + className="min-w-90" + onSelectModel={setConfigureModel} + onPublish={() => { + void saveDraft() + }} + onOpenVersions={() => undefined} + /> + )} + rightPanel={( + } + header={( + undefined} + onToggleChatFeatures={() => undefined} + onOpenVersions={() => undefined} + onRefresh={() => { + setConversationId(null) + setClearChatList(true) + }} + showChatFeaturesAction={false} + /> + )} + chat={( + [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 ( +
+
+ +
+
+
+ + +
+
+ ) +} + function useAgentOrchestrateModelOptions() { const [model, setModel] = useAtom(agentComposerModelAtom) const { diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx index f28b735d984..46ad39c9cd7 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx @@ -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 ( + { + if (!nextOpen) + onClose() + }} + disablePointerDismissal + > + + + {onSaveInlineToRoster && ( + + + + + + + + {t('roster.saveToRoster', { ns: 'agentV2' })} + + + + )} + + {agent.name} + + + {t(`${i18nPrefix}.roster.inlineSetup.description`, { ns: 'workflow' })} + + {children ??
} + +
+ ) +} + export function AgentRosterField({ agent, agentId, @@ -382,21 +437,33 @@ export function AgentRosterField({ - setPanelOpen(false)} - > - {panelBody} - + {isInlineSetup + ? ( + setPanelOpen(false)} + > + {panelBody} + + ) + : ( + setPanelOpen(false)} + > + {panelBody} + + )} ) : ( diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index 6948ec55cd4..fcb9e14c428 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -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, agentTask?: string) => { const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current) @@ -322,14 +333,28 @@ export function AgentV2Panel({ isPending={isAgentBindingPending} panelBody={isAgentPanelOpen && displayedAgent ? ( - + isInlineAgentReady || isInlineAgentPending + ? ( + handleAgentPanelOpenChange(false)} + open={isAgentPanelOpen} + /> + ) + : ( + + ) ) : undefined} panelMode={isInlineAgentPending || (isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger) ? 'setup' : 'detail'} 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 58a23e2be32..ebe461f0676 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,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 }) => (
{`build:${props.conversationId ?? 'none'}`} + +
), })) @@ -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( + + + , + ) + + 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( + + + , + ) + + 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() diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx index 6bcff963443..0e92e873ea3 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx @@ -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({ )}
- - + {showChatFeaturesAction && ( + <> + + + + )}
) diff --git a/web/features/agent-v2/agent-detail/configure/components/workspace.tsx b/web/features/agent-v2/agent-detail/configure/components/workspace.tsx new file mode 100644 index 00000000000..9f116d77c7d --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/workspace.tsx @@ -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 ( +
+ {leftPanel} +
+ {rightPanel} + {sidePanels} +
+
+ ) +} + +type AgentConfigurePreviewSurfaceProps = { + background?: ReactNode + chat: ReactNode + header: ReactNode +} + +export function AgentConfigurePreviewSurface({ + background, + chat, + header, +}: AgentConfigurePreviewSurfaceProps) { + return ( +
+ {background} + {header} +
+ {chat} +
+
+ ) +} diff --git a/web/features/agent-v2/agent-detail/configure/page.tsx b/web/features/agent-v2/agent-detail/configure/page.tsx index faf783bd73b..70e7df37e07 100644 --- a/web/features/agent-v2/agent-detail/configure/page.tsx +++ b/web/features/agent-v2/agent-detail/configure/page.tsx @@ -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('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( @@ -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 ( -
- { - void buildDraftActions.applyBuildDraft() - }} - onDiscard={() => { - void buildDraftActions.discardBuildDraft() - }} - /> - ) - : undefined} - onSelectModel={setConfigureModel} - onPublish={publishDraft} - onOpenVersions={() => setShowPreviewVersions(true)} - onExitVersions={() => selectVersion(null)} - /> - - {/* Preview area */} -
-
- - setShowChatFeatures(open => !open)} - onOpenVersions={() => setShowPreviewVersions(true)} - onRefresh={restartCurrentChat} - refreshDisabled={isRefreshingDebugConversation} - /> - -
+ leftPanel={( + { + void buildDraftActions.applyBuildDraft() + }} + onDiscard={() => { + void buildDraftActions.discardBuildDraft() + }} + /> + ) + : undefined} + onSelectModel={setConfigureModel} + onPublish={publishDraft} + onOpenVersions={() => setShowPreviewVersions(true)} + onExitVersions={() => selectVersion(null)} + /> + )} + rightPanel={( + } + header={( + setShowChatFeatures(open => !open)} + onOpenVersions={() => setShowPreviewVersions(true)} + onRefresh={restartCurrentChat} + refreshDisabled={isRefreshingDebugConversation || buildDraftActions.isDiscardingBuildDraft} + /> + )} + chat={( { + 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} /> -
-
- - {showPreviewVersions && ( - setShowPreviewVersions(false)} - /> - )} - setShowChatFeatures(false)} + )} /> -
-
+ )} + sidePanels={( + <> + {showPreviewVersions && ( + setShowPreviewVersions(false)} + /> + )} + setShowChatFeatures(false)} + /> + + )} + /> ) } diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts index 87e67ddfe22..865d4e4c3e2 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts @@ -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])