From 2718eefb8160311c0e2065ddd70af545be83b4f1 Mon Sep 17 00:00:00 2001 From: jiajia Date: Sun, 17 May 2026 22:58:53 +0800 Subject: [PATCH] Keep quick-add menus focused on likely next steps Node plus actions should offer a short continuation menu while handle dragging remains a full exploration surface. This records the product rule in a dedicated PRD and implements a static priority filter inside the existing connection menu instead of introducing a separate ranking layer. Constraint: Test server flow uses the connection drop menu for both drag-created nodes and node plus quick-add actions Rejected: Split quick-add into a separate menu component | duplicated option compatibility logic and increased drift risk Confidence: high Scope-risk: narrow Directive: Keep quick-add filtering downstream of compatibility filtering so suggestions never create invalid handle connections Tested: npm run test:run -- src/components/__tests__/ConnectionDropMenu.test.tsx src/components/__tests__/WorkflowCanvas.test.tsx Tested: npm run test:run Tested: npm run build Not-tested: npm run lint; current next lint script is not supported by the installed Next CLI --- prd-connection-quick-add-menu.md | 131 ++++++++++++++++++ src/components/ConnectionDropMenu.tsx | 75 +++++++++- src/components/WorkflowCanvas.tsx | 7 + .../__tests__/ConnectionDropMenu.test.tsx | 65 +++++++++ .../__tests__/WorkflowCanvas.test.tsx | 6 +- 5 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 prd-connection-quick-add-menu.md diff --git a/prd-connection-quick-add-menu.md b/prd-connection-quick-add-menu.md new file mode 100644 index 00000000..e71eac0b --- /dev/null +++ b/prd-connection-quick-add-menu.md @@ -0,0 +1,131 @@ +# PRD: Connection Menu Quick Add Prioritization + +Version: 1.0 +Last Updated: 2026-05-17 +Status: Implementation synced +Scope: `popiart-node-canvas` + +## 1. Summary + +The connection creation menu has two different user intents: + +- Dragging from a handle to blank canvas is an exploration action. It should show the complete list of compatible downstream or upstream node functions. +- Clicking a node-level `+` button is a quick continuation action. It should show a short, high-signal list: up to 5 generally important functions plus up to 2 functions that are strongly related to the source node. + +The same `ConnectionDropMenu` component may continue to render both flows, but it must know which mode opened it. + +## 2. Product Rule + +Core rule: + +> Drag shows the full compatible menu. Node `+` shows the best next-step menu. + +Detailed behavior: + +- Drag from an output handle keeps the complete compatible target-node list. +- Drag from an input handle keeps the complete compatible source-node list. +- Click a node `+` opens quick-add mode. +- Quick-add mode filters the already-compatible options. It must not suggest a node that the current handle cannot connect to. +- Quick-add mode returns at most 7 options: + - up to 5 common important options for the current handle type + - up to 2 source-node-related options +- If no source-node-related options are available after compatibility filtering and dedupe, quick-add shows only the common important options. +- Existing action items such as `Split Grid Now` and `Image Multi-Angle` are allowed in quick-add when they are prioritized and compatible. + +## 3. Common Important Options + +These priorities are intentionally static so the first iteration is predictable and easy to review. + +| Handle type | Common quick-add options | +| --- | --- | +| image | Generate Image, Generate Video, Output, Annotate, Image Compare | +| text | Generate Image, Generate Video, LLM Generate, Prompt Constructor, Generate Audio | +| video | Generate Video, Output, Video Trim, Frame Grab, Video Stitch | +| audio | Generate Video, Output, Video Stitch, Router, Switch | +| 3d | 3D Viewer, Output, Generate 3D, Router, Switch | +| easeCurve | Ease Curve, Router, Switch, Generate Video, Video Stitch | + +The final rendered list is the intersection of this priority list and the current compatibility list. + +## 4. Source-Node-Related Options + +Related options are also filtered by handle compatibility and deduped against the common list. + +| Source node | Related quick-add options | +| --- | --- | +| Image Input | Image Multi-Angle, Split Grid Now | +| Annotation | Image Multi-Angle, Split Grid Now | +| Generate Image | Image Multi-Angle, Split Grid Now | +| Prompt | Array, Prompt Constructor | +| Prompt Constructor | LLM Generate, Array | +| LLM Generate | Generate Image, Generate Video | +| Video Input | Video Trim, Frame Grab | +| Generate Video | Ease Curve, Frame Grab | +| Video Stitch | Video Trim, Ease Curve | +| Video Trim | Frame Grab, Video Stitch | +| Audio Input | Generate Video, Output | +| Generate Audio | Generate Video, Output | +| Generate 3D | 3D Viewer, Output | +| 3D Viewer | Generate Image, Output | + +Nodes without a related mapping fall back to common important options only. + +## 5. Implementation Notes + +- Add a menu mode: `drag` or `quickAdd`. +- Default mode is `drag` for backward compatibility. +- `WorkflowCanvas` should set `mode: "quickAdd"` and `sourceNodeType` when opening the menu from the node `+` button. +- Existing handle-drag code should keep `mode: "drag"` and preserve full lists. +- Filtering belongs in `ConnectionDropMenu` because it already owns handle-type option lists. +- Do not add new dependencies or dynamic ranking infrastructure for this iteration. + +## 6. Acceptance Criteria + +- Dragging from an image output still shows the complete image-compatible menu. +- Clicking `+` on an image source shows no more than 7 options. +- Clicking `+` on an image source includes common actions and the image-specific related actions when compatible. +- Clicking `+` for a source node with no related mapping shows common important options only. +- The selected quick-add option still creates and connects the new node exactly as before. +- Keyboard navigation and action item selection continue to work. + +## 7. Verification Plan + +- Unit test `ConnectionDropMenu` full drag behavior remains unchanged. +- Unit test quick-add image menu is capped and includes related image actions. +- Unit test quick-add without related mapping falls back to common options only. +- Component test `WorkflowCanvas` passes quick-add mode and source node type from the node `+` button. +- Run targeted tests for `ConnectionDropMenu` and `WorkflowCanvas`. +- Run full test suite and production build before publishing. + +## 8. Iteration Log + +- 2026-05-17: Synced requested behavior into PRD before implementation. +- 2026-05-17: First implementation target is a static priority filter with no new ranking service. +- 2026-05-17: Implemented `drag` and `quickAdd` modes in `ConnectionDropMenu`. +- 2026-05-17: Wired node `+` in `WorkflowCanvas` to pass `mode: "quickAdd"` and `sourceNodeType`. + +## 9. Implementation Record + +Changed files: + +- `src/components/ConnectionDropMenu.tsx` + - Adds static common and source-related priority maps. + - Keeps full compatible options in `drag` mode. + - Filters to common plus related options in `quickAdd` mode. +- `src/components/WorkflowCanvas.tsx` + - Marks handle-drag menus as `drag`. + - Marks node `+` menus as `quickAdd`. + - Passes the clicked source node type into the menu. +- `src/components/__tests__/ConnectionDropMenu.test.tsx` + - Covers full drag menu preservation. + - Covers image quick-add cap and related actions. + - Covers common-only fallback when there is no related mapping. +- `src/components/__tests__/WorkflowCanvas.test.tsx` + - Covers node `+` passing quick-add mode and source node type. + +Verification: + +- Passed targeted tests: `npm run test:run -- src/components/__tests__/ConnectionDropMenu.test.tsx src/components/__tests__/WorkflowCanvas.test.tsx` +- Passed full tests: `npm run test:run` (`108` files, `2157` tests) +- Passed production build: `npm run build` +- Lint script did not run because `next lint` is no longer recognized by the installed Next CLI and is treated as a project directory named `lint`. diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index 16ae7e6a..25233df0 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -6,6 +6,9 @@ import { useI18n } from "@/i18n"; // Actions are special menu items that trigger behavior instead of creating a node export type MenuAction = "splitGridImmediate" | "multiAngleImage"; +type ConnectionHandleType = "image" | "text" | "video" | "audio" | "3d" | "easeCurve"; +type ConnectionMenuMode = "drag" | "quickAdd"; +type QuickAddOptionType = NodeType | MenuAction; interface MenuOption { type: NodeType | MenuAction; @@ -703,10 +706,73 @@ const THREE_D_SOURCE_OPTIONS: MenuOption[] = [ }, ]; +const COMMON_QUICK_ADD_PRIORITIES: Record = { + image: ["nanoBanana", "generateVideo", "output", "annotation", "imageCompare"], + text: ["nanoBanana", "generateVideo", "llmGenerate", "promptConstructor", "generateAudio"], + video: ["generateVideo", "output", "videoTrim", "videoFrameGrab", "videoStitch"], + audio: ["generateVideo", "output", "videoStitch", "router", "switch"], + "3d": ["glbViewer", "output", "generate3d", "router", "switch"], + easeCurve: ["easeCurve", "router", "switch", "generateVideo", "videoStitch"], +}; + +const RELATED_QUICK_ADD_PRIORITIES: Partial> = { + imageInput: ["multiAngleImage", "splitGridImmediate"], + annotation: ["multiAngleImage", "splitGridImmediate"], + nanoBanana: ["multiAngleImage", "splitGridImmediate"], + prompt: ["array", "promptConstructor"], + promptConstructor: ["llmGenerate", "array"], + llmGenerate: ["nanoBanana", "generateVideo"], + videoInput: ["videoTrim", "videoFrameGrab"], + generateVideo: ["easeCurve", "videoFrameGrab"], + videoStitch: ["videoTrim", "easeCurve"], + videoTrim: ["videoFrameGrab", "videoStitch"], + audioInput: ["generateVideo", "output"], + generateAudio: ["generateVideo", "output"], + generate3d: ["glbViewer", "output"], + glbViewer: ["nanoBanana", "output"], +}; + +const pickPrioritizedOptions = ( + options: MenuOption[], + priority: QuickAddOptionType[], + limit: number, + excluded = new Set() +): MenuOption[] => { + const optionByType = new Map(options.map((option) => [option.type, option])); + const picked: MenuOption[] = []; + + for (const type of priority) { + if (excluded.has(type)) continue; + const option = optionByType.get(type); + if (!option) continue; + picked.push(option); + if (picked.length >= limit) break; + } + + return picked; +}; + +const getQuickAddOptions = ( + options: MenuOption[], + handleType: ConnectionHandleType | null, + sourceNodeType?: NodeType +): MenuOption[] => { + if (!handleType) return []; + + const common = pickPrioritizedOptions(options, COMMON_QUICK_ADD_PRIORITIES[handleType], 5); + const commonTypes = new Set(common.map((option) => option.type)); + const relatedPriority = sourceNodeType ? RELATED_QUICK_ADD_PRIORITIES[sourceNodeType] ?? [] : []; + const related = pickPrioritizedOptions(options, relatedPriority, 2, commonTypes); + + return [...common, ...related]; +}; + interface ConnectionDropMenuProps { position: { x: number; y: number }; - handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null; + handleType: ConnectionHandleType | null; connectionType: "source" | "target"; // source = dragging from output, target = dragging from input + mode?: ConnectionMenuMode; + sourceNodeType?: NodeType; onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void; onClose: () => void; } @@ -715,6 +781,8 @@ export function ConnectionDropMenu({ position, handleType, connectionType, + mode = "drag", + sourceNodeType, onSelect, onClose, }: ConnectionDropMenuProps) { @@ -785,7 +853,10 @@ export function ConnectionDropMenu({ } }, [handleType, connectionType]); - const options = getOptions(); + const allOptions = getOptions(); + const options = mode === "quickAdd" + ? getQuickAddOptions(allOptions, handleType, sourceNodeType) + : allOptions; // Handle keyboard navigation useEffect(() => { diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 10662ab2..1d01f52c 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -215,6 +215,8 @@ interface ConnectionDropState { flowPosition: { x: number; y: number }; handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null; connectionType: "source" | "target"; + mode?: "drag" | "quickAdd"; + sourceNodeType?: NodeType; sourceNodeId: string | null; sourceHandleId: string | null; } @@ -662,6 +664,8 @@ export function WorkflowCanvas() { }, handleType: source.handleType, connectionType: "source", + mode: "quickAdd", + sourceNodeType: node.type as NodeType, sourceNodeId: node.id, sourceHandleId: source.handleId, }); @@ -1168,6 +1172,7 @@ export function WorkflowCanvas() { flowPosition: flowPos, handleType: fromHandleType, connectionType: isFromSource ? "source" : "target", + mode: "drag", sourceNodeId: connectionState.fromNode.id, sourceHandleId: fromHandleId, }); @@ -2663,6 +2668,8 @@ export function WorkflowCanvas() { position={connectionDrop.position} handleType={connectionDrop.handleType} connectionType={connectionDrop.connectionType} + mode={connectionDrop.mode} + sourceNodeType={connectionDrop.sourceNodeType} onSelect={handleMenuSelect} onClose={handleCloseDropMenu} /> diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx index ada66a21..f8c3f905 100644 --- a/src/components/__tests__/ConnectionDropMenu.test.tsx +++ b/src/components/__tests__/ConnectionDropMenu.test.tsx @@ -103,6 +103,71 @@ describe("ConnectionDropMenu", () => { expect(screen.queryByText("Generate Image")).not.toBeInTheDocument(); expect(screen.queryByText("Generate Video")).not.toBeInTheDocument(); }); + + it("should keep the full drag menu even when a source node type is provided", () => { + render( + + ); + + expect(screen.getByText("Split Grid Node")).toBeInTheDocument(); + expect(screen.getByText("Output Gallery")).toBeInTheDocument(); + expect(screen.getByText("Router")).toBeInTheDocument(); + expect(screen.getByText("Switch")).toBeInTheDocument(); + }); + }); + + describe("Quick Add Prioritization", () => { + it("should cap image quick-add to common options plus image-related actions", () => { + render( + + ); + + expect(screen.getAllByRole("button")).toHaveLength(7); + expect(screen.getByText("Generate Image")).toBeInTheDocument(); + expect(screen.getByText("Generate Video")).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + expect(screen.getByText("Annotate")).toBeInTheDocument(); + expect(screen.getByText("Image Compare")).toBeInTheDocument(); + expect(screen.getByText("Image Multi-Angle")).toBeInTheDocument(); + expect(screen.getByText("Split Grid Now")).toBeInTheDocument(); + expect(screen.queryByText("Split Grid Node")).not.toBeInTheDocument(); + expect(screen.queryByText("Output Gallery")).not.toBeInTheDocument(); + expect(screen.queryByText("Router")).not.toBeInTheDocument(); + expect(screen.queryByText("Switch")).not.toBeInTheDocument(); + }); + + it("should show only common important options when there is no related source mapping", () => { + render( + + ); + + expect(screen.getAllByRole("button")).toHaveLength(5); + expect(screen.getByText("Generate Image")).toBeInTheDocument(); + expect(screen.getByText("Generate Video")).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + expect(screen.getByText("Annotate")).toBeInTheDocument(); + expect(screen.getByText("Image Compare")).toBeInTheDocument(); + expect(screen.queryByText("Image Multi-Angle")).not.toBeInTheDocument(); + expect(screen.queryByText("Split Grid Now")).not.toBeInTheDocument(); + }); }); describe("Node Type Filtering - Target Connection (from input handle)", () => { diff --git a/src/components/__tests__/WorkflowCanvas.test.tsx b/src/components/__tests__/WorkflowCanvas.test.tsx index 55957061..ec24fd6b 100644 --- a/src/components/__tests__/WorkflowCanvas.test.tsx +++ b/src/components/__tests__/WorkflowCanvas.test.tsx @@ -58,11 +58,13 @@ vi.mock("@xyflow/react", async () => { // Mock the child components that aren't being tested vi.mock("@/components/ConnectionDropMenu", () => ({ - ConnectionDropMenu: ({ handleType, connectionType, onSelect }: any) => ( + ConnectionDropMenu: ({ handleType, connectionType, mode, sourceNodeType, onSelect }: any) => (