Browse Source

Merge branch 'develop' into ai-flow-builder

handoff-20260429-1057
shrimbly 6 months ago
parent
commit
c4f8b2c058
  1. 16
      .github/workflows/block-master-prs.yml
  2. 136
      package-lock.json
  3. 2
      package.json
  4. 54
      src/app/api/open-directory/route.ts
  5. 46
      src/components/Header.tsx
  6. 75
      src/components/MultiSelectToolbar.tsx
  7. 43
      src/components/edges/EditableEdge.tsx
  8. 33
      src/components/edges/ReferenceEdge.tsx
  9. 88
      src/components/modals/PromptEditorModal.tsx

16
.github/workflows/block-master-prs.yml

@ -0,0 +1,16 @@
name: Enforce Develop Branch
on:
pull_request:
branches:
- master # Triggers only on PRs targeting master
jobs:
guard-master:
runs-on: ubuntu-latest
steps:
- name: Check base branch
# logic: If the PR is NOT coming from a release or hotfix branch, block it.
if: "!startsWith(github.head_ref, 'release/') && !startsWith(github.head_ref, 'hotfix/')"
run: |
echo "::error::⛔ WRONG BRANCH: You are trying to merge into 'master'. Please change the base branch of this PR to 'develop'."
exit 1

136
package-lock.json

@ -12,6 +12,7 @@
"@tailwindcss/postcss": "^4.1.17",
"@xyflow/react": "^12.9.3",
"autoprefixer": "^10.4.22",
"jszip": "^3.10.1",
"konva": "^10.0.12",
"next": "^16.0.6",
"postcss": "^8.5.6",
@ -24,6 +25,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -2496,6 +2498,7 @@
"@types/d3-selection": "*"
}
},
<<<<<<< HEAD
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -2509,6 +2512,17 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
=======
"node_modules/@types/jszip": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jszip": "*"
}
>>>>>>> develop
},
"node_modules/@types/node": {
"version": "24.10.1",
@ -3043,11 +3057,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
<<<<<<< HEAD
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
=======
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
>>>>>>> develop
"license": "MIT"
},
"node_modules/cross-spawn": {
@ -3692,6 +3713,7 @@
"node": ">= 14"
}
},
<<<<<<< HEAD
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -3714,6 +3736,19 @@
"engines": {
"node": ">=8"
}
=======
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
>>>>>>> develop
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
@ -3724,11 +3759,18 @@
"node": ">=8"
}
},
<<<<<<< HEAD
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
=======
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
>>>>>>> develop
"license": "MIT"
},
"node_modules/isexe": {
@ -3905,6 +3947,7 @@
"bignumber.js": "^9.0.0"
}
},
<<<<<<< HEAD
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -3916,6 +3959,18 @@
},
"engines": {
"node": ">=6"
=======
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
>>>>>>> develop
}
},
"node_modules/jwa": {
@ -3958,6 +4013,18 @@
}
],
"license": "MIT"
<<<<<<< HEAD
=======
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
>>>>>>> develop
},
"node_modules/lightningcss": {
"version": "1.30.2",
@ -4477,6 +4544,7 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
<<<<<<< HEAD
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
@ -4489,6 +4557,13 @@
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
=======
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
>>>>>>> develop
},
"node_modules/path-key": {
"version": "3.1.1",
@ -4575,6 +4650,7 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
<<<<<<< HEAD
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@ -4625,6 +4701,13 @@
"engines": {
"node": ">=6"
}
=======
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
>>>>>>> develop
},
"node_modules/react": {
"version": "19.2.0",
@ -4701,6 +4784,7 @@
"react": "^19.2.0"
}
},
<<<<<<< HEAD
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -4734,6 +4818,28 @@
"engines": {
"node": ">=0.10.0"
}
=======
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
>>>>>>> develop
},
"node_modules/rimraf": {
"version": "5.0.10",
@ -4858,6 +4964,12 @@
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@ -4952,6 +5064,7 @@
"node": ">=0.10.0"
}
},
<<<<<<< HEAD
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -4964,6 +5077,21 @@
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
=======
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
>>>>>>> develop
"license": "MIT"
},
"node_modules/string-width": {
@ -5293,6 +5421,7 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
<<<<<<< HEAD
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@ -5458,6 +5587,13 @@
"engines": {
"node": ">=18"
}
=======
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
>>>>>>> develop
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",

2
package.json

@ -16,6 +16,7 @@
"@tailwindcss/postcss": "^4.1.17",
"@xyflow/react": "^12.9.3",
"autoprefixer": "^10.4.22",
"jszip": "^3.10.1",
"konva": "^10.0.12",
"next": "^16.0.6",
"postcss": "^8.5.6",
@ -28,6 +29,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

54
src/app/api/open-directory/route.ts

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
import os from "os";
const execFileAsync = promisify(execFile);
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { path } = body;
if (!path) {
return NextResponse.json(
{ success: false, error: "Path is required" },
{ status: 400 }
);
}
let command = "";
let args: string[] = [];
const platform = os.platform();
switch (platform) {
case "darwin":
command = "open";
args = [path];
break;
case "win32":
command = "explorer";
args = [path];
break;
case "linux":
command = "xdg-open";
args = [path];
break;
default:
// Fallback for other Unix-like systems
command = "xdg-open";
args = [path];
}
await execFileAsync(command, args);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to open directory:", error);
return NextResponse.json(
{ success: false, error: "Failed to open directory" },
{ status: 500 }
);
}
}

46
src/components/Header.tsx

@ -82,6 +82,31 @@ export function Header() {
}, 50);
};
const handleOpenDirectory = async () => {
if (!saveDirectoryPath) return;
try {
const response = await fetch("/api/open-directory", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ path: saveDirectoryPath }),
});
const result = await response.json();
if (!response.ok || !result.success) {
console.error("Failed to open directory:", result.error);
alert(`Failed to open project folder: ${result.error || "Unknown error"}`);
return;
}
} catch (error) {
console.error("Failed to open directory:", error);
alert("Failed to open project folder. Please try again.");
}
};
return (
<>
<ProjectSetupModal
@ -133,6 +158,27 @@ export function Header() {
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-red-500" />
)}
</button>
{saveDirectoryPath && (
<button
onClick={handleOpenDirectory}
className="p-1 text-neutral-400 hover:text-neutral-200 transition-colors"
title="Open Project Folder"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
/>
</svg>
</button>
)}
<button
onClick={handleOpenSettings}
className="p-1 text-neutral-400 hover:text-neutral-200 transition-colors"

75
src/components/MultiSelectToolbar.tsx

@ -2,7 +2,14 @@
import { useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import JSZip from "jszip";
import type {
ImageInputNodeData,
AnnotationNodeData,
NanoBananaNodeData,
OutputNodeData,
} from "@/types";
const STACK_GAP = 20;
@ -156,6 +163,58 @@ export function MultiSelectToolbar() {
removeNodesFromGroup(nodeIds);
};
const handleDownloadImages = useCallback(async () => {
// Extract images from selected nodes based on node type
const images: { data: string; name: string }[] = [];
selectedNodes.forEach((node, index) => {
let imageData: string | null = null;
switch (node.type) {
case "imageInput":
imageData = (node.data as ImageInputNodeData).image;
break;
case "annotation":
imageData = (node.data as AnnotationNodeData).outputImage;
break;
case "nanoBanana":
imageData = (node.data as NanoBananaNodeData).outputImage;
break;
case "output":
imageData = (node.data as OutputNodeData).image;
break;
}
if (imageData) {
images.push({
data: imageData,
name: `image-${index + 1}.png`,
});
}
});
if (images.length === 0) return;
// Create ZIP file
const zip = new JSZip();
images.forEach(({ data, name }) => {
// Remove data URL prefix to get raw base64
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
zip.file(name, base64Data, { base64: true });
});
// Generate and download
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `images-${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [selectedNodes]);
if (!toolbarPosition || selectedNodes.length < 2) return null;
return (
@ -220,6 +279,20 @@ export function MultiSelectToolbar() {
</svg>
</button>
)}
{/* Separator */}
<div className="w-px h-4 bg-neutral-600 mx-0.5" />
{/* Download images button */}
<button
onClick={handleDownloadImages}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title="Download images as ZIP"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
</div>
);
}

43
src/components/edges/EditableEdge.tsx

@ -16,12 +16,12 @@ interface EdgeData extends WorkflowEdgeData {
offsetY?: number;
}
// Colors for different connection types
// Colors for different connection types (dimmed for softer appearance)
const EDGE_COLORS = {
image: "#10b981", // Green for image connections
prompt: "#3b82f6", // Blue for prompt connections
default: "#94a3b8", // Gray for unknown
pause: "#f97316", // Orange for paused edges
image: "#0d9668", // Dimmed green for image connections
prompt: "#2563eb", // Dimmed blue for prompt connections
default: "#64748b", // Dimmed gray for unknown
pause: "#ea580c", // Dimmed orange for paused edges
};
export function EditableEdge({
@ -38,6 +38,7 @@ export function EditableEdge({
data,
sourceHandleId,
targetHandleId,
source,
target,
}: EdgeProps) {
const { setEdges } = useReactFlow();
@ -46,6 +47,15 @@ export function EditableEdge({
const nodes = useWorkflowStore((state) => state.nodes);
const [isDragging, setIsDragging] = useState(false);
// Check if any node is selected and if this edge is connected to it
const isConnectedToSelection = useMemo(() => {
const selectedNodes = nodes.filter((n) => n.selected);
if (selectedNodes.length === 0) return false; // No selection, show all dimmed
// Check if this edge connects to any selected node
return selectedNodes.some((n) => n.id === source || n.id === target);
}, [nodes, source, target]);
const edgeData = data as EdgeData | undefined;
const offsetX = edgeData?.offsetX ?? 0;
const offsetY = edgeData?.offsetY ?? 0;
@ -71,8 +81,12 @@ export function EditableEdge({
return EDGE_COLORS.default;
}, [hasPause, sourceHandleId, targetHandleId]);
// Generate a unique gradient ID for this edge
const gradientId = `pulse-gradient-${id}`;
// Generate a unique gradient ID based on edge color and selection state
const gradientId = useMemo(() => {
const colorKey = hasPause ? "pause" : (sourceHandleId || targetHandleId || "default");
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return `edge-gradient-${colorKey}-${selectionKey}-${id}`;
}, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection, id]);
// Calculate the path based on edge style
const [edgePath, labelX, labelY] = useMemo(() => {
@ -167,13 +181,22 @@ export function EditableEdge({
return (
<>
{/* SVG gradient definition for bright-dim-bright effect */}
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={edgeColor} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
<stop offset="50%" stopColor={edgeColor} stopOpacity={isConnectedToSelection ? 0.55 : 0.1} />
<stop offset="100%" stopColor={edgeColor} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
</linearGradient>
</defs>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
stroke: edgeColor,
stroke: `url(#${gradientId})`,
strokeWidth: 3,
strokeLinecap: "round",
strokeLinejoin: "round",
@ -187,7 +210,7 @@ export function EditableEdge({
<path
d={edgePath}
fill="none"
stroke={edgeColor}
stroke={`url(#${gradientId})`}
strokeWidth={10}
strokeLinecap="round"
strokeLinejoin="round"
@ -200,7 +223,7 @@ export function EditableEdge({
<path
d={edgePath}
fill="none"
stroke={edgeColor}
stroke={`url(#${gradientId})`}
strokeWidth={5}
strokeLinecap="round"
strokeLinejoin="round"

33
src/components/edges/ReferenceEdge.tsx

@ -8,8 +8,8 @@ import {
} from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
// Grey color for reference connections
const REFERENCE_COLOR = "#6b7280";
// Grey color for reference connections (dimmed for softer appearance)
const REFERENCE_COLOR = "#52525b";
export function ReferenceEdge({
id,
@ -21,8 +21,20 @@ export function ReferenceEdge({
targetPosition,
style,
markerEnd,
source,
target,
}: EdgeProps) {
const edgeStyle = useWorkflowStore((state) => state.edgeStyle);
const nodes = useWorkflowStore((state) => state.nodes);
// Check if any node is selected and if this edge is connected to it
const isConnectedToSelection = useMemo(() => {
const selectedNodes = nodes.filter((n) => n.selected);
if (selectedNodes.length === 0) return false; // No selection, show all dimmed
// Check if this edge connects to any selected node
return selectedNodes.some((n) => n.id === source || n.id === target);
}, [nodes, source, target]);
// Calculate the path - always use curved for reference edges for softer look
const [edgePath] = useMemo(() => {
@ -37,15 +49,30 @@ export function ReferenceEdge({
});
}, [sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition]);
// Generate a unique gradient ID based on selection state
const gradientId = useMemo(() => {
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return `reference-gradient-${selectionKey}-${id}`;
}, [isConnectedToSelection, id]);
return (
<>
{/* SVG gradient definition for bright-dim-bright effect */}
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={REFERENCE_COLOR} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
<stop offset="50%" stopColor={REFERENCE_COLOR} stopOpacity={isConnectedToSelection ? 0.55 : 0.1} />
<stop offset="100%" stopColor={REFERENCE_COLOR} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
</linearGradient>
</defs>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
stroke: REFERENCE_COLOR,
stroke: `url(#${gradientId})`,
strokeWidth: 2,
strokeDasharray: "6 4",
strokeLinecap: "round",

88
src/components/modals/PromptEditorModal.tsx

@ -20,6 +20,7 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
onClose,
}) => {
const [prompt, setPrompt] = useState(initialPrompt);
const [showConfirmation, setShowConfirmation] = useState(false);
const [fontSize, setFontSize] = useState(() => {
// Load font size from localStorage on mount
if (typeof window !== 'undefined') {
@ -46,11 +47,23 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
}
}, [fontSize]);
// Track unsaved changes
const hasUnsavedChanges = prompt !== initialPrompt;
// Handle close attempt - show confirmation if there are unsaved changes
const handleAttemptClose = useCallback(() => {
if (hasUnsavedChanges) {
setShowConfirmation(true);
} else {
onClose();
}
}, [hasUnsavedChanges, onClose]);
// Handle Escape key to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
handleAttemptClose();
}
};
@ -61,7 +74,7 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
}, [isOpen, handleAttemptClose]);
const handleSubmit = useCallback(() => {
onSubmit(prompt);
@ -76,10 +89,24 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close if clicking the backdrop itself, not the dialog content
if (e.target === e.currentTarget) {
onClose();
handleAttemptClose();
}
},
[handleAttemptClose]
);
const handleDismissConfirmation = useCallback(() => {
setShowConfirmation(false);
}, []);
const handleConfirmationBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only dismiss if clicking the backdrop itself, not the confirmation dialog
if (e.target === e.currentTarget) {
handleDismissConfirmation();
}
},
[onClose]
[handleDismissConfirmation]
);
if (!isOpen) return null;
@ -89,7 +116,7 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50"
onClick={handleBackdropClick}
>
<div className="bg-neutral-800 border border-neutral-700 rounded-lg shadow-2xl w-full max-w-3xl h-[85vh] flex flex-col mx-4">
<div className="relative bg-neutral-800 border border-neutral-700 rounded-lg shadow-2xl w-full max-w-3xl h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="px-6 pt-6 pb-4">
<h2 className="text-xl font-semibold text-neutral-100">
@ -129,7 +156,7 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
{/* Footer with buttons */}
<div className="flex justify-end gap-3 px-6 pb-6">
<button
onClick={onClose}
onClick={handleAttemptClose}
className="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-neutral-500"
>
Cancel
@ -141,6 +168,55 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
Submit
</button>
</div>
{/* Confirmation overlay */}
{showConfirmation && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/60 rounded-lg"
onClick={handleConfirmationBackdropClick}
>
<div className="relative bg-neutral-800 border border-neutral-600 rounded-lg p-6 mx-4 max-w-sm shadow-xl">
{/* Close button */}
<button
onClick={handleDismissConfirmation}
className="absolute top-3 right-3 text-neutral-400 hover:text-neutral-200 transition-colors focus:outline-none"
aria-label="Close"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<p className="text-neutral-100 text-center mb-6">
You have unsaved changes
</p>
<div className="flex justify-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-neutral-500"
>
Discard
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-blue-400"
>
Submit
</button>
</div>
</div>
</div>
)}
</div>
</div>
);

Loading…
Cancel
Save