Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/src/canvas/tips.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
41 changes: 41 additions & 0 deletions app/src/canvas/tips.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
): CanvasTipDefinition | null {
if (context.isRunning) return null;
return (
canvasTips.find(
(tip) => !dismissed.has(tip.id) && tip.when(context),
) ?? null
);
}
12 changes: 12 additions & 0 deletions app/src/components/AddNodesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Pencil,
Trash2,
StickyNote,
Frame,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
Expand Down Expand Up @@ -55,6 +56,7 @@ const sectionIcons: Record<string, LucideIcon> = {
"Control Flow": GitBranch,
Validation: CheckCircle,
Annotations: StickyNote,
Layout: Frame,
};

interface PaletteItem {
Expand Down Expand Up @@ -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({
Expand Down
97 changes: 83 additions & 14 deletions app/src/components/WorkflowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -85,6 +90,8 @@ import {
import { canvasToWorkflow, workflowToCanvas } from "../adapters/workflowCanvas";
import {
groupSelection,
adoptIntoFrame,
frameContainingNode,
isFrameNode,
selectedFrameIds,
selectedIds,
Expand All @@ -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";
Expand Down Expand Up @@ -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<WorkflowCanvasNodeData>) => {
Expand All @@ -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);
}
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1674,6 +1733,16 @@ export function WorkflowCanvas({
workspaceId={scope.workspaceId ?? ""}
/>

{canvasPrefs.tipsEnabled && canvasTip && (
<CanvasTip
tip={canvasTip}
shortcut={
canvasTip.chord ? canvasShortcutLabel(canvasTip.chord) : null
}
onDismiss={() => dismissCanvasTip(canvasTip.id)}
/>
)}

<CommandPalette
open={isCommandPaletteOpen}
commands={commands}
Expand Down
32 changes: 32 additions & 0 deletions app/src/components/atoms/CanvasTip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { X } from "lucide-react";
import { IconButton } from "./IconButton";
import type { CanvasTipProps } from "../../types/CanvasTipProps";

/** A single, non-blocking canvas hint. */
export function CanvasTip({ tip, shortcut, onDismiss }: CanvasTipProps) {
return (
<div className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
<div
role="status"
aria-live="polite"
className="pointer-events-auto flex items-center gap-2 rounded-sm border border-border bg-surface-raised px-2 py-1.5 text-xs text-text-secondary shadow-node dark:border-border-dark dark:bg-surface-dark-raised dark:text-text-secondary-dark"
>
<span>{tip.text}</span>
{shortcut && (
<kbd className="rounded-sm border border-border bg-surface px-1 py-0.5 font-mono text-[10px] text-text-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-primary-dark">
{shortcut}
</kbd>
)}
<IconButton
size="xs"
variant="ghost"
tooltip="Dismiss tip"
aria-label="Dismiss canvas tip"
onClick={onDismiss}
>
<X className="h-3.5 w-3.5" />
</IconButton>
</div>
</div>
);
}
56 changes: 56 additions & 0 deletions app/src/components/nodes/GroupNode.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ReactFlowProvider>
<GroupNode
id="frame-1"
type="group"
selected
dragging={false}
draggable
selectable
deletable
zIndex={0}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
data={{ label: "Checkout", config: color === undefined ? {} : { color } }}
/>
</ReactFlowProvider>,
);
}

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",
);
});
});
Loading
Loading