diff --git a/web/__mocks__/layout-recognize-form-field.js b/web/__mocks__/layout-recognize-form-field.js new file mode 100644 index 0000000000..90918796db --- /dev/null +++ b/web/__mocks__/layout-recognize-form-field.js @@ -0,0 +1,14 @@ +// Stub for @/components/layout-recognize-form-field: the real module drags in +// the app shell (llm hooks, routes, react-router), which jsdom cannot host. +// Only the ParseDocumentType values matter to constant/pipeline.tsx. +module.exports = { + __esModule: true, + ParseDocumentType: { + DeepDOC: 'DeepDOC', + PlainText: 'Plain Text', + Docling: 'Docling', + OpenDataLoader: 'OpenDataLoader', + TCADPParser: 'TCADP Parser', + }, + LayoutRecognizeFormField: () => null, +}; diff --git a/web/jest-esbuild-transformer.cjs b/web/jest-esbuild-transformer.cjs new file mode 100644 index 0000000000..5bf4e4ba65 --- /dev/null +++ b/web/jest-esbuild-transformer.cjs @@ -0,0 +1,44 @@ +// Jest transformer wrapping esbuild with the same options esbuild-jest was +// configured with, plus `define: { 'import.meta.env': '{}' }` — esbuild-jest@0.5 +// does not forward esbuild's `define` option, and source files read Vite's +// import.meta.env at module scope, which crashes under jest's cjs runtime. +// Files containing jest.mock still go through esbuild-jest for its babel-based +// mock hoisting. +const path = require('node:path'); +const esbuild = require('esbuild'); +const esbuildJest = require('esbuild-jest'); + +const esbuildJestTransformer = esbuildJest.createTransformer({ + sourcemap: true, + loaders: { '.ts': 'tsx' }, +}); + +const supportedLoaders = ['js', 'jsx', 'ts', 'tsx', 'json']; + +module.exports = { + createTransformer() { + return { + process(content, filename, config, opts) { + if (content.indexOf('ock(') >= 0) { + return esbuildJestTransformer.process(content, filename, config, opts); + } + const ext = path.extname(filename).slice(1); + const loader = ext === 'ts' ? 'tsx' : supportedLoaders.includes(ext) ? ext : 'text'; + const result = esbuild.transformSync(content, { + loader, + format: 'cjs', + target: 'es2018', + sourcemap: true, + sourcesContent: false, + sourcefile: filename, + define: { + 'import.meta.env': '{}', + // Vite's glob import; jestImportMetaGlob is set in jest-setup.ts + 'import.meta.glob': 'jestImportMetaGlob', + }, + }); + return { code: result.code, map: JSON.parse(result.map) }; + }, + }; + }, +}; diff --git a/web/jest-setup.ts b/web/jest-setup.ts index af3e171b27..4016e2403a 100644 --- a/web/jest-setup.ts +++ b/web/jest-setup.ts @@ -5,3 +5,20 @@ import React from 'react'; // while source files rely on the automatic runtime and never import React. // Expose React globally so rendering components in tests works. (globalThis as Record).React = React; + +// jsdom does not provide these, but react-router reads them at module scope +if (typeof globalThis.TextEncoder === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { TextDecoder, TextEncoder } = require('node:util'); + Object.assign(globalThis, { TextDecoder, TextEncoder }); +} + +// Vite's import.meta.glob is rewritten to this stub by jest-esbuild-transformer.cjs +(globalThis as Record).jestImportMetaGlob = () => ({}); + +// jsdom does not expose fetch; some modules call it at import time and +// handle the rejection themselves (e.g. utils/backend-runtime.ts) +if (typeof globalThis.fetch === 'undefined') { + (globalThis as Record).fetch = () => + Promise.reject(new Error('fetch is not available in tests')); +} diff --git a/web/jest.config.ts b/web/jest.config.ts index 962e75f53a..68add22c54 100644 --- a/web/jest.config.ts +++ b/web/jest.config.ts @@ -3,17 +3,14 @@ import type { Config } from 'jest'; const config: Config = { testEnvironment: 'jsdom', transform: { - '^.+\\.(ts|tsx|js|jsx)$': [ - 'esbuild-jest', - { - sourcemap: true, - loaders: { - '.ts': 'tsx', - }, - }, - ], + // Local wrapper around esbuild-jest that also defines import.meta.env; + // see jest-esbuild-transformer.cjs + '^.+\\.(ts|tsx|js|jsx)$': '/jest-esbuild-transformer.cjs', }, moduleNameMapper: { + // Drags the app shell (routes/react-router) into jsdom; see __mocks__ + '^@/components/layout-recognize-form-field$': + '/__mocks__/layout-recognize-form-field.js', '^@/(.*)$': '/src/$1', '^human-id$': '/__mocks__/human-id.js', '\\.(css|less|scss|sass)$': '/__mocks__/styleMock.js', diff --git a/web/src/pages/agent/canvas/node/agent-node.tsx b/web/src/pages/agent/canvas/node/agent-node.tsx index 66f316f0c1..fe102f73c7 100644 --- a/web/src/pages/agent/canvas/node/agent-node.tsx +++ b/web/src/pages/agent/canvas/node/agent-node.tsx @@ -71,8 +71,14 @@ function InnerAgentNode({ id={NodeHandleId.AgentBottom} left={180} visible={hasSubAgent(edges, id)} + nodeId={id} + /> + -
diff --git a/web/src/pages/agent/canvas/node/handle.tsx b/web/src/pages/agent/canvas/node/handle.tsx index 717fb3fa1c..00f3aad12d 100644 --- a/web/src/pages/agent/canvas/node/handle.tsx +++ b/web/src/pages/agent/canvas/node/handle.tsx @@ -2,7 +2,7 @@ import { useSetModalState } from '@/hooks/common-hooks'; import { cn } from '@/lib/utils'; import { Handle, HandleProps, Position } from '@xyflow/react'; import { Plus, ChevronDown } from 'lucide-react'; -import { useMemo } from 'react'; +import { useMemo, type MouseEvent } from 'react'; import { NodeHandleId } from '../../constant'; import { HandleContext } from '../../context'; import { useIsPipeline } from '../../hooks/use-is-pipeline'; @@ -105,24 +105,47 @@ export function BottomHandle({ id, left, visible, + nodeId, }: { id: NodeHandleId; left: number; visible: boolean; + nodeId: string; }) { + const toggleBottomCollapse = useGraphStore( + (state) => state.toggleBottomCollapse, + ); + const collapsed = useGraphStore( + (state) => state.collapsedBottomHandles[nodeId]?.includes(id) ?? false, + ); + + const handleClick = (e: MouseEvent) => { + e.stopPropagation(); + toggleBottomCollapse(nodeId, id); + }; + return ( - + ); } diff --git a/web/src/pages/agent/store.test.ts b/web/src/pages/agent/store.test.ts index 91496dd2ee..15acf13ced 100644 --- a/web/src/pages/agent/store.test.ts +++ b/web/src/pages/agent/store.test.ts @@ -159,3 +159,140 @@ describe('useGraphStore.deleteIterationNodeById', () => { expect(state.edges.map((edge) => edge.id)).toEqual(['branch-edge']); }); }); + +describe('useGraphStore.toggleBottomCollapse', () => { + const buildSubgraph = () => { + const nodes = [ + createNode('agent:0', Operator.Agent), + createNode('agent:1', Operator.Agent), + createNode('tool:0', Operator.Tool), + createNode('tool:1', Operator.Tool), + createNode('message:0', Operator.Message), + ]; + + const edges = [ + createEdge('e-sub', 'agent:0', 'agent:1', { + sourceHandle: NodeHandleId.AgentBottom, + targetHandle: NodeHandleId.AgentTop, + }), + createEdge('e-tool-0', 'agent:0', 'tool:0', { + sourceHandle: NodeHandleId.Tool, + }), + createEdge('e-tool-1', 'agent:1', 'tool:1', { + sourceHandle: NodeHandleId.Tool, + }), + createEdge('e-goto', 'agent:1', 'message:0', { + sourceHandle: NodeHandleId.AgentException, + }), + createEdge('e-main', 'agent:0', 'message:0', { + sourceHandle: NodeHandleId.Start, + }), + ]; + + return { nodes, edges }; + }; + + const hiddenIds = (items: { id: string; hidden?: boolean }[]) => + items + .filter((x) => x.hidden) + .map((x) => x.id) + .sort(); + + beforeEach(() => { + useGraphStore.setState({ + nodes: [], + edges: [], + selectedNodeIds: [], + selectedEdgeIds: [], + clickedNodeId: '', + clickedToolId: '', + collapsedBottomHandles: {}, + }); + }); + + it('hides the handle subtree and restores it on a second toggle', () => { + const { nodes, edges } = buildSubgraph(); + + useGraphStore.setState({ + nodes, + edges, + selectedNodeIds: ['agent:1'], + selectedEdgeIds: ['e-tool-1'], + }); + + useGraphStore + .getState() + .toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom); + + let state = useGraphStore.getState(); + expect(state.collapsedBottomHandles).toEqual({ + 'agent:0': [NodeHandleId.AgentBottom], + }); + // The sub-agent's whole subtree is hidden, including its tool node + expect(hiddenIds(state.nodes)).toEqual(['agent:1', 'tool:1']); + // The goto edge from a hidden node is hidden too, + // while the right-side main-flow edge stays visible + expect(hiddenIds(state.edges)).toEqual(['e-goto', 'e-sub', 'e-tool-1']); + expect(state.selectedNodeIds).toEqual([]); + expect(state.selectedEdgeIds).toEqual([]); + + useGraphStore + .getState() + .toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom); + + state = useGraphStore.getState(); + expect(state.collapsedBottomHandles).toEqual({}); + expect(state.nodes.every((x) => !x.hidden)).toBe(true); + expect(state.edges.every((x) => !x.hidden)).toBe(true); + }); + + it('collapses the two bottom handles independently', () => { + const { nodes, edges } = buildSubgraph(); + + useGraphStore.setState({ nodes, edges }); + + useGraphStore + .getState() + .toggleBottomCollapse('agent:0', NodeHandleId.Tool); + + let state = useGraphStore.getState(); + // Only the tool node is hidden; the sub-agent subtree stays visible + expect(hiddenIds(state.nodes)).toEqual(['tool:0']); + expect(hiddenIds(state.edges)).toEqual(['e-tool-0']); + + useGraphStore + .getState() + .toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom); + + state = useGraphStore.getState(); + expect(hiddenIds(state.nodes)).toEqual(['agent:1', 'tool:0', 'tool:1']); + expect(hiddenIds(state.edges)).toEqual([ + 'e-goto', + 'e-sub', + 'e-tool-0', + 'e-tool-1', + ]); + + // Expanding one handle keeps the other handle's subtree hidden + useGraphStore + .getState() + .toggleBottomCollapse('agent:0', NodeHandleId.Tool); + + state = useGraphStore.getState(); + expect(hiddenIds(state.nodes)).toEqual(['agent:1', 'tool:1']); + expect(hiddenIds(state.edges)).toEqual(['e-goto', 'e-sub', 'e-tool-1']); + }); + + it('does nothing when there is no bottom subtree', () => { + useGraphStore.setState({ + nodes: [createNode('agent:0', Operator.Agent)], + edges: [], + }); + + useGraphStore + .getState() + .toggleBottomCollapse('agent:0', NodeHandleId.AgentBottom); + + expect(useGraphStore.getState().collapsedBottomHandles).toEqual({}); + }); +}); diff --git a/web/src/pages/agent/store.ts b/web/src/pages/agent/store.ts index 9a2f82d58c..6a71706671 100644 --- a/web/src/pages/agent/store.ts +++ b/web/src/pages/agent/store.ts @@ -38,6 +38,12 @@ import { mapEdgeMouseEvent, } from './utils'; import { deleteAllDownstreamAgentsAndTool } from './utils/delete-node'; +import { + CollapsedBottomHandles, + filterBottomSubtreeNodeIds, + filterCollapsedHiddenIds, + toggleCollapsedBottomHandle, +} from './utils/filter-downstream-nodes'; type IAgentTool = IAgentForm['tools'][number]; @@ -142,6 +148,8 @@ export type RFState = { edges: Edge[]; selectedNodeIds: string[]; selectedEdgeIds: string[]; + // Collapsed bottom handles per node: nodeId -> collapsed handle ids + collapsedBottomHandles: CollapsedBottomHandles; clickedNodeId: string; // currently selected node clickedToolId: string; // currently selected tool id onNodesChange: OnNodesChange; @@ -200,6 +208,7 @@ export type RFState = { ) => void; // Deleting a condition of a classification operator will delete the related edge findAgentToolNodeById: (id: string | null) => string | undefined; selectNodeIds: (nodeIds: string[]) => void; + toggleBottomCollapse: (nodeId: string, handleId: NodeHandleId) => void; hasDownstreamNode: (nodeId: string) => boolean; hasUpstreamNode: (nodeId: string) => boolean; }; @@ -212,6 +221,7 @@ const useGraphStore = create()( edges: [] as Edge[], selectedNodeIds: [] as string[], selectedEdgeIds: [] as string[], + collapsedBottomHandles: {} as CollapsedBottomHandles, clickedNodeId: '', clickedToolId: '', onNodesChange: (changes) => { @@ -737,6 +747,47 @@ const useGraphStore = create()( })), ); }, + toggleBottomCollapse: (nodeId, handleId) => { + const { edges, collapsedBottomHandles } = get(); + if (filterBottomSubtreeNodeIds(edges, nodeId, handleId).length === 0) { + return; + } + const nextCollapsed = toggleCollapsedBottomHandle( + collapsedBottomHandles, + nodeId, + handleId, + ); + const { hiddenNodeIds, hiddenEdgeIds } = filterCollapsedHiddenIds( + edges, + nextCollapsed, + ); + + set((state) => { + state.collapsedBottomHandles = nextCollapsed; + for (const node of state.nodes) { + if (hiddenNodeIds.has(node.id)) { + node.hidden = true; + node.selected = false; + } else if (node.hidden) { + node.hidden = false; + } + } + for (const edge of state.edges) { + if (hiddenEdgeIds.has(edge.id)) { + edge.hidden = true; + edge.selected = false; + } else if (edge.hidden) { + edge.hidden = false; + } + } + state.selectedNodeIds = state.selectedNodeIds.filter( + (x) => !hiddenNodeIds.has(x), + ); + state.selectedEdgeIds = state.selectedEdgeIds.filter( + (x) => !hiddenEdgeIds.has(x), + ); + }); + }, hasDownstreamNode: (nodeId) => { const { edges } = get(); return edges.some((edge) => edge.source === nodeId); diff --git a/web/src/pages/agent/utils/filter-downstream-nodes.ts b/web/src/pages/agent/utils/filter-downstream-nodes.ts index f376caff5a..35ddc111f8 100644 --- a/web/src/pages/agent/utils/filter-downstream-nodes.ts +++ b/web/src/pages/agent/utils/filter-downstream-nodes.ts @@ -42,6 +42,68 @@ export function filterAllDownstreamAgentAndToolNodeIds( ); } +// Get the node ids hanging below one specific bottom handle of an agent node: +// the handle's own children, plus their whole sub-agent/tool subtree +export function filterBottomSubtreeNodeIds( + edges: Edge[], + nodeId: string, + handleId: string, +) { + return filterAllDownstreamNodeIds(edges, [nodeId], (edge: Edge) => + edge.source === nodeId + ? edge.sourceHandle === handleId + : edge.sourceHandle === NodeHandleId.AgentBottom || + edge.sourceHandle === NodeHandleId.Tool, + ); +} + +export type CollapsedBottomHandles = Record; + +// Toggle one bottom handle in the collapsed map, dropping the node entry +// when it has no collapsed handles left +export function toggleCollapsedBottomHandle( + collapsed: CollapsedBottomHandles, + nodeId: string, + handleId: string, +): CollapsedBottomHandles { + const handleIds = collapsed[nodeId] ?? []; + const nextHandleIds = handleIds.includes(handleId) + ? handleIds.filter((x) => x !== handleId) + : handleIds.concat(handleId); + + const next = { ...collapsed }; + if (nextHandleIds.length > 0) { + next[nodeId] = nextHandleIds; + } else { + delete next[nodeId]; + } + return next; +} + +// Union of the node/edge ids hidden by all collapsed subtrees, so expanding +// one handle keeps nodes hidden by another (e.g. nested) collapse hidden +export function filterCollapsedHiddenIds( + edges: Edge[], + collapsed: CollapsedBottomHandles, +) { + const hiddenNodeIds = new Set(); + Object.entries(collapsed).forEach(([nodeId, handleIds]) => { + handleIds.forEach((handleId) => { + filterBottomSubtreeNodeIds(edges, nodeId, handleId).forEach((x) => + hiddenNodeIds.add(x), + ); + }); + }); + + const hiddenEdgeIds = new Set( + edges + .filter((x) => hiddenNodeIds.has(x.source) || hiddenNodeIds.has(x.target)) + .map((x) => x.id), + ); + + return { hiddenNodeIds, hiddenEdgeIds }; +} + // Get all downstream agent operators of the current agent operator export function filterAllDownstreamAgentNodeIds( edges: Edge[],