diff --git a/app/src/canvas/tips.test.ts b/app/src/canvas/tips.test.ts new file mode 100644 index 00000000..52a541cc --- /dev/null +++ b/app/src/canvas/tips.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { selectCanvasTip } from "./tips"; +import type { CanvasTipContext } from "../types/CanvasTipContext"; + +const idleContext: CanvasTipContext = { + isRunning: false, + canGroup: false, + canUngroup: false, + canUndo: false, + hasUnconnectedNode: false, +}; + +describe("selectCanvasTip", () => { + it("prioritizes the contextual frame gesture", () => { + expect( + selectCanvasTip( + { ...idleContext, canGroup: true, canUndo: true }, + new Set(), + )?.id, + ).toBe("group-selection"); + }); + + it("never repeats a dismissed tip and suppresses tips during runs", () => { + expect( + selectCanvasTip( + { ...idleContext, canGroup: true, canUndo: true }, + new Set(["group-selection"]), + )?.id, + ).toBe("undo"); + expect( + selectCanvasTip({ ...idleContext, canGroup: true, isRunning: true }, new Set()), + ).toBeNull(); + }); +}); diff --git a/app/src/canvas/tips.ts b/app/src/canvas/tips.ts new file mode 100644 index 00000000..86ee2a76 --- /dev/null +++ b/app/src/canvas/tips.ts @@ -0,0 +1,41 @@ +import type { CanvasTipContext } from "../types/CanvasTipContext"; +import type { CanvasTipDefinition } from "../types/CanvasTipDefinition"; + +const canvasTips: readonly CanvasTipDefinition[] = [ + { + id: "group-selection", + text: "Frame this selection", + chord: "group", + when: (context) => context.canGroup, + }, + { + id: "ungroup-selection", + text: "Release this frame", + chord: "ungroup", + when: (context) => context.canUngroup, + }, + { + id: "undo", + text: "Undo your last canvas change", + chord: "undo", + when: (context) => context.canUndo, + }, + { + id: "connect-node", + text: "Drag from a handle to connect this node", + when: (context) => context.hasUnconnectedNode, + }, +]; + +/** The highest-priority eligible tip that has not been dismissed. */ +export function selectCanvasTip( + context: CanvasTipContext, + dismissed: ReadonlySet, +): CanvasTipDefinition | null { + if (context.isRunning) return null; + return ( + canvasTips.find( + (tip) => !dismissed.has(tip.id) && tip.when(context), + ) ?? null + ); +} diff --git a/app/src/components/AddNodesPanel.tsx b/app/src/components/AddNodesPanel.tsx index 56760929..180bb9cd 100644 --- a/app/src/components/AddNodesPanel.tsx +++ b/app/src/components/AddNodesPanel.tsx @@ -13,6 +13,7 @@ import { Pencil, Trash2, StickyNote, + Frame, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -55,6 +56,7 @@ const sectionIcons: Record = { "Control Flow": GitBranch, Validation: CheckCircle, Annotations: StickyNote, + Layout: Frame, }; interface PaletteItem { @@ -166,6 +168,16 @@ const nodeTemplates: { category: string; nodes: NodeTemplate[] }[] = [ }, ], }, + { + category: "Layout", + nodes: [ + { + type: "group", + label: "Group Frame", + description: "Organize related nodes", + }, + ], + }, ]; export default function AddNodesPanel({ diff --git a/app/src/components/WorkflowCanvas.tsx b/app/src/components/WorkflowCanvas.tsx index fa496772..8e02cb72 100644 --- a/app/src/components/WorkflowCanvas.tsx +++ b/app/src/components/WorkflowCanvas.tsx @@ -42,6 +42,7 @@ import NoteNode from "./nodes/NoteNode"; import CustomEdge from "./CustomEdge"; import { withNodeBoundary } from "./atoms/flow/NodeBoundary"; import { EmptyCanvasHint } from "./atoms/EmptyCanvasHint"; +import { CanvasTip } from "./atoms/CanvasTip"; import { RunMiniMap } from "./RunMiniMap"; import AddNodesPanel from "./AddNodesPanel"; import NodeModal from "./NodeModal"; @@ -71,7 +72,11 @@ import useWorkflowPolling from "../hooks/useWorkflowPolling"; import useWorkflowLiveUpdates from "../hooks/useWorkflowLiveUpdates"; import useRunCamera from "../hooks/useRunCamera"; import { useClipboardActions } from "../hooks/useClipboardActions"; -import { useCanvasKeyboardShortcuts } from "../hooks/useCanvasKeyboardShortcuts"; +import { + canvasShortcutLabel, + useCanvasKeyboardShortcuts, +} from "../hooks/useCanvasKeyboardShortcuts"; +import { useCanvasTip } from "../hooks/useCanvasTip"; import { useSpacePan } from "../hooks/useSpacePan"; import { preserveCanvasRuntimeState, @@ -85,6 +90,8 @@ import { import { canvasToWorkflow, workflowToCanvas } from "../adapters/workflowCanvas"; import { groupSelection, + adoptIntoFrame, + frameContainingNode, isFrameNode, selectedFrameIds, selectedIds, @@ -106,7 +113,7 @@ import { canvasInteractionProps } from "../utils/canvasInteraction"; import { nearestInDirection } from "../utils/directionalFocus"; import type { FocusDirection } from "../types/FocusDirection"; import type { FocusModeDirection } from "../types/FocusModeDirection"; -import { adjacentFocusModeNode } from "../utils/focusModeOrder"; +import { adjacentFocusModeNode, isEditableNode } from "../utils/focusModeOrder"; import { asPresetNodeType } from "../utils/nodePresets"; import { Wand2 } from "lucide-react"; import { useScopeContext } from "../hooks/useScopeContext"; @@ -771,15 +778,33 @@ export function WorkflowCanvas({ selectedNodeRef.current = null; }, []); - const onNodeDragStart = useCallback(() => { + const onNodeDragStart = useCallback((_: MouseEvent | TouchEvent, node: CanvasNode) => { // isDraggingNodeRef removed — auto-save skips during drag via isSwaggerRefreshing guard // Dragging a node under a moving camera is unusable; the camera yields. suspendFollow(); - }, [suspendFollow]); + // A constrained child cannot ever cross its frame boundary, so temporarily + // lift the constraint. Drag stop immediately reparents it or restores it. + if (node.parentId !== undefined) { + setNodes((current) => + current.map((item) => { + if (item.id !== node.id || item.extent === undefined) return item; + const dragged = { ...item }; + delete dragged.extent; + return dragged; + }), + ); + } + }, [setNodes, suspendFollow]); - const onNodeDragStop = useCallback(() => { - // Drag stop handler — no-op, auto-save resumes naturally - }, []); + const onNodeDragStop = useCallback((_: MouseEvent | TouchEvent, node: CanvasNode) => { + setNodes((current) => + adoptIntoFrame( + current, + node.id, + frameContainingNode(current, node.id), + ), + ); + }, [setNodes]); const onNodeDoubleClick = useCallback( (event: React.MouseEvent, node: Node) => { @@ -791,7 +816,9 @@ export function WorkflowCanvas({ ) { return; } - if (node.type !== "start" && node.type !== "end") { + // Frames and notes are canvas objects: a double-click renames them in + // place (see `GroupNode`/`NoteNode`), it does not open a step editor. + if (isEditableNode(node)) { setIsFocusMode(false); setModalNode(node); } @@ -802,7 +829,8 @@ export function WorkflowCanvas({ /** * Double-click the empty pane to frame the whole graph. The gesture is free * because `zoomOnDoubleClick` is off — and on a *node* it already opens the - * editor, which is a better use of it than framing that one node. + * editor (or renames a frame/note), which is a better use of it than framing + * that one node. * * Through `suspendFollow` like the zoom controls: a mid-run double-click * would otherwise fight the camera and snap straight back. @@ -886,7 +914,7 @@ export function WorkflowCanvas({ const openFocusMode = useCallback(() => { const node = selectedNodeRef.current ?? nodesRef.current.find((item) => item.selected); - if (!node || node.type === "group" || node.type === "note" || node.type === "start" || node.type === "end") { + if (!node || !isEditableNode(node)) { toast.info("Select an editable node to enter focus mode"); return; } @@ -1192,6 +1220,32 @@ export function WorkflowCanvas({ apply: applyHistoryEntry, }); + const canvasTipContext = useMemo(() => { + const selection = selectedIds(nodes); + const selected = nodes.filter((node) => selection.has(node.id)); + const hasUnconnectedNode = nodes.some( + (node) => + !isFrameNode(node) && + node.type !== "note" && + node.type !== "start" && + node.type !== "end" && + !edges.some((edge) => edge.source === node.id || edge.target === node.id), + ); + return { + isRunning, + canGroup: + selected.length >= 2 && + selected.every( + (node) => !isFrameNode(node) && node.parentId === undefined, + ), + canUngroup: selectedFrameIds(nodes, selection).size > 0, + canUndo, + hasUnconnectedNode, + }; + }, [canUndo, edges, isRunning, nodes]); + const { tip: canvasTip, dismiss: dismissCanvasTip } = + useCanvasTip(canvasTipContext); + useCanvasKeyboardShortcuts({ isEditorOverlayOpen, isRunning, @@ -1392,10 +1446,11 @@ export function WorkflowCanvas({ x: canvasBounds.left + canvasBounds.width / 2, y: canvasBounds.top + canvasBounds.height / 2, }); - setNodes((currentNodes) => [ - ...currentNodes, - createCanvasNode(template, position), - ]); + setNodes((currentNodes) => { + const node = createCanvasNode(template, position); + const next = [...currentNodes, node]; + return adoptIntoFrame(next, node.id, frameContainingNode(next, node.id)); + }); }, [setNodes], ); @@ -1621,6 +1676,10 @@ export function WorkflowCanvas({ onRedo={redo} canUndo={canUndo} canRedo={canRedo} + onGroup={groupSelected} + onUngroup={ungroupSelected} + canGroup={canvasTipContext.canGroup} + canUngroup={canvasTipContext.canUngroup} onHistory={() => setShowHistory(true)} onJsonEditor={() => { if (!isHydrated) { @@ -1674,6 +1733,16 @@ export function WorkflowCanvas({ workspaceId={scope.workspaceId ?? ""} /> + {canvasPrefs.tipsEnabled && canvasTip && ( + dismissCanvasTip(canvasTip.id)} + /> + )} + +
+ {tip.text} + {shortcut && ( + + {shortcut} + + )} + + + +
+ + ); +} diff --git a/app/src/components/nodes/GroupNode.test.tsx b/app/src/components/nodes/GroupNode.test.tsx new file mode 100644 index 00000000..2aa17a0b --- /dev/null +++ b/app/src/components/nodes/GroupNode.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ReactFlowProvider } from "@xyflow/react"; +import GroupNode from "./GroupNode"; + +const patchConfig = vi.fn(); +vi.mock("../../hooks/useNodeConfigPatch", () => ({ + useNodeConfigPatch: () => patchConfig, +})); + +function renderFrame(color?: string) { + return render( + + + , + ); +} + +describe("GroupNode tints", () => { + it("marks the stored tint and writes the one that is picked", async () => { + renderFrame("violet"); + + expect(screen.getByRole("button", { name: "violet" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await userEvent.click(screen.getByRole("button", { name: "rose" })); + expect(patchConfig).toHaveBeenCalledWith("color", "rose"); + }); + + // An unknown stored name would leave `--aw-group-tint` undefined, which + // collapses every color-mix that draws the frame. + it("falls back to slate when the stored tint is not one of ours", () => { + renderFrame("chartreuse"); + + expect(screen.getByRole("button", { name: "slate" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); +}); diff --git a/app/src/components/nodes/GroupNode.tsx b/app/src/components/nodes/GroupNode.tsx index 57868e6e..004730a9 100644 --- a/app/src/components/nodes/GroupNode.tsx +++ b/app/src/components/nodes/GroupNode.tsx @@ -3,7 +3,12 @@ import { NodeResizer, useReactFlow } from "@xyflow/react"; import type { NodeProps } from "@xyflow/react"; import { Frame, Ungroup } from "lucide-react"; import { IconButton } from "../atoms/IconButton"; -import { ungroupFrames } from "../../utils/canvasGroups"; +import { useNodeConfigPatch } from "../../hooks/useNodeConfigPatch"; +import { + FRAME_MIN_HEIGHT, + FRAME_MIN_WIDTH, + ungroupFrames, +} from "../../utils/canvasGroups"; import type { CanvasNode } from "../../types/CanvasNode"; import { GROUP_TINTS } from "../../constants/GroupTints"; import type { GroupTint } from "../../types/GroupTint"; @@ -24,10 +29,19 @@ import type { GroupTint } from "../../types/GroupTint"; */ function GroupNode({ id, data, selected }: NodeProps) { const { updateNodeData, setNodes } = useReactFlow(); + const patchConfig = useNodeConfigPatch(id); const [isRenaming, setIsRenaming] = useState(false); const inputRef = useRef(null); - const tint = GROUP_TINTS[(data.config?.color as GroupTint) ?? "slate"]; + // A stored name that is no longer a tint (renamed token, hand-edited file) + // would leave `--aw-group-tint` undefined and collapse every color-mix that + // draws this node, so it falls back rather than rendering an invisible frame. + const stored = data.config?.color; + const tintName: GroupTint = + typeof stored === "string" && stored in GROUP_TINTS + ? (stored as GroupTint) + : "slate"; + const tint = GROUP_TINTS[tintName]; const label = typeof data.label === "string" ? data.label : "Group"; return ( @@ -35,8 +49,8 @@ function GroupNode({ id, data, selected }: NodeProps) { ) { {selected && ( + + {(Object.keys(GROUP_TINTS) as GroupTint[]).map((name) => ( +