From f2571e96dba95fac2918c14cd24491ad07c67bf7 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 21:12:49 +1300 Subject: [PATCH 1/5] fix(quick-001): fix type signatures and store aggregation for multi-image dynamicInputs - Update dynamicInputs type from Record to Record in 4 locations - Fix getConnectedInputs to aggregate same-key values into arrays instead of overwriting - Fix dynamicInputs.prompt access to handle array case - Fix hasPrompt check in POST handler to handle array-typed prompt - Update processedDynamicInputs type in both Replicate and fal blocks Co-Authored-By: Claude Opus 4.5 --- src/app/api/generate/route.ts | 16 ++++++++++------ src/lib/providers/types.ts | 2 +- src/store/workflowStore.ts | 19 +++++++++++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 9e2337c2..fbc11af3 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -33,7 +33,7 @@ interface MultiProviderGenerateRequest extends GenerateRequest { selectedModel?: SelectedModel; parameters?: Record; /** Dynamic inputs from schema-based connections (e.g., image_url, tail_image_url, prompt) */ - dynamicInputs?: Record; + dynamicInputs?: Record; } /** @@ -1273,7 +1273,11 @@ export async function POST(request: NextRequest) { // - Provided via dynamicInputs // - Images are provided (image-to-video/image-to-image models) // - Dynamic inputs contain image frames (first_frame, last_frame, etc.) - const hasPrompt = prompt || (dynamicInputs && dynamicInputs.prompt); + const hasPrompt = prompt || (dynamicInputs && ( + typeof dynamicInputs.prompt === 'string' + ? dynamicInputs.prompt + : Array.isArray(dynamicInputs.prompt) && dynamicInputs.prompt.length > 0 + )); const hasImages = (images && images.length > 0); const hasImageInputs = dynamicInputs && Object.keys(dynamicInputs).some(key => key.includes('frame') || key.includes('image') @@ -1311,14 +1315,14 @@ export async function POST(request: NextRequest) { const processedImages: string[] = images ? [...images] : []; // Process dynamicInputs: filter empty values, keep Data URIs - let processedDynamicInputs: Record | undefined = undefined; + let processedDynamicInputs: Record | undefined = undefined; if (dynamicInputs) { processedDynamicInputs = {}; for (const key of Object.keys(dynamicInputs)) { const value = dynamicInputs[key]; - // Skip empty/null/undefined values + // Skip empty/null/undefined values (arrays pass through) if (value === null || value === undefined || value === '') { continue; } @@ -1395,14 +1399,14 @@ export async function POST(request: NextRequest) { const processedImages: string[] = images ? [...images] : []; // Process dynamicInputs: filter empty values - let processedDynamicInputs: Record | undefined = undefined; + let processedDynamicInputs: Record | undefined = undefined; if (dynamicInputs) { processedDynamicInputs = {}; for (const key of Object.keys(dynamicInputs)) { const value = dynamicInputs[key]; - // Skip empty/null/undefined values + // Skip empty/null/undefined values (arrays pass through) if (value === null || value === undefined || value === '') { continue; } diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index ff0f67a6..2280da3d 100644 --- a/src/lib/providers/types.ts +++ b/src/lib/providers/types.ts @@ -87,7 +87,7 @@ export interface GenerationInput { /** Model-specific parameters (varies by provider/model) */ parameters?: Record; /** Dynamic inputs mapped from schema (e.g., { "image_url": "data:...", "tail_image_url": "data:..." }) */ - dynamicInputs?: Record; + dynamicInputs?: Record; } /** diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 7a959909..5f5f736a 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -132,7 +132,7 @@ interface WorkflowStore { // Helpers getNodeById: (id: string) => WorkflowNode | undefined; - getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; text: string | null; dynamicInputs: Record }; + getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; text: string | null; dynamicInputs: Record }; validateWorkflow: () => { valid: boolean; errors: string[] }; // Global Image History @@ -707,7 +707,7 @@ export const useWorkflowStore = create((set, get) => ({ const images: string[] = []; const videos: string[] = []; let text: string | null = null; - const dynamicInputs: Record = {}; + const dynamicInputs: Record = {}; // Get the target node to check for inputSchema const targetNode = nodes.find((n) => n.id === nodeId); @@ -787,7 +787,15 @@ export const useWorkflowStore = create((set, get) => ({ // Map normalized handle ID to schema name for dynamicInputs // This allows API to receive schema-specific parameter names if (handleId && handleToSchemaName[handleId]) { - dynamicInputs[handleToSchemaName[handleId]] = value; + const schemaName = handleToSchemaName[handleId]; + const existing = dynamicInputs[schemaName]; + if (existing !== undefined) { + dynamicInputs[schemaName] = Array.isArray(existing) + ? [...existing, value] + : [existing, value]; + } else { + dynamicInputs[schemaName] = value; + } } // Route to typed arrays based on source output type @@ -1013,7 +1021,10 @@ export const useWorkflowStore = create((set, get) => ({ const { images, text, dynamicInputs } = getConnectedInputs(node.id); // For dynamic inputs, check if we have at least a prompt - const promptText = text || dynamicInputs.prompt || null; + const promptFromDynamic = Array.isArray(dynamicInputs.prompt) + ? dynamicInputs.prompt[0] + : dynamicInputs.prompt; + const promptText = text || promptFromDynamic || null; if (!promptText) { logger.error('node.error', 'nanoBanana node missing text input', { nodeId: node.id, From 65dbf79c8aa52cfe9017a804259d0937c3a0fd64 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 21:14:39 +1300 Subject: [PATCH 2/5] test(quick-001): add regression tests for multi-image dynamicInputs aggregation - Test: multiple images to same schema-mapped handle aggregate into array - Test: single image to schema-mapped handle stays as plain string - Test: multiple images with distinct schema handles remain separate strings - Test: three images to same handle produce array of length 3 - Fix react-dom version mismatch (19.2.0 -> 19.2.3) to match react Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 10 +- package.json | 2 +- .../workflowStore.integration.test.ts | 127 ++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76009965..06c8625f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "next": "^16.0.6", "postcss": "^8.5.6", "react": "^19.2.0", - "react-dom": "^19.2.0", + "react-dom": "^19.2.3", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", "zustand": "^5.0.9" @@ -4767,15 +4767,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.3" } }, "node_modules/react-is": { diff --git a/package.json b/package.json index bc06c2f8..51e5118c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "next": "^16.0.6", "postcss": "^8.5.6", "react": "^19.2.0", - "react-dom": "^19.2.0", + "react-dom": "^19.2.3", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", "zustand": "^5.0.9" diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index 55e72234..315f4701 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -365,6 +365,133 @@ describe("workflowStore integration tests", () => { }); }); + describe("Multi-image dynamicInputs aggregation", () => { + it("should aggregate multiple images to same schema-mapped handle into array", () => { + const store = useWorkflowStore.getState(); + const img1 = "data:image/png;base64,img1"; + const img2 = "data:image/png;base64,img2"; + + useWorkflowStore.setState({ + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: img1 }), + createTestNode("imageInput-2", "imageInput", { image: img2 }), + createTestNode("nanoBanana-1", "nanoBanana", { + inputSchema: [ + { name: "image_urls", type: "image", required: true, label: "Images" }, + ], + }), + ], + edges: [ + createTestEdge("imageInput-1", "nanoBanana-1", "image", "image"), + createTestEdge("imageInput-2", "nanoBanana-1", "image", "image"), + ], + }); + + const result = store.getConnectedInputs("nanoBanana-1"); + + // Both images should be aggregated into an array under the schema name + expect(Array.isArray(result.dynamicInputs["image_urls"])).toBe(true); + expect(result.dynamicInputs["image_urls"]).toHaveLength(2); + expect(result.dynamicInputs["image_urls"]).toContain(img1); + expect(result.dynamicInputs["image_urls"]).toContain(img2); + + // images array should also contain both + expect(result.images).toHaveLength(2); + expect(result.images).toContain(img1); + expect(result.images).toContain(img2); + }); + + it("should keep single image to schema-mapped handle as string", () => { + const store = useWorkflowStore.getState(); + const img1 = "data:image/png;base64,img1"; + + useWorkflowStore.setState({ + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: img1 }), + createTestNode("nanoBanana-1", "nanoBanana", { + inputSchema: [ + { name: "image_urls", type: "image", required: true, label: "Images" }, + ], + }), + ], + edges: [ + createTestEdge("imageInput-1", "nanoBanana-1", "image", "image"), + ], + }); + + const result = store.getConnectedInputs("nanoBanana-1"); + + // Single image should be a plain string, not wrapped in array + expect(result.dynamicInputs["image_urls"]).toBe(img1); + expect(Array.isArray(result.dynamicInputs["image_urls"])).toBe(false); + }); + + it("should keep multiple images with distinct schema handles as separate strings", () => { + const store = useWorkflowStore.getState(); + const img1 = "data:image/png;base64,img1"; + const img2 = "data:image/png;base64,img2"; + + useWorkflowStore.setState({ + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: img1 }), + createTestNode("imageInput-2", "imageInput", { image: img2 }), + createTestNode("generateVideo-1", "generateVideo", { + inputSchema: [ + { name: "start_image_url", type: "image", required: true, label: "Start" }, + { name: "end_image_url", type: "image", required: false, label: "End" }, + ], + }), + ], + edges: [ + createTestEdge("imageInput-1", "generateVideo-1", "image", "image-0"), + createTestEdge("imageInput-2", "generateVideo-1", "image", "image-1"), + ], + }); + + const result = store.getConnectedInputs("generateVideo-1"); + + // Each should be a plain string, not an array + expect(result.dynamicInputs["start_image_url"]).toBe(img1); + expect(result.dynamicInputs["end_image_url"]).toBe(img2); + expect(Array.isArray(result.dynamicInputs["start_image_url"])).toBe(false); + expect(Array.isArray(result.dynamicInputs["end_image_url"])).toBe(false); + }); + + it("should produce array of length 3 when three images connect to same handle", () => { + const store = useWorkflowStore.getState(); + const img1 = "data:image/png;base64,img1"; + const img2 = "data:image/png;base64,img2"; + const img3 = "data:image/png;base64,img3"; + + useWorkflowStore.setState({ + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: img1 }), + createTestNode("imageInput-2", "imageInput", { image: img2 }), + createTestNode("imageInput-3", "imageInput", { image: img3 }), + createTestNode("nanoBanana-1", "nanoBanana", { + inputSchema: [ + { name: "image_urls", type: "image", required: true, label: "Images" }, + ], + }), + ], + edges: [ + createTestEdge("imageInput-1", "nanoBanana-1", "image", "image"), + createTestEdge("imageInput-2", "nanoBanana-1", "image", "image"), + createTestEdge("imageInput-3", "nanoBanana-1", "image", "image"), + ], + }); + + const result = store.getConnectedInputs("nanoBanana-1"); + + // Should be array of length 3 + expect(Array.isArray(result.dynamicInputs["image_urls"])).toBe(true); + expect(result.dynamicInputs["image_urls"]).toHaveLength(3); + expect(result.dynamicInputs["image_urls"]).toContain(img1); + expect(result.dynamicInputs["image_urls"]).toContain(img2); + expect(result.dynamicInputs["image_urls"]).toContain(img3); + }); + }); + describe("Edge cases", () => { it("should return empty images array and null text when no connections", () => { const store = useWorkflowStore.getState(); From d339a486a488d79caebb543d6f01f9c21896eadf Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 22:07:57 +1300 Subject: [PATCH 3/5] fix: update react-dom to ^19.2.4, next to ^16.0.7, fix community workflow test Update vulnerable react-dom dependency and next.js to patched versions. Fix QuickstartTemplatesView test to mock two-step R2 download flow. Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 97 ++++++++++--------- package.json | 4 +- .../QuickstartTemplatesView.test.tsx | 16 ++- 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06c8625f..e0de7427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,10 @@ "autoprefixer": "^10.4.22", "jszip": "^3.10.1", "konva": "^10.0.12", - "next": "^16.0.6", + "next": "^16.0.7", "postcss": "^8.5.6", "react": "^19.2.0", - "react-dom": "^19.2.3", + "react-dom": "^19.2.4", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", "zustand": "^5.0.9" @@ -1605,15 +1605,15 @@ } }, "node_modules/@next/env": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.6.tgz", - "integrity": "sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.6.tgz", - "integrity": "sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", "cpu": [ "arm64" ], @@ -1627,9 +1627,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.6.tgz", - "integrity": "sha512-LlLLNrK9WCIUkq2GciWDcquXYIf7vLxX8XE49gz7EncssZGL1vlHwgmURiJsUZAvk0HM1a8qb1ABDezsjAE/jw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", "cpu": [ "x64" ], @@ -1643,9 +1643,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.6.tgz", - "integrity": "sha512-r04NzmLSGGfG8EPXKVK72N5zDNnq9pa9el78LhdtqIC3zqKh74QfKHnk24DoK4PEs6eY7sIK/CnNpt30oc59kg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", "cpu": [ "arm64" ], @@ -1659,9 +1659,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.6.tgz", - "integrity": "sha512-hfB/QV0hA7lbD1OJxp52wVDlpffUMfyxUB5ysZbb/pBC5iuhyLcEKSVQo56PFUUmUQzbMsAtUu6k2Gh9bBtWXA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", "cpu": [ "arm64" ], @@ -1675,9 +1675,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.6.tgz", - "integrity": "sha512-PZJushBgfvKhJBy01yXMdgL+l5XKr7uSn5jhOQXQXiH3iPT2M9iG64yHpPNGIKitKrHJInwmhPVGogZBAJOCPw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", "cpu": [ "x64" ], @@ -1691,9 +1691,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.6.tgz", - "integrity": "sha512-LqY76IojrH9yS5fyATjLzlOIOgwyzBuNRqXwVxcGfZ58DWNQSyfnLGlfF6shAEqjwlDNLh4Z+P0rnOI87Y9jEw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", "cpu": [ "x64" ], @@ -1707,9 +1707,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.6.tgz", - "integrity": "sha512-eIfSNNqAkj0tqKRf0u7BVjqylJCuabSrxnpSENY3YKApqwDMeAqYPmnOwmVe6DDl3Lvkbe7cJAyP6i9hQ5PmmQ==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", "cpu": [ "arm64" ], @@ -1723,9 +1723,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.6.tgz", - "integrity": "sha512-QGs18P4OKdK9y2F3Th42+KGnwsc2iaThOe6jxQgP62kslUU4W+g6AzI6bdIn/pslhSfxjAMU5SjakfT5Fyo/xA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", "cpu": [ "x64" ], @@ -4447,13 +4447,14 @@ } }, "node_modules/next": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.6.tgz", - "integrity": "sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", "dependencies": { - "@next/env": "16.0.6", + "@next/env": "16.1.6", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -4465,14 +4466,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.6", - "@next/swc-darwin-x64": "16.0.6", - "@next/swc-linux-arm64-gnu": "16.0.6", - "@next/swc-linux-arm64-musl": "16.0.6", - "@next/swc-linux-x64-gnu": "16.0.6", - "@next/swc-linux-x64-musl": "16.0.6", - "@next/swc-win32-arm64-msvc": "16.0.6", - "@next/swc-win32-x64-msvc": "16.0.6", + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { @@ -4758,24 +4759,24 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-is": { diff --git a/package.json b/package.json index 51e5118c..92b5c515 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,10 @@ "autoprefixer": "^10.4.22", "jszip": "^3.10.1", "konva": "^10.0.12", - "next": "^16.0.6", + "next": "^16.0.7", "postcss": "^8.5.6", "react": "^19.2.0", - "react-dom": "^19.2.3", + "react-dom": "^19.2.4", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", "zustand": "^5.0.9" diff --git a/src/components/__tests__/QuickstartTemplatesView.test.tsx b/src/components/__tests__/QuickstartTemplatesView.test.tsx index 8b57651f..5c35e177 100644 --- a/src/components/__tests__/QuickstartTemplatesView.test.tsx +++ b/src/components/__tests__/QuickstartTemplatesView.test.tsx @@ -464,7 +464,13 @@ describe("QuickstartTemplatesView", () => { if (url === "/api/community-workflows/comm-1") { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ success: true, workflow: mockWorkflow }), + json: () => Promise.resolve({ success: true, downloadUrl: "https://r2.example.com/comm-1.json" }), + }); + } + if (url === "https://r2.example.com/comm-1.json") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockWorkflow), }); } return Promise.resolve({ @@ -517,7 +523,13 @@ describe("QuickstartTemplatesView", () => { if (url === "/api/community-workflows/comm-1") { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ success: true, workflow: mockWorkflow }), + json: () => Promise.resolve({ success: true, downloadUrl: "https://r2.example.com/comm-1.json" }), + }); + } + if (url === "https://r2.example.com/comm-1.json") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockWorkflow), }); } return Promise.resolve({ From 3ed51330454b8de25909e82b16b4c1dd033169cd Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 23:24:11 +1300 Subject: [PATCH 4/5] fix: normalize directory path hostname prefix from macOS network volumes On macOS, osascript's folder picker can return hostname-prefixed paths (e.g. "HOSTNAME/Users/...") for network-mounted volumes instead of absolute POSIX paths, causing directory validation to fail. Co-Authored-By: Claude Opus 4.5 --- .../browse-directory/__tests__/route.test.ts | 49 +++++++++++++++++++ src/app/api/browse-directory/route.ts | 35 ++++++++++--- src/components/ProjectSetupModal.tsx | 6 +++ .../__tests__/ProjectSetupModal.test.tsx | 35 +++++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 src/app/api/browse-directory/__tests__/route.test.ts diff --git a/src/app/api/browse-directory/__tests__/route.test.ts b/src/app/api/browse-directory/__tests__/route.test.ts new file mode 100644 index 00000000..5eff72ad --- /dev/null +++ b/src/app/api/browse-directory/__tests__/route.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSelectedPath } from "../route"; + +describe("normalizeSelectedPath", () => { + it("should strip hostname prefix on macOS", () => { + expect(normalizeSelectedPath("AT-ALGKG9VR/Users/guy/Desktop", "darwin")) + .toBe("/Users/guy/Desktop"); + }); + + it("should preserve absolute paths on macOS", () => { + expect(normalizeSelectedPath("/Users/guy/Desktop", "darwin")) + .toBe("/Users/guy/Desktop"); + }); + + it("should remove trailing slash", () => { + expect(normalizeSelectedPath("/Users/guy/Desktop/", "darwin")) + .toBe("/Users/guy/Desktop"); + }); + + it("should strip hostname and trailing slash", () => { + expect(normalizeSelectedPath("HOST/Users/guy/", "darwin")) + .toBe("/Users/guy"); + }); + + it("should strip hostname prefix on Linux", () => { + expect(normalizeSelectedPath("hostname/home/user", "linux")) + .toBe("/home/user"); + }); + + it("should not modify Windows drive paths", () => { + expect(normalizeSelectedPath("C:\\Users\\guy", "win32")) + .toBe("C:\\Users\\guy"); + }); + + it("should preserve Windows drive root", () => { + expect(normalizeSelectedPath("C:\\", "win32")) + .toBe("C:\\"); + }); + + it("should preserve Unix root /", () => { + expect(normalizeSelectedPath("/", "darwin")) + .toBe("/"); + }); + + it("should leave hostname-only (no slash) as-is", () => { + expect(normalizeSelectedPath("HOSTNAME", "darwin")) + .toBe("HOSTNAME"); + }); +}); diff --git a/src/app/api/browse-directory/route.ts b/src/app/api/browse-directory/route.ts index 607f2ac2..a6cd2304 100644 --- a/src/app/api/browse-directory/route.ts +++ b/src/app/api/browse-directory/route.ts @@ -7,6 +7,33 @@ import { join } from "path"; const execAsync = promisify(exec); +/** + * Normalize a path returned by native directory pickers. + * On macOS, osascript can return hostname-prefixed paths for network volumes + * (e.g. "HOSTNAME/Users/..." instead of "/Users/..."). This strips the + * hostname prefix and cleans up trailing slashes. + */ +export function normalizeSelectedPath(selectedPath: string, platform: string): string { + // On macOS/Linux, ensure the path is absolute. + // osascript can return hostname-prefixed paths for network volumes + // e.g. "AT-ALGKG9VR/Users/guy/Desktop" instead of "/Users/guy/Desktop" + if ((platform === "darwin" || platform === "linux") && !selectedPath.startsWith("/")) { + const firstSlash = selectedPath.indexOf("/"); + if (firstSlash >= 0) { + selectedPath = selectedPath.substring(firstSlash); + } + } + + // Remove trailing slash/backslash (except root paths like "/" or "C:\") + if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) { + if (!(platform === "win32" && /^[A-Za-z]:\\$/.test(selectedPath))) { + selectedPath = selectedPath.slice(0, -1); + } + } + + return selectedPath; +} + // GET: Open native directory picker and return the selected path export async function GET() { const platform = process.platform; @@ -121,13 +148,7 @@ if ($result) { Write-Output $result } }); } - // Remove trailing slash/backslash if present (except for root paths like "/" or "C:\") - if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) { - // Don't remove trailing slash from Windows drive roots like "C:\" - if (!(platform === "win32" && /^[A-Za-z]:\\$/.test(selectedPath))) { - selectedPath = selectedPath.slice(0, -1); - } - } + selectedPath = normalizeSelectedPath(selectedPath, platform); return NextResponse.json({ success: true, diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 0812ccfa..c69b0fe0 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -199,6 +199,12 @@ export function ProjectSetupModal({ return; } + const trimmedPath = directoryPath.trim(); + if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:/.test(trimmedPath))) { + setError("Project directory must be an absolute path (starting with / or a drive letter)"); + return; + } + setIsValidating(true); setError(null); diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index b9b0dffb..1e6d1173 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -382,6 +382,41 @@ describe("ProjectSetupModal", () => { expect(onSave).not.toHaveBeenCalled(); }); + it("should show error when path is not absolute", async () => { + const onSave = vi.fn(); + + render( + + ); + + // Fill name and a hostname-prefixed relative path + fireEvent.change(screen.getByPlaceholderText("my-project"), { + target: { value: "My Project" }, + }); + fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { + target: { value: "AT-ALGKG9VR/Users/guy/Desktop/AI Project" }, + }); + + // Click Create + fireEvent.click(screen.getByText("Create")); + + await waitFor(() => { + expect( + screen.getByText("Project directory must be an absolute path (starting with / or a drive letter)") + ).toBeInTheDocument(); + }); + expect(onSave).not.toHaveBeenCalled(); + // Validation should fail client-side without making a fetch to /api/workflow + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/api/workflow") + ); + }); + it("should show error when path is not a directory", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { From 9624c44e4aa80a53f23173131ee671197d354b67 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 23:33:47 +1300 Subject: [PATCH 5/5] fix: handle forward-slash drive roots and UNC paths in validation Broaden the trailing-slash exemption to recognize both C:\ and C:/ as drive roots. Tighten the client-side absolute path check to require a separator after the drive letter and accept UNC paths (\\server\share). Co-Authored-By: Claude Opus 4.5 --- src/app/api/browse-directory/__tests__/route.test.ts | 7 ++++++- src/app/api/browse-directory/route.ts | 2 +- src/components/ProjectSetupModal.tsx | 4 ++-- src/components/__tests__/ProjectSetupModal.test.tsx | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/api/browse-directory/__tests__/route.test.ts b/src/app/api/browse-directory/__tests__/route.test.ts index 5eff72ad..c325da55 100644 --- a/src/app/api/browse-directory/__tests__/route.test.ts +++ b/src/app/api/browse-directory/__tests__/route.test.ts @@ -32,11 +32,16 @@ describe("normalizeSelectedPath", () => { .toBe("C:\\Users\\guy"); }); - it("should preserve Windows drive root", () => { + it("should preserve Windows drive root with backslash", () => { expect(normalizeSelectedPath("C:\\", "win32")) .toBe("C:\\"); }); + it("should preserve Windows drive root with forward slash", () => { + expect(normalizeSelectedPath("C:/", "win32")) + .toBe("C:/"); + }); + it("should preserve Unix root /", () => { expect(normalizeSelectedPath("/", "darwin")) .toBe("/"); diff --git a/src/app/api/browse-directory/route.ts b/src/app/api/browse-directory/route.ts index a6cd2304..1f3df3b2 100644 --- a/src/app/api/browse-directory/route.ts +++ b/src/app/api/browse-directory/route.ts @@ -26,7 +26,7 @@ export function normalizeSelectedPath(selectedPath: string, platform: string): s // Remove trailing slash/backslash (except root paths like "/" or "C:\") if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) { - if (!(platform === "win32" && /^[A-Za-z]:\\$/.test(selectedPath))) { + if (!(platform === "win32" && /^[A-Za-z]:[\\\/]$/.test(selectedPath))) { selectedPath = selectedPath.slice(0, -1); } } diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index c69b0fe0..7476907a 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -200,8 +200,8 @@ export function ProjectSetupModal({ } const trimmedPath = directoryPath.trim(); - if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:/.test(trimmedPath))) { - setError("Project directory must be an absolute path (starting with / or a drive letter)"); + if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(trimmedPath) || trimmedPath.startsWith("\\\\"))) { + setError("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)"); return; } diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index 1e6d1173..ed1a0efb 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -407,7 +407,7 @@ describe("ProjectSetupModal", () => { await waitFor(() => { expect( - screen.getByText("Project directory must be an absolute path (starting with / or a drive letter)") + screen.getByText("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)") ).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled();