Browse Source

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
feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
2718eefb81
  1. 131
      prd-connection-quick-add-menu.md
  2. 75
      src/components/ConnectionDropMenu.tsx
  3. 7
      src/components/WorkflowCanvas.tsx
  4. 65
      src/components/__tests__/ConnectionDropMenu.test.tsx
  5. 6
      src/components/__tests__/WorkflowCanvas.test.tsx

131
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`.

75
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<ConnectionHandleType, QuickAddOptionType[]> = {
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<Record<NodeType, QuickAddOptionType[]>> = {
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<QuickAddOptionType>()
): 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(() => {

7
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}
/>

65
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(
<ConnectionDropMenu
{...defaultProps}
handleType="image"
connectionType="source"
mode="drag"
sourceNodeType="imageInput"
/>
);
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(
<ConnectionDropMenu
{...defaultProps}
handleType="image"
connectionType="source"
mode="quickAdd"
sourceNodeType="imageInput"
/>
);
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(
<ConnectionDropMenu
{...defaultProps}
handleType="image"
connectionType="source"
mode="quickAdd"
sourceNodeType="router"
/>
);
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)", () => {

6
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) => (
<div
data-testid="connection-drop-menu"
data-handle-type={handleType}
data-connection-type={connectionType}
data-mode={mode}
data-source-node-type={sourceNodeType}
>
<button onClick={() => onSelect({ type: "nanoBanana", isAction: false })}>
Create Generate Image
@ -281,6 +283,8 @@ describe("WorkflowCanvas", () => {
const menu = await screen.findByTestId("connection-drop-menu");
expect(menu).toHaveAttribute("data-handle-type", "image");
expect(menu).toHaveAttribute("data-connection-type", "source");
expect(menu).toHaveAttribute("data-mode", "quickAdd");
expect(menu).toHaveAttribute("data-source-node-type", "imageInput");
fireEvent.click(screen.getByText("Create Generate Image"));

Loading…
Cancel
Save