diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index ece36b121b2..089182565ba 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -348,4 +348,52 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }), })) }) + + it('still saves manually when inline agent autosave is disabled', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({ + nodeId: 'node-1', + baseConfig: { + schema_version: 1, + }, + autoSaveEnabled: false, + enabled: true, + }), { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }) + + await act(async () => { + await result.current.saveDraft() + }) + + expect(mockComposerMutationFn).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + node_id: 'node-1', + }, + body: expect.objectContaining({ + variant: 'workflow', + save_strategy: 'node_job_only', + agent_soul: expect.objectContaining({ + schema_version: 1, + }), + }), + }, expect.any(Object)) + }) }) 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 2ce927dde5c..95a31c3cf5c 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 @@ -20,6 +20,7 @@ const { mockSetInputs, mockStoreState, mockUseAgentRosterDetail, + mockWorkflowInlineAgentDetailRefetch, mockUseWorkflowInlineAgentDetail, mockUseNodeCrud, } = vi.hoisted(() => ({ @@ -48,6 +49,7 @@ const { setOpenInlineAgentPanelNodeId: vi.fn(), }, mockUseAgentRosterDetail: vi.fn(), + mockWorkflowInlineAgentDetailRefetch: vi.fn(), mockUseWorkflowInlineAgentDetail: vi.fn(), mockUseNodeCrud: vi.fn(), })) @@ -188,12 +190,39 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({ inlineComposerState?: unknown isInline: boolean nodeId: string + onClose?: () => void + onSaved?: (binding: { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null + }) => void + onSaveInlineToRoster?: () => void open: boolean }) => { mockOrchestrateDrawerPanelProps.push(props) return ( -
+
+ + +
) }, })) @@ -339,6 +368,8 @@ describe('agent/panel', () => { }, } : undefined, + isFetching: false, + refetch: mockWorkflowInlineAgentDetailRefetch, })) }) @@ -586,6 +617,7 @@ describe('agent/panel', () => { fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ })) expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node') + expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalled() mockStoreState.openInlineAgentPanelNodeId = 'agent-node' rerender( @@ -612,7 +644,7 @@ describe('agent/panel', () => { expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() }) - it('opens save-to-roster action from the inline drawer menu and rebinds to the saved roster agent', () => { + it('opens save-to-roster action from the inline workspace menu and rebinds to the saved roster agent', () => { mockStoreState.openInlineAgentPanelNodeId = 'agent-node' render( { ) 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' })) + expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument() + fireEvent.click(within(panel).getByRole('button', { name: 'Inline workspace more' })) fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster', hidden: true })) expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined) @@ -651,6 +683,43 @@ describe('agent/panel', () => { ) }) + it('updates the inline binding snapshot from the modal save response before closing', () => { + mockStoreState.openInlineAgentPanelNodeId = 'agent-node' + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save inline modal' })) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith( + { + id: 'agent-node', + data: expect.objectContaining({ + agent_binding: { + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'latest-snapshot-2', + }, + }), + }, + expect.objectContaining({ + sync: true, + notRefreshWhenSyncError: true, + }), + ) + expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined) + }) + it('does not show start from scratch for an existing inline agent binding', () => { render( { it('opens the inline panel while workflow composer state is still loading', () => { mockStoreState.openInlineAgentPanelNodeId = 'agent-node' - mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined }) + mockUseWorkflowInlineAgentDetail.mockReturnValue({ + data: undefined, + isFetching: true, + refetch: mockWorkflowInlineAgentDetailRefetch, + }) const { container } = render( { + const saveComposer = useSerialAsyncCallback(async (configSnapshot: AgentSoulConfig): Promise => { if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) return @@ -121,6 +123,7 @@ export function useWorkflowInlineAgentConfigureSync({ setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul)) setDraftSavedAt(Date.now()) lastAutosavedDraftKeyRef.current = savedDraftKey + return composerState }) const latestDraftSaveRef = useRef<() => void>(() => undefined) @@ -137,7 +140,7 @@ export function useWorkflowInlineAgentConfigureSync({ return debouncedSaveDraft.cancel?.() - await saveComposer(getAgentSoulDraft()) + return saveComposer(getAgentSoulDraft()) }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer]) useEffect(() => { @@ -147,6 +150,7 @@ export function useWorkflowInlineAgentConfigureSync({ if ( !enabledRef.current + || !autoSaveEnabled || !store.get(isAgentComposerDirtyAtom) || lastAutosavedDraftKeyRef.current === agentSoulDraftKey ) { @@ -155,13 +159,14 @@ export function useWorkflowInlineAgentConfigureSync({ debouncedSaveDraft() }) - }, [debouncedSaveDraft, getAgentSoulDraft, store]) + }, [autoSaveEnabled, debouncedSaveDraft, getAgentSoulDraft, store]) useEffect(() => { return () => { - debouncedSaveDraft.flush?.() + if (autoSaveEnabled) + debouncedSaveDraft.flush?.() } - }, [debouncedSaveDraft]) + }, [autoSaveEnabled, debouncedSaveDraft]) return { draftSavedAt, 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 ad70ca413b1..c2350b7009d 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 @@ -1,8 +1,14 @@ 'use client' import type { AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' -import type { WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { AgentComposerBindingResponse, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen' import { Button } from '@langgenius/dify-ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { skipToken, useQuery } from '@tanstack/react-query' import { useAtom } from 'jotai' import { useState } from 'react' @@ -29,6 +35,8 @@ type AgentOrchestrateDrawerPanelProps = { isInline: boolean nodeId: string onClose?: () => void + onSaved?: (binding: AgentComposerBindingResponse) => void + onSaveInlineToRoster?: () => void open: boolean } @@ -122,8 +130,11 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ inlineComposerState, nodeId, onClose, + onSaved, + onSaveInlineToRoster, open, }: AgentOrchestrateDrawerPanelProps) { + const { t } = useTranslation() const [clearChatList, setClearChatList] = useState(false) const [conversationId, setConversationId] = useState(null) const [isSaving, setIsSaving] = useState(false) @@ -137,6 +148,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ nodeId, baseConfig: agentSoulConfig, currentModel, + autoSaveEnabled: false, enabled: open && !!agentSoulConfig, }) const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig as AgentSoulConfig | undefined) @@ -161,7 +173,17 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ setIsSaving(true) try { - await saveDraft() + const composerState = await saveDraft() + const binding = composerState?.binding + if ( + binding?.binding_type !== 'inline_agent' + || !binding.agent_id + || !binding.current_snapshot_id + ) { + return + } + + onSaved?.(binding) onClose?.() } finally { @@ -188,6 +210,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ onClose?.()} + onSaveInlineToRoster={onSaveInlineToRoster} onSave={() => { void handleSave() }} @@ -217,6 +240,16 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ setClearChatList(true) }} showChatFeaturesAction={false} + trailingAction={( + + )} /> )} chat={( @@ -232,7 +265,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ draftType="debug_build" onClearChatListChange={setClearChatList} onConversationIdChange={setConversationId} - onSaveDraftBeforeRun={saveDraft} /> )} /> @@ -244,10 +276,12 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ function WorkflowInlineAgentConfigureActionBar({ isSaving, onCancel, + onSaveInlineToRoster, onSave, }: { isSaving: boolean onCancel: () => void + onSaveInlineToRoster?: () => void onSave: () => void }) { const { t } = useTranslation('common') @@ -268,16 +302,28 @@ function WorkflowInlineAgentConfigureActionBar({
- + + + + + )} + /> + + + + {t('roster.saveToRoster', { ns: 'agentV2' })} + + + )} + {trailingAction && ( + <> + + {trailingAction} + + )}
)